termux: support Android builds and installation (#644)

* termux: build natively and run plugins on Bun 1.4.2

Select nogui for Android builds, open browser and OAuth links with
termux-open-url, and write an executable Termux:Boot script that starts
the gateway with shell-quoted paths.

Bun 1.3 has no official Android build. Upgrade the default plugin runtime
to 1.4.2, add the official Android arm64 and amd64 download targets, and
update SHA-256 checksums for every supported platform.

Document Termux installation, startup and source updates.

Validation on Termux arm64:
- Native build and go vet pass.
- A fresh config downloads and verifies Android Bun 1.4.2 and loads a
  test plugin.
- Boot startup can be enabled and disabled.
- The Web page, authenticated state API, unauthenticated API rejection,
  gateway healthcheck and Web quit action work.
- Browser argument preservation, boot path quoting and permissions, and
  plugin tests pass.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a10014-56c1-73d3-b3ee-2ea569713989

* site: install Android builds in Termux

Detect Termux through TERMUX_VERSION or its PREFIX and select the Android
CLI asset instead of a Linux binary. Install into PREFIX/bin by default,
keep MAGPIE_BIN_DIR overrides, and suggest the Web or terminal interface
without Linux desktop setup instructions.

When the release has no Android asset, explain how to build from source.
Add android/arm64 to release-cli and stop the build loop on failure.
Document the installer and its release-asset requirement.

Shell syntax and all 11 site tests pass, covering platform selection,
custom paths, missing assets, checksum rejection and existing Linux
installation paths. The Android arm64 release build succeeds on Termux;
with a simulated release feed, the installer verifies and installs that
binary into PREFIX/bin and the installed command runs successfully.

Amp-Thread-ID: https://ampcode.com/threads/T-01a10014-56c1-73d3-b3ee-2ea569713989
Co-authored-by: Amp <amp@ampcode.com>

* ci: check Android releases and the installer

Build both linux/amd64 and android/arm64 through make release-cli in the
CLI test matrix. Run the site and installer tests in the Linux job with
Node 22, and check the installer's shell syntax.

The release workflow in yetone/magpie-releases already calls the source
Makefile's release-cli target, uploads dist/magpie-cli-*, and generates
checksums for every artifact. The Android target added to TARGETS is
therefore included without changing that workflow.

Replace the full Termux README section with a short platform note beside
the Linux and Windows installation requirements, removing duplicate
commands, the fork URL and temporary release availability details.

Both CLI release builds, all 11 site tests, shell syntax checks and
actionlint validation pass locally.

Amp-Thread-ID: https://ampcode.com/threads/T-01a10014-56c1-73d3-b3ee-2ea569713989
Co-authored-by: Amp <amp@ampcode.com>

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
XYenonandAmp authored and GitHub committed 2026-10-03 14:29:32 +08:00
1 parent 18e6cb8140
commit 69368007b0
14 files changed
+330 -14

No files matched your search

+20 -3
View File
@@ -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
+8 -3
View File
@@ -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
+5
View File
@@ -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
+2
View File
@@ -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)
}
+5
View File
@@ -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() {
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")
}
+32
View File
@@ -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)
}
}
+41
View File
@@ -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 >/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 }
@@ -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)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !darwin && !windows
//go:build !darwin && !windows && !android
package autostart
+5 -1
View File
@@ -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)
}
+14 -6
View File
@@ -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)
+24
View File
@@ -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) {
+106
View File
@@ -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);
}
});
+15
View File
@@ -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)
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