diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e5817bb1..2368a222 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,14 +80,31 @@ jobs: run: go test -v -count=1 -timeout 2m ./internal/update -run '^(TestInstallBinary|TestMoveAside)' cli: - name: cli (nogui) + name: cli (${{ matrix.goos }}/${{ matrix.goarch }}, nogui) + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: android + goarch: arm64 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: Build the terminal-only magpie + - name: Build the terminal-only release env: CGO_ENABLED: '0' - run: go build -tags nogui -o /dev/null . + run: make release-cli TARGETS="${{ matrix.goos }}/${{ matrix.goarch }}" + - uses: actions/setup-node@v4 + if: matrix.goos == 'linux' + with: + node-version: 22 + - name: Test the site and installer + if: matrix.goos == 'linux' + run: | + sh -n site/public/install.sh + node --test site/*.test.mjs diff --git a/Makefile b/Makefile index dbb9ef96..ccd5667a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS = -s -w -X main.version=$(VERSION) TAGS = production -TARGETS = darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 windows/arm64 +TARGETS = darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 windows/arm64 android/arm64 # The GUI links the platform webview through cgo, so it is built natively. # `nogui` builds the terminal-only magpie, which cross-compiles anywhere. @@ -15,6 +15,11 @@ endif ifeq ($(shell uname -s),Linux) TAGS += gtk3 endif +# Termux uses Android's libc and has no desktop webview. Keep the web UI +# and terminal commands, without linking GTK or Wails. +ifeq ($(shell go env GOOS),android) + TAGS = nogui +endif .PHONY: build cli install test app icons release release-cli release-windows release-linux clean dev dev-once @@ -28,7 +33,7 @@ install: go install -tags "$(TAGS)" -trimpath -ldflags="$(LDFLAGS)" . test: - go vet ./... && go test ./... + go vet -tags "$(TAGS)" ./... && go test -tags "$(TAGS)" ./... # macOS bundle: menu bar app with no Dock icon (LSUIElement). app: build @@ -69,7 +74,7 @@ release-cli: @for t in $(TARGETS); do \ os=$${t%/*}; arch=$${t#*/}; ext=""; [ $$os = windows ] && ext=.exe; \ echo " $$os/$$arch (cli)"; \ - CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -tags nogui -trimpath -ldflags="$(LDFLAGS)" -o dist/magpie-cli-$$os-$$arch$$ext . ; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -tags nogui -trimpath -ldflags="$(LDFLAGS)" -o dist/magpie-cli-$$os-$$arch$$ext . || exit 1; \ done # The desktop app for Windows: the system WebView2 needs no cgo, so both diff --git a/README.md b/README.md index 9720e9be..2f36af06 100644 --- a/README.md +++ b/README.md @@ -716,6 +716,11 @@ Linux needs `libgtk-3-dev` and `libwebkit2gtk-4.1-dev` for the app build (the Makefile adds the `gtk3` tag; with plain `go build`, pass `-tags gtk3`); Windows uses the WebView2 runtime that ships with the OS. +Termux uses the Android terminal build: the installer puts it in +`$PREFIX/bin`, and `make build` selects it automatically. With plain +`go build` or `go install`, pass `-tags nogui`; use `magpie web` or +`magpie tui` for the interface. `magpie autostart on` requires Termux:Boot. + ### Docker `docker build` makes a server image: the terminal-only binary on diff --git a/accounts_cli.go b/accounts_cli.go index de094229..99abf9c3 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -372,6 +372,8 @@ func openInBrowser(url string) { cmd = proc.Command("open", url) case "windows": cmd = proc.Command("rundll32", "url.dll,FileProtocolHandler", url) + case "android": + cmd = proc.Command("termux-open-url", url) default: cmd = proc.Command("xdg-open", url) } diff --git a/autostart_cli.go b/autostart_cli.go index 35ac1e89..b6dbf4fc 100644 --- a/autostart_cli.go +++ b/autostart_cli.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "runtime" "github.com/yetone/magpie/internal/autostart" ) @@ -22,7 +23,11 @@ func autostartCmd(args []string) error { } } if autostart.Enabled() { - fmt.Println(green.Render("✓"), "magpie opens at login, in the tray") + if runtime.GOOS == "android" { + fmt.Println(green.Render("✓"), "magpie starts the gateway at boot (requires Termux:Boot)") + } else { + fmt.Println(green.Render("✓"), "magpie opens at login, in the tray") + } } else { fmt.Println("magpie doesn't open at login") } diff --git a/browser_android_test.go b/browser_android_test.go new file mode 100644 index 00000000..4cb48ce3 --- /dev/null +++ b/browser_android_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestTermuxOpenInBrowser(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "url") + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("MAGPIE_BROWSER_TEST", out) + script := "#!/system/bin/sh\n[ \"$#\" = 1 ] || exit 1\nprintf '%s' \"$1\" > \"$MAGPIE_BROWSER_TEST\"\n" + if err := os.WriteFile(filepath.Join(dir, "termux-open-url"), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + url := "https://example.com/oauth?state=a&code=$literal'quote" + openInBrowser(url) + deadline := time.Now().Add(3 * time.Second) + for { + b, err := os.ReadFile(out) + if err == nil && string(b) == url { + return + } + if time.Now().After(deadline) { + t.Fatalf("termux-open-url did not receive the URL intact: got %q, error=%v", b, err) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/internal/autostart/autostart_android.go b/internal/autostart/autostart_android.go new file mode 100644 index 00000000..0a410910 --- /dev/null +++ b/internal/autostart/autostart_android.go @@ -0,0 +1,41 @@ +package autostart + +import ( + "os" + "path/filepath" + "strings" +) + +// Termux:Boot runs executable scripts in ~/.termux/boot after Android boots. +func record() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".termux", "boot", "magpie") +} + +func enabled() bool { + info, err := os.Stat(record()) + return err == nil && !info.IsDir() && info.Mode()&0o111 != 0 +} + +func enable(exe string) error { + p := record() + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + // Android's shell is available even before Termux's PATH is loaded. + q := "'" + strings.ReplaceAll(exe, "'", "'\"'\"'") + "'" + body := "#!/system/bin/sh\nexec " + q + " serve /dev/null 2>&1 &\n" + if err := os.WriteFile(p, []byte(body), 0o700); err != nil { + return err + } + return os.Chmod(p, 0o700) +} + +func disable() error { + if err := os.Remove(record()); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func refresh() error { return nil } diff --git a/internal/autostart/autostart_android_test.go b/internal/autostart/autostart_android_test.go new file mode 100644 index 00000000..f9f47039 --- /dev/null +++ b/internal/autostart/autostart_android_test.go @@ -0,0 +1,52 @@ +package autostart + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestBootScript(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + // Spaces, quotes and shell expansions must stay part of the file name. + exe := filepath.Join(home, "magpie ' $not_a_variable") + out := filepath.Join(home, "args") + if err := os.WriteFile(exe, []byte("#!/system/bin/sh\nprintf '%s' \"$1\" > \"$HOME/args\"\n"), 0o700); err != nil { + t.Fatal(err) + } + if err := enable(exe); err != nil { + t.Fatal(err) + } + if err := os.Chmod(record(), 0o600); err != nil { + t.Fatal(err) + } + if Enabled() { + t.Fatal("non-executable script reported as enabled") + } + if err := enable(exe); err != nil { + t.Fatal(err) + } + if !Enabled() { + t.Fatal("enable did not restore executable permission") + } + if err := exec.Command("/system/bin/sh", record()).Run(); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(3 * time.Second) + for { + b, err := os.ReadFile(out) + if err == nil && string(b) == "serve" { + break + } + if time.Now().After(deadline) { + t.Fatalf("boot script did not launch the gateway: args=%q, error=%v", b, err) + } + time.Sleep(10 * time.Millisecond) + } + if err := disable(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/autostart/autostart_other.go b/internal/autostart/autostart_other.go index d2e09dd8..332c4d0a 100644 --- a/internal/autostart/autostart_other.go +++ b/internal/autostart/autostart_other.go @@ -1,4 +1,4 @@ -//go:build !darwin && !windows +//go:build !darwin && !windows && !android package autostart diff --git a/internal/autostart/autostart_test.go b/internal/autostart/autostart_test.go index 0cfe45e0..26081568 100644 --- a/internal/autostart/autostart_test.go +++ b/internal/autostart/autostart_test.go @@ -32,7 +32,11 @@ func TestSet(t *testing.T) { if err != nil { t.Fatal(err) } - for _, want := range []string{"/opt/my apps/magpie.AppImage", Arg} { + arg := Arg + if runtime.GOOS == "android" { + arg = "serve" + } + for _, want := range []string{"/opt/my apps/magpie.AppImage", arg} { if !strings.Contains(string(b), want) { t.Errorf("%s lacks %q:\n%s", path, want, b) } diff --git a/internal/plugin/bun.go b/internal/plugin/bun.go index 5514a6e8..d6653a2e 100644 --- a/internal/plugin/bun.go +++ b/internal/plugin/bun.go @@ -25,17 +25,19 @@ import ( // BunVersion is the Bun magpie downloads to run plugins with the first // time one is needed, and the oldest it runs them on: newer releases are // taken as they come (see CheckBun). Update bunSums below when it changes. -const BunVersion = "1.3.14" +const BunVersion = "1.4.2" // bunSums are the SHA-256s of BunVersion's builds, from Bun's own // SHASUMS256.txt. They let the default Bun be downloaded through a mirror // when Bun's GitHub release page can't be reached. var bunSums = map[string]string{ - "bun-darwin-aarch64.zip": "d8b96221828ad6f97ac7ac0ab7e95872341af763001e8803e8267652c2652620", - "bun-darwin-x64.zip": "4183df3374623e5bab315c547cfa0974533cd457d86b73b639f7a87974cd6633", - "bun-linux-aarch64.zip": "a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b", - "bun-linux-x64-baseline.zip": "a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7", - "bun-windows-x64-baseline.zip": "538f9c846355d9e847b2671bc00c47da4229a0befb24df3282b739770f3b475f", + "bun-darwin-aarch64.zip": "90987a3a16d7db556d886ac3d551e7b6d3edf0a1cf43acaed622e8676be1d12f", + "bun-darwin-x64.zip": "80520d7e17526308c9185d261679ac6d27798d3803a0e9f7ff9121ab8affb012", + "bun-linux-aarch64.zip": "54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7", + "bun-linux-x64-baseline.zip": "c678040f14fe0440eb839d37cbd0ce4c051a32da72806ac97de6a6aab6bf728f", + "bun-windows-x64-baseline.zip": "78c221c2376f79731ccf4e4af0b3bb46d81fefa3296c5abee09ad8a1b21e68c6", + "bun-linux-aarch64-android.zip": "a1c7e2983f1bb65146beb256a4d72449f23042412bc2cf278aa6397ba27e0274", + "bun-linux-x64-android-baseline.zip": "fe36d8d4795e0eadc22fb6696d44d168491c2e5b9b7cbb12b8c96b0c0c40a4f9", } func bunChecksum(version, target string) string { @@ -60,6 +62,12 @@ func bunTarget() (string, error) { } switch runtime.GOOS { case "darwin", "linux": + case "android": + t := "bun-linux-" + arch + "-android" + if arch == "x64" { + t += "-baseline" + } + return t, nil case "windows": if arch != "x64" { return "", fmt.Errorf("Bun has no build for windows/%s", runtime.GOARCH) diff --git a/internal/plugin/bunupdate_test.go b/internal/plugin/bunupdate_test.go index ff3e870a..9018670f 100644 --- a/internal/plugin/bunupdate_test.go +++ b/internal/plugin/bunupdate_test.go @@ -96,6 +96,8 @@ func TestBunDefaultSums(t *testing.T) { "bun-linux-aarch64", "bun-linux-x64-baseline", "bun-windows-x64-baseline", + "bun-linux-aarch64-android", + "bun-linux-x64-android-baseline", } { got := bunChecksum(BunVersion, target) if len(got) != 64 { @@ -110,6 +112,28 @@ func TestBunDefaultSums(t *testing.T) { } } +func TestBunTarget(t *testing.T) { + want := map[string]string{ + "darwin/arm64": "bun-darwin-aarch64", + "darwin/amd64": "bun-darwin-x64", + "linux/arm64": "bun-linux-aarch64", + "linux/amd64": "bun-linux-x64-baseline", + "windows/amd64": "bun-windows-x64-baseline", + "android/arm64": "bun-linux-aarch64-android", + "android/amd64": "bun-linux-x64-android-baseline", + }[runtime.GOOS+"/"+runtime.GOARCH] + got, err := bunTarget() + if want == "" { + if err == nil { + t.Fatalf("unsupported platform selected %q", got) + } + return + } + if err != nil || got != want { + t.Fatalf("bunTarget = %q, %v; want %q", got, err, want) + } +} + // A Bun out two days is taken: downloaded, its checksum checked, tried, // and run from then on; the one before it is kept to fall back on. func TestCheckBunTakesASettledRelease(t *testing.T) { diff --git a/site/install.test.mjs b/site/install.test.mjs new file mode 100644 index 00000000..74a175ff --- /dev/null +++ b/site/install.test.mjs @@ -0,0 +1,106 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const installer = fileURLToPath(new URL("./public/install.sh", import.meta.url)); + +function install(t, { termux = true, arch = "aarch64", override = false, prefixOnly = false, missing = false, corrupt = false, desktop = false } = {}) { + const dir = mkdtempSync(join(tmpdir(), "magpie-install-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const tools = join(dir, "tools"); + const home = join(dir, "home"); + const prefix = join(dir, "com.termux/files/usr"); + const bin = override ? join(dir, "custom bin") : termux ? join(prefix, "bin") : join(home, ".local/bin"); + mkdirSync(tools, { recursive: true }); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(bin, "magpie"), "old installation"); + const payload = "test binary\n"; + const cpu = arch === "aarch64" ? "arm64" : "amd64"; + const file = termux ? `magpie-cli-android-${cpu}` : desktop ? `magpie-linux-${cpu}` : `magpie-cli-linux-${cpu}`; + const assets = {}; + // A Linux asset is present even when Android's is missing: never use it. + for (const name of [file, `magpie-cli-linux-${cpu}`]) { + if (missing && name === file) continue; + assets[name] = { url: `https://download.invalid/${name}`, sha256: corrupt ? "0".repeat(64) : createHash("sha256").update(payload).digest("hex") }; + } + const requests = join(dir, "requests"); + writeFileSync(join(tools, "uname"), `#!/bin/sh\ncase "$1" in -m) echo '${arch}';; -s) echo Linux;; esac\n`, { mode: 0o755 }); + writeFileSync(join(tools, "ldconfig"), "#!/bin/sh\necho libwebkit2gtk-4.1.so.0\n", { mode: 0o755 }); + writeFileSync(join(tools, "curl"), `#!${process.execPath} +const fs = require("node:fs"); +const args = process.argv.slice(2); +const url = args.at(-1); +fs.appendFileSync(process.env.INSTALL_REQUESTS, url + String.fromCharCode(10)); +if (url.endsWith("/api/latest")) process.stdout.write(process.env.INSTALL_FEED); +else fs.writeFileSync(args[args.indexOf("-o") + 1], process.env.INSTALL_PAYLOAD); +`, { mode: 0o755 }); + const env = { + ...process.env, + HOME: home, + PREFIX: termux ? prefix : "", + TERMUX_VERSION: termux && !prefixOnly ? "test" : "", + MAGPIE_BIN_DIR: override ? bin : "", + MAGPIE_CLI: desktop ? "" : "1", + PATH: tools + ":" + process.env.PATH, + XDG_CONFIG_HOME: join(dir, "config"), + XDG_DATA_HOME: join(dir, "share"), + INSTALL_REQUESTS: requests, + INSTALL_FEED: JSON.stringify({ version: "test", assets }), + INSTALL_PAYLOAD: payload, + }; + const result = spawnSync("sh", [installer], { env, encoding: "utf8" }); + return { ...result, bin, file, payload, share: env.XDG_DATA_HOME, requests: readFileSync(requests, "utf8") }; +} + +test("Termux downloads Android arm64 into PREFIX/bin even with WebKit installed", (t) => { + const r = install(t, { desktop: true }); + assert.equal(r.status, 0, r.stderr); + assert.equal(readFileSync(join(r.bin, "magpie"), "utf8"), r.payload); + assert.ok(statSync(join(r.bin, "magpie")).mode & 0o111); + assert.match(r.requests, /magpie-cli-android-arm64/); + assert.doesNotMatch(r.requests, /magpie-linux-|icon-256/); + assert.equal(existsSync(join(r.share, "applications/magpie.desktop")), false); + assert.match(r.stdout, /run it: magpie web/); + assert.doesNotMatch(r.stdout, /install WebKit/); +}); + +test("Termux prefix detection and amd64 target", (t) => { + const r = install(t, { prefixOnly: true, arch: "x86_64" }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.requests, /magpie-cli-android-amd64/); +}); + +test("Termux respects a custom bin directory containing spaces", (t) => { + const r = install(t, { override: true }); + assert.equal(r.status, 0, r.stderr); + assert.equal(readFileSync(join(r.bin, "magpie"), "utf8"), r.payload); +}); + +test("missing Android release never falls back to Linux", (t) => { + const r = install(t, { missing: true }); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /no magpie-cli-android-arm64.*build in Termux/); + assert.equal(readFileSync(join(r.bin, "magpie"), "utf8"), "old installation"); + assert.equal(r.requests.trim().split("\n").length, 1); +}); + +test("a checksum mismatch leaves the existing installation intact", (t) => { + const r = install(t, { corrupt: true }); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /does not match its checksum/); + assert.equal(readFileSync(join(r.bin, "magpie"), "utf8"), "old installation"); +}); + +test("ordinary Linux still selects its CLI or desktop build", (t) => { + for (const desktop of [false, true]) { + const r = install(t, { termux: false, desktop }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.requests, desktop ? /magpie-linux-arm64/ : /magpie-cli-linux-arm64/); + assert.equal(existsSync(join(r.share, "applications/magpie.desktop")), desktop); + } +}); diff --git a/site/public/install.sh b/site/public/install.sh index 4bdc25aa..ba5259ec 100644 --- a/site/public/install.sh +++ b/site/public/install.sh @@ -6,6 +6,7 @@ # Linux: puts magpie in ~/.local/bin — the desktop app when GTK 3 and # WebKitGTK 4.1 are installed (with a menu entry), the terminal build # otherwise or with MAGPIE_CLI=1. +# Termux: installs the Android terminal build into $PREFIX/bin. # Every download is checked against the release's SHA-256. set -eu @@ -23,9 +24,17 @@ esac case "$(uname -s)" in Darwin) os=darwin; file="magpie-darwin-$arch.zip" ;; Linux) - os=linux; file="magpie-cli-linux-$arch" - if [ -z "${MAGPIE_CLI:-}" ] && { ldconfig -p 2>/dev/null | grep -q 'libwebkit2gtk-4\.1\.so\.0'; }; then - file="magpie-linux-$arch" + termux="${TERMUX_VERSION:-}" + case "${PREFIX:-}" in */com.termux/files/usr) termux=1 ;; esac + if [ -n "$termux" ]; then + [ -n "${PREFIX:-}" ] || die "Termux requires PREFIX to locate its bin directory" + os=android; file="magpie-cli-android-$arch" + bin="${MAGPIE_BIN_DIR:-$PREFIX/bin}" + else + os=linux; file="magpie-cli-linux-$arch" + if [ -z "${MAGPIE_CLI:-}" ] && { ldconfig -p 2>/dev/null | grep -q 'libwebkit2gtk-4\.1\.so\.0'; }; then + file="magpie-linux-$arch" + fi fi ;; *) die "unsupported system: $(uname -s); see $site" ;; esac @@ -39,6 +48,9 @@ entry=$(printf '%s' "$feed" | sed -n "s/.*\"$file\":{\([^}]*\)}.*/\1/p") url=$(printf '%s' "$entry" | sed -n 's/.*"url":"\([^"]*\)".*/\1/p') sum=$(printf '%s' "$entry" | sed -n 's/.*"sha256":"\([^"]*\)".*/\1/p') version=$(printf '%s' "$feed" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') +if [ "$os" = android ] && { [ -z "$url" ] || [ -z "$sum" ]; }; then + die "the newest release has no $file; build in Termux with go build -tags nogui (see $site/docs/start)" +fi [ -n "$url" ] && [ -n "$sum" ] || die "the newest release has no $file" say "downloading magpie $version ($file)" @@ -77,6 +89,8 @@ EOF command -v update-desktop-database >/dev/null && update-desktop-database "$share/applications" 2>/dev/null || true command -v xdg-mime >/dev/null && xdg-mime default magpie.desktop x-scheme-handler/magpie 2>/dev/null || true say "installed the magpie desktop app (menu entry: magpie)" + elif [ "$os" = android ]; then + say "installed the Termux build; use magpie web for the browser UI or magpie tui for the terminal UI" else say "installed the terminal build; for the desktop app, install WebKitGTK 4.1 (libwebkit2gtk-4.1-0) and run this again" fi @@ -88,6 +102,7 @@ case ":$PATH:" in esac case "$file" in *.zip) say "open it: open -a magpie" ;; + magpie-cli-android-*) say "run it: magpie web" ;; magpie-linux-*) say "open it from your app menu, or run: magpie app" ;; *) say "run it: magpie" ;; esac