Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3704021cd | |||
| 24b5bff396 | |||
| 7cee00c331 | |||
| b5f32b7b2b | |||
| 8b8331c435 | |||
| 05a87960d1 | |||
| a40da25b8c | |||
| e3d3c48ce0 | |||
| 09543d1e11 |
@@ -125,3 +125,22 @@ jobs:
|
||||
cache: true
|
||||
- name: build webview viewer
|
||||
run: CGO_ENABLED=1 go build -tags webview ./cmd/kage
|
||||
|
||||
# Cross-compile the GUI-subsystem Windows base the release ships for
|
||||
# double-click viewers (kage pack --base). A change that breaks the
|
||||
# -H windowsgui link is caught here instead of at release time. Pure Go, so it
|
||||
# cross-compiles from Linux with no extra toolchain.
|
||||
windows-gui:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: build windowsgui base
|
||||
env:
|
||||
GOOS: windows
|
||||
CGO_ENABLED: "0"
|
||||
run: go build -ldflags "-H=windowsgui" -o kage-windowsgui.exe ./cmd/kage
|
||||
|
||||
+48
-3
@@ -43,9 +43,35 @@ builds:
|
||||
- freebsd_amd64
|
||||
- freebsd_arm64
|
||||
|
||||
# A second Windows build linked for the GUI subsystem (-H windowsgui). It is
|
||||
# the same pure-Go kage, but a viewer packed onto it shows only its window when
|
||||
# double-clicked, with no console flashing behind it. Users point `kage pack
|
||||
# --base` at this to build a clean double-click Windows app. The default build
|
||||
# above stays console-attached so the CLI still prints clone progress.
|
||||
- id: kage-gui
|
||||
binary: kage
|
||||
main: ./cmd/kage
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -H=windowsgui
|
||||
- -X github.com/tamnd/kage/cli.Version={{ .Version }}
|
||||
- -X github.com/tamnd/kage/cli.Commit={{ .ShortCommit }}
|
||||
- -X github.com/tamnd/kage/cli.Date={{ .CommitDate }}
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
targets:
|
||||
- windows_amd64
|
||||
- windows_arm64
|
||||
|
||||
archives:
|
||||
# tar.gz everywhere except a zip on Windows.
|
||||
# tar.gz everywhere except a zip on Windows. Scoped to the console build so
|
||||
# this is the archive every package manager installs.
|
||||
- id: default
|
||||
ids:
|
||||
- kage
|
||||
name_template: "kage_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
@@ -54,6 +80,18 @@ archives:
|
||||
- LICENSE
|
||||
- README.md
|
||||
|
||||
# A separate zip for the GUI-subsystem Windows binary, named so it cannot be
|
||||
# confused with the console build. It is a base to pack against, not something
|
||||
# to run directly, so no package manager points at it.
|
||||
- id: windows-gui
|
||||
ids:
|
||||
- kage-gui
|
||||
name_template: "kage_{{ .Version }}_windows-gui_{{ .Arch }}"
|
||||
formats: [zip]
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
|
||||
nfpms:
|
||||
# One nfpm definition emits the deb, rpm, and apk for every Linux build. kage
|
||||
# is a user command, not a daemon, so there is no unit file and no
|
||||
@@ -61,6 +99,8 @@ nfpms:
|
||||
# dependency the user supplies (or the container image bundles).
|
||||
- id: linux-packages
|
||||
package_name: kage
|
||||
ids:
|
||||
- kage
|
||||
file_name_template: "{{ .ConventionalFileName }}"
|
||||
vendor: tamnd
|
||||
homepage: https://github.com/tamnd/kage
|
||||
@@ -106,6 +146,8 @@ homebrew_casks:
|
||||
# HOMEBREW_TAP_GITHUB_TOKEN (a PAT with write to tamnd/homebrew-tap) is set,
|
||||
# so a tokenless release still writes the cask into dist for inspection.
|
||||
- name: kage
|
||||
ids:
|
||||
- default
|
||||
repository:
|
||||
owner: tamnd
|
||||
name: homebrew-tap
|
||||
@@ -119,8 +161,11 @@ homebrew_casks:
|
||||
email: tamnd87@gmail.com
|
||||
|
||||
scoops:
|
||||
# Scoop manifest for Windows, pushed to the bucket repository.
|
||||
- repository:
|
||||
# Scoop manifest for Windows, pushed to the bucket repository. It installs the
|
||||
# console build (the CLI), not the GUI base.
|
||||
- ids:
|
||||
- default
|
||||
repository:
|
||||
owner: tamnd
|
||||
name: scoop-bucket
|
||||
token: '{{ envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "" }}'
|
||||
|
||||
+34
-1
@@ -6,6 +6,37 @@ All notable changes to kage are recorded here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.1] - 2026-06-15
|
||||
|
||||
### Added
|
||||
|
||||
- ZIM archives now carry the metadata Kiwix and `zimcheck` treat as mandatory. Every archive gets a `Name` and a `Description` (a host-derived line when `--description` is not given), and, when the mirror has a usable favicon, an `Illustrator_48x48@1` entry: the icon rescaled to a 48x48 PNG, which is the book icon Kiwix shows in its library.
|
||||
|
||||
## [0.2.0] - 2026-06-15
|
||||
|
||||
### Added
|
||||
|
||||
- `kage pack --app` wraps the packed viewer in a double-click desktop app with
|
||||
the site's favicon as the icon. The flag builds on the binary format, so it
|
||||
composes with `--base` (including a `webview` base) and `--icon`. On macOS it
|
||||
writes a `.app` bundle (`Info.plist`, the viewer under `Contents/MacOS`, and an
|
||||
`.icns` generated from the favicon); on Linux, with a Linux `--base`, it writes
|
||||
an AppImage-style `.AppDir` and folds it into a single `.AppImage` when
|
||||
`appimagetool` is installed. The icon is found in the mirror automatically
|
||||
(preferring a large `apple-touch-icon.png`, then `favicon.png` or a PNG-based
|
||||
`favicon.ico`) and can be overridden with `--icon`.
|
||||
- The release now ships a GUI-subsystem Windows base,
|
||||
`kage_<version>_windows-gui_<arch>.zip`. Packing a viewer onto it with
|
||||
`--format binary --base` produces a `.exe` that opens with no console window
|
||||
behind it, the Windows equivalent of the `.app` double-click experience.
|
||||
|
||||
### Changed
|
||||
|
||||
- Cross-platform packing detects the base binary's target OS from its executable
|
||||
header (ELF, PE, or Mach-O) rather than its file name, so a Windows viewer
|
||||
always gets a `.exe` suffix and the run hint names the right platform even when
|
||||
the base is named without one.
|
||||
|
||||
## [0.1.2] - 2026-06-15
|
||||
|
||||
### Security
|
||||
@@ -84,7 +115,9 @@ can browse offline, with every script stripped out.
|
||||
a multi-arch container image on GHCR (Chromium bundled), checksums, SBOMs, and
|
||||
a cosign signature, all cut from one version tag by GoReleaser.
|
||||
|
||||
[Unreleased]: https://github.com/tamnd/kage/compare/v0.1.2...HEAD
|
||||
[Unreleased]: https://github.com/tamnd/kage/compare/v0.2.1...HEAD
|
||||
[0.2.1]: https://github.com/tamnd/kage/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/tamnd/kage/compare/v0.1.2...v0.2.0
|
||||
[0.1.2]: https://github.com/tamnd/kage/compare/v0.1.1...v0.1.2
|
||||
[0.1.1]: https://github.com/tamnd/kage/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://github.com/tamnd/kage/releases/tag/v0.1.0
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
**kage** (影, "shadow") clones a website into a folder you can browse offline, with every script stripped out. It opens each page in real headless Chrome, waits for the page to settle, snapshots the DOM a human would have seen, then deletes all the JavaScript and pulls the CSS, images, and fonts down to local paths. What lands on disk looks like the live site and runs no code.
|
||||
|
||||
[Install](#install) • [Quick start](#quick-start) • [Commands](#commands) • [Clone](#clone) • [Pack](#pack-it-into-one-file) • [Native window](#a-real-window-not-a-browser-tab) • [How it works](#how-it-works)
|
||||
[Install](#install) • [Quick start](#quick-start) • [Commands](#commands) • [Clone](#clone) • [Pack](#pack-it-into-one-file) • [Double-click app](#a-double-click-app) • [Native window](#a-real-window-not-a-browser-tab) • [How it works](#how-it-works)
|
||||
|
||||

|
||||
|
||||
@@ -65,7 +65,7 @@ kage pack paulgraham.com --format binary -o paulgraham
|
||||
| --- | --- |
|
||||
| `kage clone <url>` | render a site in headless Chrome and write a browsable, script-free mirror |
|
||||
| `kage serve [dir]` | preview a cloned folder over a local HTTP server |
|
||||
| `kage pack <mirror-dir>` | collapse a mirror into one ZIM archive, or a self-contained viewer binary |
|
||||
| `kage pack <mirror-dir>` | collapse a mirror into one ZIM archive, a self-contained viewer binary, or a double-click app |
|
||||
| `kage open <file.zim>` | serve a packed ZIM back for offline reading |
|
||||
|
||||
## Clone
|
||||
@@ -158,6 +158,32 @@ kage pack paulgraham.com --format binary --base kage-windows-amd64.exe # -> pa
|
||||
|
||||
The trade is size. The binary carries a whole kage, so it weighs around 13 MiB plus the site no matter how small the mirror is. When you only need the content, the ZIM is far leaner.
|
||||
|
||||
### A double-click app
|
||||
|
||||
A bare binary is great from a terminal, but double-click it in a file manager and the experience is rough: macOS opens a Terminal window behind the site, and on Windows a console flashes up next to it. Add `--app` and kage wraps the same viewer in a proper desktop app so a double-click just opens the site, no terminal, with the mirror's own favicon as the icon.
|
||||
|
||||
On macOS you get a real `.app` bundle:
|
||||
|
||||
```bash
|
||||
kage pack paulgraham.com --app # -> paulgraham.app
|
||||
open paulgraham.app # or double-click it in Finder
|
||||
```
|
||||
|
||||
On Linux, point `--base` at a Linux kage and you get an [AppImage](https://appimage.org)-style `.AppDir` with a `.desktop` launcher (`Terminal=false`, so no console). If [`appimagetool`](https://github.com/AppImage/appimagetool) is installed, kage folds it into a single double-clickable `.AppImage` for you:
|
||||
|
||||
```bash
|
||||
kage pack paulgraham.com --app --base kage-linux-amd64 # -> paulgraham.AppDir (+ .AppImage)
|
||||
```
|
||||
|
||||
kage finds the icon by digging the favicon out of the mirror (it prefers a large `apple-touch-icon.png` and falls back to `favicon.ico`); pass `--icon some.png` to override it. Pair `--app` with a `webview` base (below) and the double-click opens a native window instead of the browser, which is the full "it's an app" effect.
|
||||
|
||||
Windows needs no bundle, because there a single `.exe` already is the app. The catch is the console window. The release ships a `kage_<version>_windows-gui_<arch>.zip` whose binary is linked for the GUI subsystem, so a viewer packed onto it opens with no console behind it:
|
||||
|
||||
```bash
|
||||
# Build a console-free Windows viewer (from any OS)
|
||||
kage pack paulgraham.com --format binary --base kage-windows-gui-amd64.exe # -> paulgraham.exe
|
||||
```
|
||||
|
||||
## A real window, not a browser tab
|
||||
|
||||
By default a packed binary opens your system browser, which means the site shows up as yet another tab, address bar and all, next to the 47 you already have open. Build kage with the `webview` tag and it opens the site in its own window instead, backed by the operating system's WebView (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux). Paul Graham's essays, offline, in something that looks and feels like a real app:
|
||||
|
||||
+198
-2
@@ -2,7 +2,9 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -19,6 +21,8 @@ type packFlags struct {
|
||||
format string
|
||||
out string
|
||||
base string
|
||||
app bool
|
||||
icon string
|
||||
noCompress bool
|
||||
title string
|
||||
description string
|
||||
@@ -34,7 +38,9 @@ func newPackCmd() *cobra.Command {
|
||||
Long: "pack turns a cloned folder into one distributable file. With --format zim\n" +
|
||||
"it writes an open ZIM archive (the format Kiwix uses) that kage open or any\n" +
|
||||
"ZIM reader can browse. With --format binary it appends that archive to a copy\n" +
|
||||
"of kage, producing a single executable that serves the site offline when run.",
|
||||
"of kage, producing a single executable that serves the site offline when run.\n" +
|
||||
"Add --app to wrap that executable in a double-click desktop app (a .app bundle\n" +
|
||||
"on macOS, an AppImage-style .AppDir on Linux) with the site's favicon as the icon.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPack(args[0], f)
|
||||
@@ -43,7 +49,9 @@ func newPackCmd() *cobra.Command {
|
||||
fs := cmd.Flags()
|
||||
fs.StringVar(&f.format, "format", "zim", "output format: zim or binary")
|
||||
fs.StringVarP(&f.out, "out", "o", "", "output path (default per format)")
|
||||
fs.StringVar(&f.base, "base", "", "base kage binary for --format binary (default this kage)")
|
||||
fs.StringVar(&f.base, "base", "", "base kage binary for the viewer (default this kage)")
|
||||
fs.BoolVar(&f.app, "app", false, "wrap the viewer in a double-click desktop app (.app on macOS, .AppImage/.AppDir on Linux)")
|
||||
fs.StringVar(&f.icon, "icon", "", "icon file for --app (default the site's favicon)")
|
||||
fs.BoolVar(&f.noCompress, "no-compress", false, "store every cluster raw, no zstd")
|
||||
fs.StringVar(&f.title, "title", "", "archive title (default the main page's <title>)")
|
||||
fs.StringVar(&f.description, "description", "", "archive description")
|
||||
@@ -64,6 +72,12 @@ func runPack(mirrorArg string, f *packFlags) error {
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
// --app wraps the packed viewer in a desktop bundle. It builds on the binary
|
||||
// format, so it owns the flow rather than being one more --format value.
|
||||
if f.app {
|
||||
return runPackApp(dir, f, zopts)
|
||||
}
|
||||
|
||||
switch f.format {
|
||||
case "zim":
|
||||
out, size, err := pack.BuildZIM(dir, zopts)
|
||||
@@ -102,6 +116,188 @@ func runPack(mirrorArg string, f *packFlags) error {
|
||||
}
|
||||
}
|
||||
|
||||
// runPackApp builds a double-clickable desktop app around the packed viewer,
|
||||
// shaped for whichever OS the base targets: a .app bundle on macOS, an
|
||||
// AppImage-style .AppDir on Linux. Windows needs no bundle (the .exe is the
|
||||
// app), so it is redirected to --format binary with a GUI base.
|
||||
func runPackApp(dir string, f *packFlags, zopts pack.ZIMOptions) error {
|
||||
target := resolveTargetOS(f.base)
|
||||
switch target {
|
||||
case "windows":
|
||||
return fmt.Errorf("a Windows app is just the .exe, with no bundle to build: use --format binary and a GUI base (kage built with -ldflags -H=windowsgui)")
|
||||
case "":
|
||||
if f.base != "" {
|
||||
return fmt.Errorf("--app could not tell which OS %q is for; pass a macOS or Linux kage as --base", f.base)
|
||||
}
|
||||
// No base and an unknown runtime: fall through with the host's GOOS.
|
||||
target = runtime.GOOS
|
||||
}
|
||||
|
||||
prog := defaultBinaryName(dir)
|
||||
name := f.title
|
||||
if name == "" {
|
||||
name = prog
|
||||
}
|
||||
icon, iconSrc, err := resolveIcon(dir, f.icon)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zbytes, err := pack.BuildZIMBytes(dir, zopts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch target {
|
||||
case "darwin":
|
||||
return packMacApp(zbytes, dir, f, prog, name, icon, iconSrc)
|
||||
case "linux":
|
||||
return packLinuxApp(zbytes, dir, f, prog, name, icon, iconSrc)
|
||||
default:
|
||||
return fmt.Errorf("--app supports macOS and Linux bases; %s is not one of them", osLabel(target))
|
||||
}
|
||||
}
|
||||
|
||||
// packMacApp writes the .app bundle and prints how to launch it.
|
||||
func packMacApp(zbytes []byte, dir string, f *packFlags, prog, name string, icon image.Image, iconSrc string) error {
|
||||
out := f.out
|
||||
if out == "" {
|
||||
out = prog + ".app"
|
||||
} else if !strings.HasSuffix(strings.ToLower(out), ".app") {
|
||||
out += ".app"
|
||||
}
|
||||
path, size, err := pack.BuildApp(zbytes, pack.AppOptions{
|
||||
Out: out,
|
||||
Base: f.base,
|
||||
Name: name,
|
||||
ExecName: prog,
|
||||
Identifier: bundleID(prog),
|
||||
Version: appVersion(),
|
||||
Icon: icon,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printPackResult(path, size)
|
||||
printIconLine(iconSrc)
|
||||
fmt.Fprintf(os.Stderr, " double-click %s to open the site offline\n", styleAccent.Render(filepath.Base(path)))
|
||||
if f.base == "" {
|
||||
fmt.Fprintln(os.Stderr, styleDim.Render(" (built around this kage; pass --base a webview build to open a native window instead of the browser)"))
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, styleDim.Render(" (macOS may quarantine it: xattr -dr com.apple.quarantine "+path+")"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// packLinuxApp writes the .AppDir and, when appimagetool is installed, folds it
|
||||
// into a single double-clickable .AppImage.
|
||||
func packLinuxApp(zbytes []byte, dir string, f *packFlags, prog, name string, icon image.Image, iconSrc string) error {
|
||||
out := f.out
|
||||
if out == "" {
|
||||
out = prog + ".AppDir"
|
||||
} else if !strings.HasSuffix(out, ".AppDir") {
|
||||
out += ".AppDir"
|
||||
}
|
||||
path, size, hasIcon, err := pack.BuildAppDir(zbytes, pack.LinuxAppOptions{
|
||||
Out: out,
|
||||
Base: f.base,
|
||||
Name: name,
|
||||
ExecName: prog,
|
||||
Comment: f.description,
|
||||
Version: appVersion(),
|
||||
Icon: icon,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printPackResult(path, size)
|
||||
printIconLine(iconSrc)
|
||||
|
||||
// appimagetool turns the directory into one portable file. It needs an icon,
|
||||
// so only attempt it when the mirror gave us one.
|
||||
if hasIcon {
|
||||
if img, ok := tryAppImage(path, prog); ok {
|
||||
fmt.Fprintf(os.Stderr, " built %s\n", styleTitle.Render(img))
|
||||
fmt.Fprintf(os.Stderr, " double-click %s to open the site offline\n", styleAccent.Render(filepath.Base(img)))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " run %s to open the site offline\n", styleAccent.Render("./"+filepath.Join(filepath.Base(path), "AppRun")))
|
||||
fmt.Fprintln(os.Stderr, styleDim.Render(" (install appimagetool to fold this .AppDir into one double-clickable .AppImage)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryAppImage runs appimagetool over the AppDir if it is installed, returning
|
||||
// the .AppImage path on success. A missing tool or a build failure is not fatal:
|
||||
// the caller falls back to the AppDir.
|
||||
func tryAppImage(appDir, prog string) (string, bool) {
|
||||
tool, err := exec.LookPath("appimagetool")
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
out := prog + ".AppImage"
|
||||
cmd := exec.Command(tool, appDir, out)
|
||||
// appimagetool reads the target arch from the AppRun ELF; suppress its noisy
|
||||
// progress so kage's own output stays clean, but surface a real failure.
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func printIconLine(iconSrc string) {
|
||||
if iconSrc != "" {
|
||||
fmt.Fprintf(os.Stderr, " icon %s\n", styleDim.Render(iconSrc))
|
||||
}
|
||||
}
|
||||
|
||||
// resolveIcon picks the bundle icon: an explicit --icon path if given (an error
|
||||
// there is fatal, since the user asked for that file), otherwise the site's
|
||||
// favicon discovered in the mirror. A mirror with no usable icon is fine; the
|
||||
// bundle just ships without a custom one.
|
||||
func resolveIcon(dir, iconFlag string) (img image.Image, src string, err error) {
|
||||
if iconFlag != "" {
|
||||
img, err = pack.DecodeIcon(iconFlag)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return img, iconFlag, nil
|
||||
}
|
||||
if img, src, ok := pack.FindIcon(dir); ok {
|
||||
return img, src, nil
|
||||
}
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
// bundleID builds a reverse-DNS CFBundleIdentifier from the program name,
|
||||
// keeping only characters Apple allows in an identifier.
|
||||
func bundleID(prog string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(prog) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
id := strings.Trim(b.String(), "-")
|
||||
if id == "" {
|
||||
id = "app"
|
||||
}
|
||||
return "com.kage." + id
|
||||
}
|
||||
|
||||
// appVersion uses kage's own version for the bundle, falling back to 1.0 for a
|
||||
// dev build whose version is the default "dev".
|
||||
func appVersion() string {
|
||||
if Version == "" || Version == "dev" {
|
||||
return "1.0"
|
||||
}
|
||||
return strings.TrimPrefix(Version, "v")
|
||||
}
|
||||
|
||||
// resolveMirror accepts either a path to a mirror dir or a bare host. A bare
|
||||
// host that is not a directory in the working dir is resolved against the
|
||||
// default out dir, so "kage pack paulgraham.com" works right after a clone.
|
||||
|
||||
@@ -40,7 +40,7 @@ kage crawls breadth-first from a seed URL, staying within the seed's host (and o
|
||||
|
||||
## Then what?
|
||||
|
||||
A folder is the starting point, not the end. Once you have a mirror you can [pack it](/guides/packing-a-mirror/) into a single ZIM file, the open offline-archive format Kiwix uses, so the whole site travels as one file that any ZIM reader can open. Or build kage with the `webview` tag and a packed binary opens the site in its own native window instead of a browser tab:
|
||||
A folder is the starting point, not the end. Once you have a mirror you can [pack it](/guides/packing-a-mirror/) into a single ZIM file, the open offline-archive format Kiwix uses, so the whole site travels as one file that any ZIM reader can open. You can also pack it into a self-contained binary, or a double-click desktop app with the site's favicon as its icon. Or build kage with the `webview` tag and a packed binary opens the site in its own native window instead of a browser tab:
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ kage open paulgraham.com.zim # read it back with kage
|
||||
kiwix-serve paulgraham.com.zim # or serve it with Kiwix at http://localhost
|
||||
```
|
||||
|
||||
You can also double-click the file in the [Kiwix desktop app](https://kiwix.org/en/applications/), or load it on Kiwix for Android or iOS to read your mirror on your phone. One caveat: kage writes a structurally valid archive with the standard metadata, but it does not write the full-text search index that Kiwix's own packs ship with, so browsing works everywhere while in-reader search is limited.
|
||||
You can also double-click the file in the [Kiwix desktop app](https://kiwix.org/en/applications/), or load it on Kiwix for Android or iOS to read your mirror on your phone. kage writes the metadata the format and `zimcheck` treat as mandatory, including the title, description, and the favicon Kiwix shows as the book icon in its library, so the archive shows up properly rather than as an untitled, iconless entry. One caveat: kage does not write the full-text search index that Kiwix's own packs ship with, so browsing works everywhere while in-reader search is limited.
|
||||
|
||||
## A self-contained binary
|
||||
|
||||
@@ -99,6 +99,46 @@ A binary you built or downloaded may be quarantined by Gatekeeper on first run.
|
||||
xattr -d com.apple.quarantine ./paulgraham
|
||||
```
|
||||
|
||||
## A double-click app
|
||||
|
||||
The self-contained binary is perfect from a terminal, but double-clicking it in a file manager is less tidy: on macOS Finder opens a Terminal window behind the site, and on Windows a console flashes alongside it. Add `--app` and kage wraps the same viewer in a real desktop app, so a double-click just opens the mirror with no terminal in sight, using the site's own favicon as the icon.
|
||||
|
||||
On macOS it writes a standard `.app` bundle:
|
||||
|
||||
```bash
|
||||
kage pack paulgraham.com --app
|
||||
```
|
||||
|
||||
```
|
||||
packed paulgraham.app
|
||||
size 13.5 MiB
|
||||
icon paulgraham.com/apple-touch-icon.png
|
||||
double-click paulgraham.app to open the site offline
|
||||
```
|
||||
|
||||
The bundle holds the packed viewer under `Contents/MacOS`, an `Info.plist` describing the app, and the icon converted to `Contents/Resources/icon.icns`. Double-click it in Finder, or run `open paulgraham.app`, and the site comes up with no console attached.
|
||||
|
||||
On Linux, point `--base` at a Linux kage and you get an [AppImage](https://appimage.org)-style `.AppDir`: the viewer as `AppRun`, a `.desktop` launcher with `Terminal=false`, and the icon as a PNG. When [`appimagetool`](https://github.com/AppImage/appimagetool) is on your `PATH`, kage runs it for you and turns the directory into one double-clickable `.AppImage`; otherwise it leaves the `.AppDir` ready for any AppImage tool.
|
||||
|
||||
```bash
|
||||
kage pack paulgraham.com --app --base kage-linux-amd64 # -> paulgraham.AppDir (+ .AppImage)
|
||||
```
|
||||
|
||||
kage picks the icon by digging through the mirror for the site's favicon. It prefers a large `apple-touch-icon.png` and falls back to `favicon.png` or a PNG-based `favicon.ico`; if a site only ships a legacy BMP `.ico` the bundle is built without a custom icon rather than with a mangled one. Override the choice with `--icon path/to/image.png`.
|
||||
|
||||
For the full "it's an app" effect, pair `--app` with a `webview` base so the double-click opens a native window instead of the system browser:
|
||||
|
||||
```bash
|
||||
make build-webview
|
||||
kage pack paulgraham.com --app --base bin/kage
|
||||
```
|
||||
|
||||
Windows needs no bundle, because there a single `.exe` already is the app. What it needs is to lose the console window. A normal build is console-attached (handy for the CLI, since that is where clone progress prints), so the release ships a second Windows binary linked for the GUI subsystem in `kage_<version>_windows-gui_<arch>.zip`. Pack a viewer onto that base and double-clicking the result opens the site with no console behind it:
|
||||
|
||||
```bash
|
||||
kage pack paulgraham.com --format binary --base kage-windows-gui-amd64.exe # -> paulgraham.exe
|
||||
```
|
||||
|
||||
## Metadata and options
|
||||
|
||||
```bash
|
||||
@@ -109,4 +149,6 @@ kage pack paulgraham.com \
|
||||
--date 2026-06-14
|
||||
```
|
||||
|
||||
`--title` defaults to the main page's `<title>`, then the host name. `--date` defaults to today; pass a fixed value for a fully reproducible file. `--no-compress` stores every cluster raw, which packs fastest and lets a reader without zstd open the result. `-o/--out` overrides the output path for either format.
|
||||
`--title` defaults to the main page's `<title>`, then the host name. `--description` defaults to a line derived from the host, since a description is mandatory metadata. `--date` defaults to today; pass a fixed value for a fully reproducible file. `--no-compress` stores every cluster raw, which packs fastest and lets a reader without zstd open the result. `-o/--out` overrides the output path for either format.
|
||||
|
||||
Beyond these, kage fills in the rest of the mandatory metadata on its own: a unique `Name`, the `Language`, and an `Illustrator_48x48@1` icon. The icon is the site's favicon dug out of the mirror (an `apple-touch-icon.png`, `favicon.png`, or a PNG-based `favicon.ico`) and rescaled to a 48x48 PNG, the same discovery `--app` uses for the desktop icon. A site that ships only a legacy BMP `.ico` or no icon at all is packed without one rather than with a broken image.
|
||||
|
||||
@@ -69,7 +69,6 @@ images, and fonts, and writes a browsable mirror to `<out>/<host>/`.
|
||||
| `--workers` | `4` | Concurrent page render workers |
|
||||
| `--asset-workers` | `8` | Concurrent asset download workers |
|
||||
| `--browser-pages` | `4` | Chrome page-pool size |
|
||||
| `--max-asset-mb` | `25` | Skip assets larger than N MB |
|
||||
| `--timeout` | `30s` | Per-request timeout |
|
||||
| `-q, --quiet` | `false` | Suppress per-page progress lines |
|
||||
|
||||
@@ -102,9 +101,11 @@ works right after `kage clone example.com`.
|
||||
| `--format` | `zim` | Output format: `zim` or `binary` |
|
||||
| `-o, --out` | per format | Output path; `<host>.zim` for zim, `<host>` (or `<host>.exe`) for binary |
|
||||
| `--base` | this kage | Base kage binary to append to (`--format binary`); point at another platform's binary to build a viewer for it |
|
||||
| `--app` | `false` | Wrap the viewer in a double-click desktop app (`.app` on macOS, `.AppImage`/`.AppDir` on Linux) with the site's favicon as the icon |
|
||||
| `--icon` | site favicon | Icon file for `--app`, overriding the favicon found in the mirror |
|
||||
| `--no-compress` | `false` | Store every cluster raw, no zstd |
|
||||
| `--title` | main page `<title>` | Archive title |
|
||||
| `--description` | | Archive description |
|
||||
| `--description` | host-derived line | Archive description (mandatory metadata, defaulted when unset) |
|
||||
| `--language` | `eng` | Archive language code |
|
||||
| `--date` | today | Archive date (`YYYY-MM-DD`); pass a fixed value for a reproducible file |
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ $HOME/data/kage/example.com/
|
||||
│ │ ├── logo.png
|
||||
│ │ └── fonts/body.woff2
|
||||
│ ├── cdn.example.com/ # assets from other hosts, by host
|
||||
│ └── state.json # visited set, for --resume
|
||||
│ └── state.json # visited set, for resume
|
||||
└── ...
|
||||
```
|
||||
|
||||
|
||||
@@ -6,6 +6,21 @@ weight: 40
|
||||
|
||||
The authoritative, commit-level history lives in [`CHANGELOG.md`](https://github.com/tamnd/kage/blob/main/CHANGELOG.md) and on the [releases page](https://github.com/tamnd/kage/releases). This page summarises each version.
|
||||
|
||||
## v0.2.1
|
||||
|
||||
Packed ZIM archives now carry the metadata Kiwix expects, so a mirror shows up in a ZIM reader's library with a title, a description, and an icon instead of as a blank entry.
|
||||
|
||||
- **Mandatory metadata is always written.** Every archive now gets a `Name` and a `Description` (a line derived from the host when `--description` is not given), the two fields `zimcheck` flags as missing otherwise.
|
||||
- **The favicon becomes the book icon.** When the mirror has a usable icon (an `apple-touch-icon.png`, `favicon.png`, or a PNG-based `favicon.ico`), kage rescales it to a 48x48 PNG and stores it as `Illustrator_48x48@1`, which is the icon Kiwix shows for the archive in its library. A site with no usable icon is packed without one rather than with a broken image.
|
||||
|
||||
## v0.2.0
|
||||
|
||||
Double-click apps, so a packed mirror opens like a real desktop app instead of a terminal program.
|
||||
|
||||
- **`kage pack --app`** wraps the viewer in a double-click app with the site's favicon as its icon. The flag builds on the binary format, so it composes with `--base` (including a `webview` base) and `--icon`. On macOS that is a `.app` bundle; on Linux, with a Linux `--base`, an [AppImage](https://appimage.org)-style `.AppDir` that becomes a single `.AppImage` when `appimagetool` is installed. The icon is pulled from the mirror automatically, or set with `--icon`.
|
||||
- **A GUI-subsystem Windows base** ships in the release as `kage_<version>_windows-gui_<arch>.zip`. Pack a viewer onto it with `--format binary --base` and the resulting `.exe` opens with no console window behind it.
|
||||
- **Smarter cross-platform packing.** kage reads the base binary's executable header to detect its target OS, so a Windows viewer always gets a `.exe` name and the right run hint, regardless of how the base file is named.
|
||||
|
||||
## v0.1.2
|
||||
|
||||
A security fix for how kage launches Chrome, clearer crawl errors, and a container image that actually runs.
|
||||
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
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/image v0.42.0
|
||||
golang.org/x/net v0.56.0
|
||||
)
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@ github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AppOptions controls how a macOS .app bundle is assembled around a packed
|
||||
// viewer.
|
||||
type AppOptions struct {
|
||||
Out string // path to the .app directory
|
||||
Base string // base kage binary (must be a macOS build); default os.Executable()
|
||||
Name string // display name shown in Finder and the Dock
|
||||
ExecName string // file name of the executable inside Contents/MacOS
|
||||
Identifier string // CFBundleIdentifier, e.g. com.kage.paulgraham
|
||||
Version string // CFBundleShortVersionString; default 1.0
|
||||
Icon image.Image // optional; written as Resources/icon.icns
|
||||
}
|
||||
|
||||
// BuildApp writes a double-clickable macOS application bundle that serves the
|
||||
// packed site. Finder runs Contents/MacOS/<ExecName> with no terminal attached,
|
||||
// which is the whole point: the bare appended binary opens a Terminal window
|
||||
// when double-clicked, a .app does not. The executable is the same
|
||||
// base++zim++trailer image BuildBinary produces, so Embedded finds the archive
|
||||
// at runtime exactly as it does for a plain viewer.
|
||||
//
|
||||
// It returns the bundle path and the size of the executable inside it (the part
|
||||
// that dominates, since the plist and icon are tiny).
|
||||
func BuildApp(zimBytes []byte, opts AppOptions) (string, int64, error) {
|
||||
if opts.Out == "" {
|
||||
return "", 0, fmt.Errorf("pack: BuildApp requires an output path")
|
||||
}
|
||||
if filepath.Ext(opts.Out) != ".app" {
|
||||
return "", 0, fmt.Errorf("pack: app bundle path must end in .app, got %q", opts.Out)
|
||||
}
|
||||
|
||||
base := opts.Base
|
||||
if base == "" {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("pack: locate base binary: %w", err)
|
||||
}
|
||||
base = exe
|
||||
}
|
||||
baseBytes, err := os.ReadFile(base)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("pack: read base binary %q: %w", base, err)
|
||||
}
|
||||
|
||||
execName := opts.ExecName
|
||||
if execName == "" {
|
||||
execName = "kage"
|
||||
}
|
||||
name := opts.Name
|
||||
if name == "" {
|
||||
name = execName
|
||||
}
|
||||
version := opts.Version
|
||||
if version == "" {
|
||||
version = "1.0"
|
||||
}
|
||||
|
||||
// Start from a clean bundle so a rebuild never leaves a stale icon or
|
||||
// executable behind. The path is known to end in .app from the guard above.
|
||||
if err := os.RemoveAll(opts.Out); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
contents := filepath.Join(opts.Out, "Contents")
|
||||
macOS := filepath.Join(contents, "MacOS")
|
||||
resources := filepath.Join(contents, "Resources")
|
||||
if err := os.MkdirAll(macOS, 0o755); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
hasIcon := opts.Icon != nil
|
||||
if hasIcon {
|
||||
if err := os.MkdirAll(resources, 0o755); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
icns, err := EncodeICNS(opts.Icon)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(resources, "icon.icns"), icns, 0o644); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
|
||||
plist := infoPlist(infoPlistData{
|
||||
Name: name,
|
||||
ExecName: execName,
|
||||
Identifier: opts.Identifier,
|
||||
Version: version,
|
||||
HasIcon: hasIcon,
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(contents, "Info.plist"), []byte(plist), 0o644); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
// PkgInfo is the eight-byte legacy type/creator stamp; APPL???? is the
|
||||
// generic application value every modern bundle uses.
|
||||
if err := os.WriteFile(filepath.Join(contents, "PkgInfo"), []byte("APPL????"), 0o644); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
exe := assemble(baseBytes, zimBytes)
|
||||
if err := os.WriteFile(filepath.Join(macOS, execName), exe, 0o755); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return opts.Out, int64(len(exe)), nil
|
||||
}
|
||||
|
||||
type infoPlistData struct {
|
||||
Name string
|
||||
ExecName string
|
||||
Identifier string
|
||||
Version string
|
||||
HasIcon bool
|
||||
}
|
||||
|
||||
// infoPlist renders the bundle's Info.plist. Values are XML-escaped so a site
|
||||
// title with an ampersand or angle bracket cannot corrupt the file.
|
||||
func infoPlist(d infoPlistData) string {
|
||||
pairs := [][2]string{
|
||||
{"CFBundleName", d.Name},
|
||||
{"CFBundleDisplayName", d.Name},
|
||||
{"CFBundleExecutable", d.ExecName},
|
||||
{"CFBundleIdentifier", d.Identifier},
|
||||
{"CFBundleInfoDictionaryVersion", "6.0"},
|
||||
{"CFBundlePackageType", "APPL"},
|
||||
{"CFBundleShortVersionString", d.Version},
|
||||
{"CFBundleVersion", d.Version},
|
||||
{"LSMinimumSystemVersion", "10.13"},
|
||||
}
|
||||
if d.HasIcon {
|
||||
pairs = append(pairs, [2]string{"CFBundleIconFile", "icon"})
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(xml.Header)
|
||||
b.WriteString(`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n")
|
||||
b.WriteString(`<plist version="1.0">` + "\n<dict>\n")
|
||||
for _, kv := range pairs {
|
||||
b.WriteString("\t<key>" + esc(kv[0]) + "</key>\n")
|
||||
b.WriteString("\t<string>" + esc(kv[1]) + "</string>\n")
|
||||
}
|
||||
// NSHighResolutionCapable is a boolean, not a string, so the icon and text
|
||||
// render sharp on Retina displays.
|
||||
b.WriteString("\t<key>NSHighResolutionCapable</key>\n\t<true/>\n")
|
||||
b.WriteString("</dict>\n</plist>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func esc(s string) string {
|
||||
var b strings.Builder
|
||||
_ = xml.EscapeText(&b, []byte(s))
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildApp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := filepath.Join(dir, "kage-darwin")
|
||||
baseBytes := []byte("\xcf\xfa\xed\xfeMACHO-BASE-BYTES")
|
||||
if err := os.WriteFile(base, baseBytes, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zim := []byte("ZIM-ARCHIVE-PAYLOAD")
|
||||
out := filepath.Join(dir, "Paulgraham.app")
|
||||
|
||||
path, size, err := BuildApp(zim, AppOptions{
|
||||
Out: out,
|
||||
Base: base,
|
||||
Name: "Paul Graham",
|
||||
ExecName: "paulgraham",
|
||||
Identifier: "com.kage.paulgraham",
|
||||
Version: "1.0",
|
||||
Icon: solid(48, 48, color.RGBA{A: 0xff}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != out {
|
||||
t.Errorf("path = %q, want %q", path, out)
|
||||
}
|
||||
|
||||
// The bundle skeleton.
|
||||
exe := filepath.Join(out, "Contents", "MacOS", "paulgraham")
|
||||
for _, f := range []string{
|
||||
filepath.Join(out, "Contents", "Info.plist"),
|
||||
filepath.Join(out, "Contents", "PkgInfo"),
|
||||
filepath.Join(out, "Contents", "Resources", "icon.icns"),
|
||||
exe,
|
||||
} {
|
||||
if _, err := os.Stat(f); err != nil {
|
||||
t.Errorf("missing bundle file %s: %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The executable is base++zim++trailer, and size reports its length.
|
||||
exeBytes, err := os.ReadFile(exe)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if int64(len(exeBytes)) != size {
|
||||
t.Errorf("size = %d, want %d", size, len(exeBytes))
|
||||
}
|
||||
if !strings.HasPrefix(string(exeBytes), string(baseBytes)) {
|
||||
t.Error("executable does not start with the base bytes")
|
||||
}
|
||||
tr := exeBytes[len(exeBytes)-trailerLen:]
|
||||
if string(tr[:8]) != trailerMagic || string(tr[trailerLen-8:]) != trailerMagic {
|
||||
t.Error("trailer magic missing from the executable")
|
||||
}
|
||||
if got := binary.LittleEndian.Uint64(tr[8:16]); got != uint64(len(zim)) {
|
||||
t.Errorf("trailer records zim length %d, want %d", got, len(zim))
|
||||
}
|
||||
|
||||
// On a unix host the executable bit must be set so Finder can launch it.
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(exe)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm()&0o111 == 0 {
|
||||
t.Errorf("executable mode = %v, want the execute bit set", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// Info.plist carries the identity and points at the icon.
|
||||
plist, err := os.ReadFile(filepath.Join(out, "Contents", "Info.plist"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"<string>paulgraham</string>", // CFBundleExecutable
|
||||
"<string>Paul Graham</string>", // CFBundleName / DisplayName
|
||||
"<string>com.kage.paulgraham</string>", // CFBundleIdentifier
|
||||
"<key>CFBundleIconFile</key>",
|
||||
"<string>icon</string>",
|
||||
"NSHighResolutionCapable",
|
||||
} {
|
||||
if !strings.Contains(string(plist), want) {
|
||||
t.Errorf("Info.plist missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAppNoIcon(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := filepath.Join(dir, "kage")
|
||||
if err := os.WriteFile(base, []byte("\xcf\xfa\xed\xfeBASE"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(dir, "Site.app")
|
||||
if _, _, err := BuildApp([]byte("ZIM"), AppOptions{Out: out, Base: base, ExecName: "site"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// No icon means no Resources dir and no icon key in the plist.
|
||||
if _, err := os.Stat(filepath.Join(out, "Contents", "Resources")); !os.IsNotExist(err) {
|
||||
t.Errorf("Resources dir should be absent without an icon, got err=%v", err)
|
||||
}
|
||||
plist, err := os.ReadFile(filepath.Join(out, "Contents", "Info.plist"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(plist), "CFBundleIconFile") {
|
||||
t.Error("plist names an icon file but none was provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAppRejectsBadOut(t *testing.T) {
|
||||
if _, _, err := BuildApp([]byte("ZIM"), AppOptions{Out: "noapp", Base: "x"}); err == nil {
|
||||
t.Error("BuildApp should reject an output path without a .app extension")
|
||||
}
|
||||
if _, _, err := BuildApp([]byte("ZIM"), AppOptions{Out: ""}); err == nil {
|
||||
t.Error("BuildApp should reject an empty output path")
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LinuxAppOptions controls how a Linux application directory is assembled around
|
||||
// a packed viewer.
|
||||
type LinuxAppOptions struct {
|
||||
Out string // path to the .AppDir directory
|
||||
Base string // base kage binary (must be a Linux build); default os.Executable()
|
||||
Name string // display name shown in menus
|
||||
ExecName string // base name for the .desktop and icon files
|
||||
Comment string // optional one-line description for the launcher
|
||||
Version string // version string recorded in the .desktop
|
||||
Icon image.Image // optional; written as the launcher icon
|
||||
}
|
||||
|
||||
// BuildAppDir writes an AppImage-style application directory. The layout follows
|
||||
// the AppDir convention so `appimagetool` can fold it into a single
|
||||
// double-clickable .AppImage, but it is useful on its own: AppRun is the packed
|
||||
// viewer, the .desktop file launches it with Terminal=false (no console), and
|
||||
// the icon gives it a face in the file manager and menus.
|
||||
//
|
||||
// It returns the AppDir path, the size of the executable inside it, and whether
|
||||
// an icon was written (the caller needs that to decide if an .AppImage can be
|
||||
// built, since AppImage requires one).
|
||||
func BuildAppDir(zimBytes []byte, opts LinuxAppOptions) (path string, size int64, hasIcon bool, err error) {
|
||||
if opts.Out == "" {
|
||||
return "", 0, false, fmt.Errorf("pack: BuildAppDir requires an output path")
|
||||
}
|
||||
if !strings.HasSuffix(opts.Out, ".AppDir") {
|
||||
return "", 0, false, fmt.Errorf("pack: app dir path must end in .AppDir, got %q", opts.Out)
|
||||
}
|
||||
|
||||
base := opts.Base
|
||||
if base == "" {
|
||||
exe, e := os.Executable()
|
||||
if e != nil {
|
||||
return "", 0, false, fmt.Errorf("pack: locate base binary: %w", e)
|
||||
}
|
||||
base = exe
|
||||
}
|
||||
baseBytes, err := os.ReadFile(base)
|
||||
if err != nil {
|
||||
return "", 0, false, fmt.Errorf("pack: read base binary %q: %w", base, err)
|
||||
}
|
||||
|
||||
execName := opts.ExecName
|
||||
if execName == "" {
|
||||
execName = "kage"
|
||||
}
|
||||
name := opts.Name
|
||||
if name == "" {
|
||||
name = execName
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(opts.Out); err != nil {
|
||||
return "", 0, false, err
|
||||
}
|
||||
if err := os.MkdirAll(opts.Out, 0o755); err != nil {
|
||||
return "", 0, false, err
|
||||
}
|
||||
|
||||
// AppRun is the AppImage entrypoint; pointing it straight at the packed
|
||||
// binary keeps the directory to a single executable with no wrapper script.
|
||||
exe := assemble(baseBytes, zimBytes)
|
||||
if err := os.WriteFile(filepath.Join(opts.Out, "AppRun"), exe, 0o755); err != nil {
|
||||
return "", 0, false, err
|
||||
}
|
||||
|
||||
hasIcon = opts.Icon != nil
|
||||
if hasIcon {
|
||||
pngBytes, err := encodeIconPNG(opts.Icon, 256)
|
||||
if err != nil {
|
||||
return "", 0, false, err
|
||||
}
|
||||
// The icon lives at the root under the name the .desktop references, and
|
||||
// .DirIcon is the AppImage convention for the directory's own icon.
|
||||
if err := os.WriteFile(filepath.Join(opts.Out, execName+".png"), pngBytes, 0o644); err != nil {
|
||||
return "", 0, false, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(opts.Out, ".DirIcon"), pngBytes, 0o644); err != nil {
|
||||
return "", 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
desktop := desktopEntry(desktopData{
|
||||
Name: name,
|
||||
ExecName: execName,
|
||||
Comment: opts.Comment,
|
||||
Version: opts.Version,
|
||||
HasIcon: hasIcon,
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(opts.Out, execName+".desktop"), []byte(desktop), 0o644); err != nil {
|
||||
return "", 0, false, err
|
||||
}
|
||||
|
||||
return opts.Out, int64(len(exe)), hasIcon, nil
|
||||
}
|
||||
|
||||
type desktopData struct {
|
||||
Name string
|
||||
ExecName string
|
||||
Comment string
|
||||
Version string
|
||||
HasIcon bool
|
||||
}
|
||||
|
||||
// desktopEntry renders a freedesktop .desktop launcher. Terminal=false is the
|
||||
// line that keeps a console from opening, the Linux echo of the macOS .app.
|
||||
func desktopEntry(d desktopData) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[Desktop Entry]\n")
|
||||
b.WriteString("Type=Application\n")
|
||||
b.WriteString("Name=" + desktopValue(d.Name) + "\n")
|
||||
if d.Comment != "" {
|
||||
b.WriteString("Comment=" + desktopValue(d.Comment) + "\n")
|
||||
}
|
||||
// AppRun is on PATH inside a running AppImage, so Exec names it directly.
|
||||
b.WriteString("Exec=AppRun\n")
|
||||
if d.HasIcon {
|
||||
b.WriteString("Icon=" + d.ExecName + "\n")
|
||||
}
|
||||
b.WriteString("Categories=Network;Utility;\n")
|
||||
b.WriteString("Terminal=false\n")
|
||||
if d.Version != "" {
|
||||
b.WriteString("X-AppImage-Version=" + desktopValue(d.Version) + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// desktopValue strips the characters that would break a .desktop key=value line
|
||||
// (newlines and the leading-space/escape pitfalls), which is enough for a name
|
||||
// or comment drawn from a page title.
|
||||
func desktopValue(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// encodeIconPNG scales img to a px-by-px square and encodes it as PNG, the icon
|
||||
// format the freedesktop world expects.
|
||||
func encodeIconPNG(img image.Image, px int) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, scaleSquare(img, px)); err != nil {
|
||||
return nil, fmt.Errorf("pack: encode icon png: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildAppDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := filepath.Join(dir, "kage-linux")
|
||||
baseBytes := []byte("\x7fELF-LINUX-BASE")
|
||||
if err := os.WriteFile(base, baseBytes, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(dir, "paulgraham.AppDir")
|
||||
|
||||
path, size, hasIcon, err := BuildAppDir([]byte("ZIM-PAYLOAD"), LinuxAppOptions{
|
||||
Out: out,
|
||||
Base: base,
|
||||
Name: "Paul Graham",
|
||||
ExecName: "paulgraham",
|
||||
Comment: "Offline mirror",
|
||||
Version: "1.0",
|
||||
Icon: solid(64, 64, color.RGBA{B: 0xff, A: 0xff}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != out || !hasIcon {
|
||||
t.Fatalf("path=%q hasIcon=%v", path, hasIcon)
|
||||
}
|
||||
|
||||
apprun := filepath.Join(out, "AppRun")
|
||||
for _, f := range []string{
|
||||
apprun,
|
||||
filepath.Join(out, "paulgraham.desktop"),
|
||||
filepath.Join(out, "paulgraham.png"),
|
||||
filepath.Join(out, ".DirIcon"),
|
||||
} {
|
||||
if _, err := os.Stat(f); err != nil {
|
||||
t.Errorf("missing %s: %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
exe, err := os.ReadFile(apprun)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if int64(len(exe)) != size {
|
||||
t.Errorf("size = %d, want %d", size, len(exe))
|
||||
}
|
||||
if !strings.HasPrefix(string(exe), string(baseBytes)) {
|
||||
t.Error("AppRun does not start with the base bytes")
|
||||
}
|
||||
tr := exe[len(exe)-trailerLen:]
|
||||
if string(tr[:8]) != trailerMagic {
|
||||
t.Error("AppRun missing the trailer")
|
||||
}
|
||||
if info, _ := os.Stat(apprun); info.Mode().Perm()&0o111 == 0 {
|
||||
t.Error("AppRun is not executable")
|
||||
}
|
||||
|
||||
// The icon is a real 256px PNG.
|
||||
f, err := os.Open(filepath.Join(out, "paulgraham.png"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
img, err := png.Decode(f)
|
||||
if err != nil {
|
||||
t.Fatalf("icon is not a PNG: %v", err)
|
||||
}
|
||||
if img.Bounds().Dx() != 256 {
|
||||
t.Errorf("icon width = %d, want 256", img.Bounds().Dx())
|
||||
}
|
||||
|
||||
desktop, err := os.ReadFile(filepath.Join(out, "paulgraham.desktop"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Name=Paul Graham",
|
||||
"Exec=AppRun",
|
||||
"Icon=paulgraham",
|
||||
"Terminal=false",
|
||||
"Comment=Offline mirror",
|
||||
} {
|
||||
if !strings.Contains(string(desktop), want) {
|
||||
t.Errorf(".desktop missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAppDirNoIcon(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := filepath.Join(dir, "kage")
|
||||
if err := os.WriteFile(base, []byte("\x7fELF"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(dir, "site.AppDir")
|
||||
_, _, hasIcon, err := BuildAppDir([]byte("ZIM"), LinuxAppOptions{Out: out, Base: base, ExecName: "site"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hasIcon {
|
||||
t.Error("hasIcon should be false without an icon")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(out, "site.png")); !os.IsNotExist(err) {
|
||||
t.Errorf("icon should be absent, got err=%v", err)
|
||||
}
|
||||
desktop, _ := os.ReadFile(filepath.Join(out, "site.desktop"))
|
||||
if strings.Contains(string(desktop), "Icon=") {
|
||||
t.Error(".desktop names an icon but none was written")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAppDirRejectsBadOut(t *testing.T) {
|
||||
if _, _, _, err := BuildAppDir([]byte("Z"), LinuxAppOptions{Out: "site", Base: "x"}); err == nil {
|
||||
t.Error("BuildAppDir should reject a path without .AppDir")
|
||||
}
|
||||
}
|
||||
+22
-17
@@ -44,26 +44,31 @@ func BuildBinary(zimBytes []byte, opts BinaryOptions) (string, int64, error) {
|
||||
return "", 0, fmt.Errorf("pack: read base binary %q: %w", base, err)
|
||||
}
|
||||
|
||||
payload := assemble(baseBytes, zimBytes)
|
||||
if err := os.WriteFile(opts.Out, payload, 0o755); err != nil {
|
||||
return opts.Out, 0, err
|
||||
}
|
||||
// WriteFile honours the mode only when it creates the file; chmod makes an
|
||||
// overwrite executable too.
|
||||
if err := os.Chmod(opts.Out, 0o755); err != nil {
|
||||
return opts.Out, 0, err
|
||||
}
|
||||
return opts.Out, int64(len(payload)), nil
|
||||
}
|
||||
|
||||
// assemble builds the self-contained viewer image: the base executable, then the
|
||||
// ZIM archive, then the KAGEPCK1 trailer that records the archive length. ELF,
|
||||
// PE, and Mach-O loaders all ignore trailing bytes, so the result still runs on
|
||||
// its target OS while Embedded finds the archive at the tail.
|
||||
func assemble(baseBytes, zimBytes []byte) []byte {
|
||||
var tr bytes.Buffer
|
||||
tr.WriteString(trailerMagic)
|
||||
_ = binary.Write(&tr, binary.LittleEndian, uint64(len(zimBytes)))
|
||||
tr.WriteString(trailerMagic)
|
||||
|
||||
f, err := os.Create(opts.Out)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
for _, chunk := range [][]byte{baseBytes, zimBytes, tr.Bytes()} {
|
||||
if _, err := f.Write(chunk); err != nil {
|
||||
_ = f.Close()
|
||||
return opts.Out, 0, err
|
||||
}
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return opts.Out, 0, err
|
||||
}
|
||||
if err := os.Chmod(opts.Out, 0o755); err != nil {
|
||||
return opts.Out, 0, err
|
||||
}
|
||||
return opts.Out, int64(len(baseBytes) + len(zimBytes) + trailerLen), nil
|
||||
out := make([]byte, 0, len(baseBytes)+len(zimBytes)+tr.Len())
|
||||
out = append(out, baseBytes...)
|
||||
out = append(out, zimBytes...)
|
||||
out = append(out, tr.Bytes()...)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// An .icns file is a tiny container: the magic "icns", a uint32 big-endian total
|
||||
// length, then a run of entries. Each entry is a four-byte OSType, a uint32
|
||||
// big-endian length covering the 8-byte entry header plus the payload, and the
|
||||
// payload itself. Since OS X 10.7 the payload may be a PNG, which lets us avoid
|
||||
// the old packed-RGBA formats entirely and just store one PNG per size.
|
||||
//
|
||||
// We emit the retina-era PNG OSTypes. macOS picks whichever size it needs for
|
||||
// the Dock, Finder, and Cmd-Tab, so covering 16 through 1024 keeps the icon
|
||||
// crisp everywhere without shipping a huge file.
|
||||
var icnsSizes = []struct {
|
||||
osType string
|
||||
px int
|
||||
}{
|
||||
{"icp4", 16},
|
||||
{"icp5", 32},
|
||||
{"icp6", 64},
|
||||
{"ic07", 128},
|
||||
{"ic08", 256},
|
||||
{"ic09", 512},
|
||||
{"ic10", 1024},
|
||||
}
|
||||
|
||||
// EncodeICNS renders img into a macOS .icns at every standard size. The source
|
||||
// is scaled to each size with Catmull-Rom resampling, which keeps a small
|
||||
// favicon from turning to mush when it is enlarged for the Dock. It returns an
|
||||
// error only if img is empty or a PNG fails to encode.
|
||||
func EncodeICNS(img image.Image) ([]byte, error) {
|
||||
if img == nil || img.Bounds().Empty() {
|
||||
return nil, fmt.Errorf("pack: icns source image is empty")
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
for _, s := range icnsSizes {
|
||||
scaled := scaleSquare(img, s.px)
|
||||
var pngBuf bytes.Buffer
|
||||
if err := png.Encode(&pngBuf, scaled); err != nil {
|
||||
return nil, fmt.Errorf("pack: encode %s icon: %w", s.osType, err)
|
||||
}
|
||||
body.WriteString(s.osType)
|
||||
writeU32BE(&body, uint32(8+pngBuf.Len()))
|
||||
body.Write(pngBuf.Bytes())
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
out.WriteString("icns")
|
||||
writeU32BE(&out, uint32(8+body.Len()))
|
||||
out.Write(body.Bytes())
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// scaleSquare returns img resampled to a px-by-px RGBA image. A non-square
|
||||
// source is fitted into the square and centred, so a wide or tall favicon is not
|
||||
// stretched.
|
||||
func scaleSquare(img image.Image, px int) image.Image {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, px, px))
|
||||
src := img.Bounds()
|
||||
sw, sh := src.Dx(), src.Dy()
|
||||
// Scale to fit, preserving aspect ratio.
|
||||
scale := float64(px) / float64(sw)
|
||||
if float64(sh)*scale > float64(px) {
|
||||
scale = float64(px) / float64(sh)
|
||||
}
|
||||
dw, dh := int(float64(sw)*scale), int(float64(sh)*scale)
|
||||
if dw < 1 {
|
||||
dw = 1
|
||||
}
|
||||
if dh < 1 {
|
||||
dh = 1
|
||||
}
|
||||
off := image.Pt((px-dw)/2, (px-dh)/2)
|
||||
rect := image.Rectangle{Min: off, Max: off.Add(image.Pt(dw, dh))}
|
||||
xdraw.CatmullRom.Scale(dst, rect, img, src, xdraw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
func writeU32BE(w *bytes.Buffer, v uint32) {
|
||||
var b [4]byte
|
||||
binary.BigEndian.PutUint32(b[:], v)
|
||||
w.Write(b[:])
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// solid returns a w-by-h image filled with one colour, enough to exercise the
|
||||
// encoder without depending on any fixture file.
|
||||
func solid(w, h int, c color.Color) image.Image {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func TestEncodeICNS(t *testing.T) {
|
||||
data, err := EncodeICNS(solid(48, 48, color.RGBA{R: 0x33, G: 0x66, B: 0x99, A: 0xff}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data[:4]) != "icns" {
|
||||
t.Fatalf("magic = %q, want icns", data[:4])
|
||||
}
|
||||
if got := binary.BigEndian.Uint32(data[4:8]); int(got) != len(data) {
|
||||
t.Fatalf("header length = %d, want %d", got, len(data))
|
||||
}
|
||||
|
||||
// Walk the entries and confirm each declared OSType is present and its PNG
|
||||
// decodes to the size it claims.
|
||||
seen := map[string]int{}
|
||||
off := 8
|
||||
for off < len(data) {
|
||||
osType := string(data[off : off+4])
|
||||
size := int(binary.BigEndian.Uint32(data[off+4 : off+8]))
|
||||
if size < 8 || off+size > len(data) {
|
||||
t.Fatalf("entry %s has bogus length %d at offset %d", osType, size, off)
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(data[off+8 : off+size]))
|
||||
if err != nil {
|
||||
t.Fatalf("entry %s payload is not a PNG: %v", osType, err)
|
||||
}
|
||||
seen[osType] = img.Bounds().Dx()
|
||||
off += size
|
||||
}
|
||||
|
||||
for _, s := range icnsSizes {
|
||||
px, ok := seen[s.osType]
|
||||
if !ok {
|
||||
t.Errorf("missing OSType %s", s.osType)
|
||||
continue
|
||||
}
|
||||
if px != s.px {
|
||||
t.Errorf("OSType %s is %dpx, want %d", s.osType, px, s.px)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeICNSEmpty(t *testing.T) {
|
||||
if _, err := EncodeICNS(nil); err == nil {
|
||||
t.Fatal("EncodeICNS(nil) should error")
|
||||
}
|
||||
if _, err := EncodeICNS(image.NewRGBA(image.Rect(0, 0, 0, 0))); err == nil {
|
||||
t.Fatal("EncodeICNS(empty) should error")
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
// Register the decoders image.Decode dispatches to. A favicon is almost
|
||||
// always one of these; SVG and legacy BMP-in-ICO are handled (or skipped)
|
||||
// explicitly below.
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
// iconNames ranks the file names sites use for their icon, best first. A large
|
||||
// PNG (an apple-touch icon, typically 180px) makes a far better app icon than a
|
||||
// 16px favicon.ico, so we prefer those even though .ico is the classic name.
|
||||
var iconNames = []string{
|
||||
"apple-touch-icon-precomposed.png",
|
||||
"apple-touch-icon.png",
|
||||
"icon.png",
|
||||
"favicon.png",
|
||||
"favicon.ico",
|
||||
}
|
||||
|
||||
// FindIcon looks through a cloned mirror for the site's icon and decodes it. It
|
||||
// returns the image, the path it came from (for a friendly log line), and
|
||||
// ok=false when nothing usable is found, in which case the caller just builds a
|
||||
// bundle with the default icon. Discovery never fails the pack.
|
||||
func FindIcon(mirrorDir string) (image.Image, string, bool) {
|
||||
for _, name := range iconNames {
|
||||
for _, p := range globIcon(mirrorDir, name) {
|
||||
if img, err := DecodeIcon(p); err == nil {
|
||||
return img, p, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Favicon48 finds the mirror's icon and renders it to a 48x48 PNG, the form the
|
||||
// ZIM Illustrator_48x48@1 metadata takes and the icon Kiwix shows for the book.
|
||||
// It returns ok=false when the mirror has no usable icon, in which case the
|
||||
// archive simply ships without one rather than failing the pack.
|
||||
func Favicon48(mirrorDir string) ([]byte, bool) {
|
||||
img, _, ok := FindIcon(mirrorDir)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, scaleSquare(img, 48)); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return buf.Bytes(), true
|
||||
}
|
||||
|
||||
// globIcon returns every file under dir whose base name equals name, nearest the
|
||||
// root first. Clones store assets under rewritten paths, so the icon may sit a
|
||||
// few directories deep rather than at the mirror root.
|
||||
func globIcon(dir, name string) []string {
|
||||
var hits []string
|
||||
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(d.Name(), name) {
|
||||
hits = append(hits, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// Shallower paths (fewer separators) are likelier to be the real site icon.
|
||||
for i := 1; i < len(hits); i++ {
|
||||
for j := i; j > 0 && depth(hits[j]) < depth(hits[j-1]); j-- {
|
||||
hits[j], hits[j-1] = hits[j-1], hits[j]
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
func depth(p string) int { return strings.Count(p, string(filepath.Separator)) }
|
||||
|
||||
// DecodeIcon reads an icon file into an image. It handles the stdlib raster
|
||||
// formats (PNG, JPEG, GIF) directly and unwraps a PNG stored inside a .ico
|
||||
// container, which is how modern sites ship a high-resolution favicon.ico. A
|
||||
// legacy BMP-only .ico returns an error rather than a mangled image, so the
|
||||
// caller falls back to the default icon.
|
||||
func DecodeIcon(path string) (image.Image, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isICO(data) {
|
||||
png, err := largestPNGInICO(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data = png
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pack: decode icon %q: %w", path, err)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// isICO reports whether data begins with an ICONDIR header: reserved 0, type 1
|
||||
// (icon), and a non-zero image count.
|
||||
func isICO(data []byte) bool {
|
||||
return len(data) >= 6 &&
|
||||
data[0] == 0 && data[1] == 0 &&
|
||||
data[2] == 1 && data[3] == 0 &&
|
||||
(uint16(data[4])|uint16(data[5])<<8) > 0
|
||||
}
|
||||
|
||||
var pngMagic = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
||||
|
||||
// largestPNGInICO scans an .ico directory for PNG-encoded entries and returns
|
||||
// the bytes of the largest one. It ignores BMP entries; if none of the entries
|
||||
// are PNG it returns an error.
|
||||
func largestPNGInICO(data []byte) ([]byte, error) {
|
||||
count := int(binary.LittleEndian.Uint16(data[4:6]))
|
||||
var best []byte
|
||||
var bestArea int
|
||||
for i := 0; i < count; i++ {
|
||||
e := 6 + i*16
|
||||
if e+16 > len(data) {
|
||||
break
|
||||
}
|
||||
w, h := int(data[e]), int(data[e+1])
|
||||
if w == 0 {
|
||||
w = 256
|
||||
}
|
||||
if h == 0 {
|
||||
h = 256
|
||||
}
|
||||
size := int(binary.LittleEndian.Uint32(data[e+8 : e+12]))
|
||||
off := int(binary.LittleEndian.Uint32(data[e+12 : e+16]))
|
||||
if off < 0 || size <= 0 || off+size > len(data) {
|
||||
continue
|
||||
}
|
||||
chunk := data[off : off+size]
|
||||
if !bytes.HasPrefix(chunk, pngMagic) {
|
||||
continue // a BMP/DIB entry; skip it
|
||||
}
|
||||
if w*h > bestArea {
|
||||
bestArea, best = w*h, chunk
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, fmt.Errorf("pack: .ico holds no PNG entry")
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngBytes encodes a solid square as PNG, used to seed icon fixtures.
|
||||
func pngBytes(t *testing.T, px int) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, solid(px, px, color.RGBA{R: 0x10, G: 0x80, B: 0x40, A: 0xff})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// leWrite appends v to w in little-endian, swallowing the error a bytes.Buffer
|
||||
// never returns. It keeps the .ico fixtures readable without an errcheck flag on
|
||||
// every field.
|
||||
func leWrite(w *bytes.Buffer, v any) { _ = binary.Write(w, binary.LittleEndian, v) }
|
||||
|
||||
// icoWithPNG wraps PNG payloads in a minimal .ico container so the ICO path is
|
||||
// exercised without a binary fixture on disk.
|
||||
func icoWithPNG(pngs [][]byte) []byte {
|
||||
var dir, body bytes.Buffer
|
||||
put := leWrite
|
||||
put(&dir, uint16(0)) // reserved
|
||||
put(&dir, uint16(1)) // type: icon
|
||||
put(&dir, uint16(len(pngs))) // count
|
||||
offset := 6 + len(pngs)*16
|
||||
for i, p := range pngs {
|
||||
// width/height bytes: 0 means 256; use a distinct small size per entry.
|
||||
dim := byte(16 * (i + 1))
|
||||
dir.WriteByte(dim) // width
|
||||
dir.WriteByte(dim) // height
|
||||
dir.WriteByte(0) // colours
|
||||
dir.WriteByte(0) // reserved
|
||||
put(&dir, uint16(1)) // planes
|
||||
put(&dir, uint16(32)) // bit count
|
||||
put(&dir, uint32(len(p))) // bytes in resource
|
||||
put(&dir, uint32(offset)) // offset
|
||||
offset += len(p)
|
||||
body.Write(p)
|
||||
}
|
||||
return append(dir.Bytes(), body.Bytes()...)
|
||||
}
|
||||
|
||||
func TestDecodeIconPNG(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "favicon.png")
|
||||
if err := os.WriteFile(p, pngBytes(t, 64), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, err := DecodeIcon(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if img.Bounds().Dx() != 64 {
|
||||
t.Errorf("decoded width = %d, want 64", img.Bounds().Dx())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeIconICOWithPNG(t *testing.T) {
|
||||
// Two PNG entries; the decoder should pick the larger (second, 32px square).
|
||||
ico := icoWithPNG([][]byte{pngBytes(t, 16), pngBytes(t, 32)})
|
||||
p := filepath.Join(t.TempDir(), "favicon.ico")
|
||||
if err := os.WriteFile(p, ico, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, err := DecodeIcon(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if img.Bounds().Dx() != 32 {
|
||||
t.Errorf("decoded width = %d, want the larger 32px entry", img.Bounds().Dx())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeIconBMPOnlyICOFails(t *testing.T) {
|
||||
// A one-entry .ico whose payload is not a PNG (here just filler) must error
|
||||
// so the caller falls back to the default icon.
|
||||
var ico bytes.Buffer
|
||||
leWrite(&ico, uint16(0))
|
||||
leWrite(&ico, uint16(1))
|
||||
leWrite(&ico, uint16(1))
|
||||
ico.Write([]byte{32, 32, 0, 0})
|
||||
leWrite(&ico, uint16(1))
|
||||
leWrite(&ico, uint16(32))
|
||||
leWrite(&ico, uint32(4))
|
||||
leWrite(&ico, uint32(22))
|
||||
ico.Write([]byte{0x42, 0x4d, 0x00, 0x00}) // "BM" DIB filler
|
||||
p := filepath.Join(t.TempDir(), "favicon.ico")
|
||||
if err := os.WriteFile(p, ico.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := DecodeIcon(p); err == nil {
|
||||
t.Fatal("BMP-only .ico should fail to decode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindIconPrefersAppleTouch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A tiny favicon at the root and a bigger apple-touch icon a level down.
|
||||
if err := os.WriteFile(filepath.Join(dir, "favicon.png"), pngBytes(t, 16), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.Join(dir, "assets")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "apple-touch-icon.png"), pngBytes(t, 180), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, src, ok := FindIcon(dir)
|
||||
if !ok {
|
||||
t.Fatal("FindIcon found nothing")
|
||||
}
|
||||
if filepath.Base(src) != "apple-touch-icon.png" {
|
||||
t.Errorf("picked %s, want apple-touch-icon.png", filepath.Base(src))
|
||||
}
|
||||
if img.Bounds().Dx() != 180 {
|
||||
t.Errorf("width = %d, want 180", img.Bounds().Dx())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindIconNone(t *testing.T) {
|
||||
if _, _, ok := FindIcon(t.TempDir()); ok {
|
||||
t.Fatal("FindIcon should report nothing in an empty dir")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package pack
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -134,6 +137,88 @@ func TestBuildZIMDeterministic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildZIMMetadata checks the metadata zimcheck treats as mandatory: a
|
||||
// Title, Name, Language, Description, and the Illustrator_48x48@1 favicon. The
|
||||
// mirror carries a real PNG favicon so the icon path is exercised end to end.
|
||||
func TestBuildZIMMetadata(t *testing.T) {
|
||||
host := writeMirror(t)
|
||||
|
||||
// Drop a 64x64 PNG favicon into the mirror so Favicon48 has something to
|
||||
// find, decode, and rescale to 48x48.
|
||||
icon := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||
for y := 0; y < 64; y++ {
|
||||
for x := 0; x < 64; x++ {
|
||||
icon.Set(x, y, color.RGBA{R: 0x33, G: 0x66, B: 0x99, A: 0xff})
|
||||
}
|
||||
}
|
||||
var pngBuf bytes.Buffer
|
||||
if err := png.Encode(&pngBuf, icon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(host, "favicon.png"), pngBuf.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := filepath.Join(t.TempDir(), "meta.zim")
|
||||
if _, _, err := BuildZIM(host, ZIMOptions{Out: out, Date: "2026-06-14", Version: "test"}); err != nil {
|
||||
t.Fatalf("BuildZIM: %v", err)
|
||||
}
|
||||
r, err := zim.Open(out)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
wantText := map[string]string{
|
||||
"Name": "example.com",
|
||||
"Language": "eng",
|
||||
"Description": "Offline mirror of example.com, cloned by kage.",
|
||||
}
|
||||
for k, want := range wantText {
|
||||
e, err := r.Get(zim.NamespaceMetadata, k)
|
||||
if err != nil {
|
||||
t.Errorf("M/%s: %v", k, err)
|
||||
continue
|
||||
}
|
||||
if string(e.Data) != want {
|
||||
t.Errorf("M/%s = %q, want %q", k, e.Data, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Illustrator_48x48@1 is a 48x48 PNG with an image/png MIME.
|
||||
ill, err := r.Get(zim.NamespaceMetadata, "Illustrator_48x48@1")
|
||||
if err != nil {
|
||||
t.Fatalf("M/Illustrator_48x48@1: %v", err)
|
||||
}
|
||||
if ill.MimeType != "image/png" {
|
||||
t.Errorf("illustrator mime = %q, want image/png", ill.MimeType)
|
||||
}
|
||||
img, format, err := image.Decode(bytes.NewReader(ill.Data))
|
||||
if err != nil {
|
||||
t.Fatalf("decode illustrator: %v", err)
|
||||
}
|
||||
if format != "png" {
|
||||
t.Errorf("illustrator format = %q, want png", format)
|
||||
}
|
||||
if b := img.Bounds(); b.Dx() != 48 || b.Dy() != 48 {
|
||||
t.Errorf("illustrator size = %dx%d, want 48x48", b.Dx(), b.Dy())
|
||||
}
|
||||
|
||||
// A caller-supplied description overrides the host-derived default.
|
||||
out2 := filepath.Join(t.TempDir(), "meta2.zim")
|
||||
if _, _, err := BuildZIM(host, ZIMOptions{Out: out2, Description: "Custom blurb.", Date: "2026-06-14"}); err != nil {
|
||||
t.Fatalf("BuildZIM: %v", err)
|
||||
}
|
||||
r2, err := zim.Open(out2)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer func() { _ = r2.Close() }()
|
||||
if d, err := r2.Get(zim.NamespaceMetadata, "Description"); err != nil || string(d.Data) != "Custom blurb." {
|
||||
t.Errorf("M/Description = %q, %v; want %q", d.Data, err, "Custom blurb.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickMainPage(t *testing.T) {
|
||||
cases := []struct {
|
||||
in []string
|
||||
|
||||
+9
-3
@@ -134,10 +134,11 @@ func buildWriter(mirrorDir string, opts ZIMOptions) (*zim.Writer, error) {
|
||||
host := filepath.Base(mirrorDir)
|
||||
title := firstNonEmpty(opts.Title, htmlTitleOf(mirrorDir, main), host)
|
||||
w.AddMetadata("Title", title)
|
||||
w.AddMetadata("Name", host)
|
||||
w.AddMetadata("Language", firstNonEmpty(opts.Language, "eng"))
|
||||
if opts.Description != "" {
|
||||
w.AddMetadata("Description", opts.Description)
|
||||
}
|
||||
// Description is mandatory metadata in the ZIM spec, so it is always written:
|
||||
// the caller's text when given, otherwise a line derived from the host.
|
||||
w.AddMetadata("Description", firstNonEmpty(opts.Description, "Offline mirror of "+host+", cloned by kage."))
|
||||
w.AddMetadata("Creator", "kage")
|
||||
w.AddMetadata("Publisher", "kage")
|
||||
if opts.Date != "" {
|
||||
@@ -146,6 +147,11 @@ func buildWriter(mirrorDir string, opts ZIMOptions) (*zim.Writer, error) {
|
||||
w.AddMetadata("Scraper", strings.TrimSpace("kage "+opts.Version))
|
||||
w.AddMetadata("Source", host)
|
||||
w.AddMetadata("Counter", counterString(counts))
|
||||
// Illustrator_48x48@1 is the 48x48 PNG favicon Kiwix shows as the archive's
|
||||
// icon. When the mirror has no usable icon the archive ships without one.
|
||||
if png, ok := Favicon48(mirrorDir); ok {
|
||||
w.AddMetadataBytes("Illustrator_48x48@1", "image/png", png)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,16 @@ func (w *Writer) AddMetadata(name, value string) {
|
||||
w.put(&entry{namespace: NamespaceMetadata, url: name, title: name, mime: "text/plain", data: []byte(value)})
|
||||
}
|
||||
|
||||
// AddMetadataBytes adds an 'M' namespace entry with an explicit MIME, for binary
|
||||
// metadata such as Illustrator_48x48@1, the 48x48 PNG favicon Kiwix shows as the
|
||||
// archive's icon.
|
||||
func (w *Writer) AddMetadataBytes(name, mime string, data []byte) {
|
||||
if mime == "" {
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
w.put(&entry{namespace: NamespaceMetadata, url: name, title: name, mime: mime, data: data})
|
||||
}
|
||||
|
||||
// AddRedirect adds a redirect from (namespace,url) to (targetNamespace,targetURL).
|
||||
func (w *Writer) AddRedirect(namespace byte, url, title string, targetNamespace byte, targetURL string) {
|
||||
if title == "" {
|
||||
|
||||
Reference in New Issue
Block a user