mobux 0.37.0

A touch-friendly tmux web UI for unhinged people who run terminal sessions from their phone while walking the dog
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
#!/usr/bin/env bash
#
# bin/setup-twa — install the toolchain mobux needs to build the TWA APK.
#
# Idempotent. User-local installs only — no sudo. Each tool: detect, skip if
# present at adequate version, otherwise install.
#
# The server embeds this script and runs it as the first phase of the install
# page's "Generate package" build whenever the toolchain is missing, so it is
# the shared implementation rather than a step anyone performs by hand.
#
# Installs/ensures:
#   - SDKMAN at ~/.sdkman
#   - JDK 17 (Temurin) via SDKMAN
#   - nvm at ~/.nvm
#   - Node LTS via nvm
#   - Android command-line tools at ~/.android/cmdline-tools/latest
#   - Android SDK platform-tools, build-tools, platform (compileSdk 34)
#   - SDK licenses accepted non-interactively
#   - @bubblewrap/cli installed via npm with prefix ~/.local
#
# At the end, prints PATH/env hints for any user-local installs — only when
# stdout is a terminal, so the server's log pane never shows shell-rc advice.
set -euo pipefail

# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------

log() { printf '\033[1;34m[setup-twa]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[setup-twa] WARN:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31m[setup-twa] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }

SCRIPT_PATH="${BASH_SOURCE[0]}"
chmod +x "${SCRIPT_PATH}" 2>/dev/null || true

require_host_tool() {
  local tool="$1"
  if ! command -v "${tool}" >/dev/null 2>&1; then
    fail "${tool} is required but not installed. Install it via your OS package manager and re-run."
  fi
}

# JDK 17 download for Android SDK licenses needs Java; we install JDK via
# SDKMAN below. The Android SDK manager itself needs zip/unzip.

# ---------------------------------------------------------------------------
# 1. SDKMAN + JDK 17
# ---------------------------------------------------------------------------

SDKMAN_DIR="${SDKMAN_DIR:-${HOME}/.sdkman}"
# Preferred JDK identifier. SDKMAN candidate names rotate as new patch releases
# come out, so on miss we fall back to whatever 17.x Temurin SDKMAN offers.
JDK_VERSION="17.0.13-tem"

ensure_sdkman() {
  if [ -s "${SDKMAN_DIR}/bin/sdkman-init.sh" ]; then
    log "SDKMAN already installed at ${SDKMAN_DIR}"
    return 0
  fi

  log "Installing SDKMAN to ${SDKMAN_DIR}"
  # SDKMAN install script honors $SDKMAN_DIR if exported.
  export SDKMAN_DIR
  curl -fsSL --max-time 300 "https://get.sdkman.io?rcupdate=false" | bash

  if [ ! -s "${SDKMAN_DIR}/bin/sdkman-init.sh" ]; then
    fail "SDKMAN install reported success but ${SDKMAN_DIR}/bin/sdkman-init.sh is missing."
  fi
}

ensure_jdk17() {
  # Source SDKMAN into this shell.
  set +u  # SDKMAN init script touches unset vars
  # shellcheck disable=SC1091
  . "${SDKMAN_DIR}/bin/sdkman-init.sh"
  set -u

  # SDKMAN installs each candidate as a directory under candidates/java/.
  # Detect any 17.x install by directory listing — robust against changes in
  # `sdk list` output formatting.
  local java_dir="${SDKMAN_DIR}/candidates/java"
  local installed=""
  if [ -d "${java_dir}" ]; then
    installed="$(find "${java_dir}" -mindepth 1 -maxdepth 1 -type d -name '17.*' \
      -printf '%f\n' 2>/dev/null | head -n1)"
  fi

  if [ -n "${installed}" ]; then
    log "JDK 17 already installed via SDKMAN (${installed})"
  else
    log "Installing JDK ${JDK_VERSION} via SDKMAN"
    # SDKMAN's internal install scripts reference unset positional params, so
    # `sdk install` blows up under `set -u`. And `yes n | sdk install` exits
    # `yes` on SIGPIPE which `pipefail` reports as a failure even when the
    # install itself succeeded. Relax both around SDKMAN calls.
    set +u
    set +o pipefail
    # `sdk install` asks "Do you want to set <ver> as default?" — answer no.
    if ! yes n | sdk install java "${JDK_VERSION}"; then
      # Fallback: pick the newest 17.x Temurin that SDKMAN currently offers.
      local fallback
      fallback="$(sdk list java 2>/dev/null \
        | grep -Eo '17\.[0-9.]+-tem' | sort -u | tail -n1 || true)"
      if [ -z "${fallback}" ]; then
        set -u
        set -o pipefail
        fail "Could not install JDK 17 via SDKMAN (no 17.x-tem candidate found)."
      fi
      log "Pinned JDK ${JDK_VERSION} not available; falling back to ${fallback}"
      yes n | sdk install java "${fallback}"
    fi
    set -u
    set -o pipefail
    # Refresh detection after install.
    installed="$(find "${java_dir}" -mindepth 1 -maxdepth 1 -type d -name '17.*' \
      -printf '%f\n' 2>/dev/null | head -n1)"
  fi

  # Use the installed JDK 17 in this shell so subsequent steps see it.
  if [ -n "${installed}" ]; then
    sdk use java "${installed}" >/dev/null || true
  fi

  if ! command -v javac >/dev/null 2>&1; then
    warn "javac not on PATH after JDK install. SDKMAN may need a fresh shell."
  else
    log "javac: $(javac -version 2>&1)"
  fi
}

# ---------------------------------------------------------------------------
# 2. nvm + Node LTS
# ---------------------------------------------------------------------------

NVM_DIR="${NVM_DIR:-${HOME}/.nvm}"

ensure_nvm() {
  if [ -s "${NVM_DIR}/nvm.sh" ]; then
    log "nvm already installed at ${NVM_DIR}"
    return 0
  fi

  log "Installing nvm to ${NVM_DIR}"
  export NVM_DIR
  # Pin a known-good nvm release; update infrequently.
  PROFILE=/dev/null curl -fsSL --max-time 300 -o- \
    https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

  if [ ! -s "${NVM_DIR}/nvm.sh" ]; then
    fail "nvm install reported success but ${NVM_DIR}/nvm.sh is missing."
  fi
}

ensure_node_lts() {
  # nvm's shell functions reference unset vars, same as SDKMAN. Keep -u off
  # for the whole function body and restore on every exit path.
  set +u
  # shellcheck disable=SC1091
  . "${NVM_DIR}/nvm.sh"

  # `nvm which lts/*` exits 0 and prints the path if any LTS is installed.
  if nvm which 'lts/*' >/dev/null 2>&1; then
    nvm use --lts >/dev/null 2>&1 || true
    log "Node LTS already installed via nvm: $(node --version 2>/dev/null || echo unknown)"
    set -u
    return 0
  fi

  log "Installing Node LTS via nvm"
  nvm install --lts
  nvm use --lts >/dev/null
  log "Node: $(node --version)"
  set -u
}

# ---------------------------------------------------------------------------
# 3. Android command-line tools + SDK packages + license acceptance
# ---------------------------------------------------------------------------

ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${HOME}/.android}"
CMDLINE_TOOLS_DIR="${ANDROID_SDK_ROOT}/cmdline-tools/latest"

# Bubblewrap defaults: API 34, build-tools 34.0.0. Pinned here for reproducibility.
ANDROID_API_LEVEL="34"
ANDROID_BUILD_TOOLS="34.0.0"

# Android command-line tools — Linux x86_64 build. Update version periodically.
# Keep the URL exactly as Google publishes; pin the build number.
CMDLINE_TOOLS_VERSION="11076708"
CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"

ensure_android_cmdline_tools() {
  if [ -x "${CMDLINE_TOOLS_DIR}/bin/sdkmanager" ]; then
    log "Android cmdline-tools already installed at ${CMDLINE_TOOLS_DIR}"
    return 0
  fi

  log "Installing Android command-line tools to ${CMDLINE_TOOLS_DIR}"
  mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools"

  local tmpdir
  tmpdir="$(mktemp -d)"
  trap 'rm -rf "${tmpdir}"' RETURN

  curl -fsSL --max-time 900 -o "${tmpdir}/cmdline-tools.zip" "${CMDLINE_TOOLS_URL}"
  unzip -q "${tmpdir}/cmdline-tools.zip" -d "${tmpdir}"

  # The zip extracts to a directory named "cmdline-tools/"; sdkmanager expects
  # to live at <root>/cmdline-tools/latest/.
  if [ ! -d "${tmpdir}/cmdline-tools" ]; then
    fail "Unexpected layout in Android cmdline-tools zip."
  fi
  mv "${tmpdir}/cmdline-tools" "${CMDLINE_TOOLS_DIR}"

  if [ ! -x "${CMDLINE_TOOLS_DIR}/bin/sdkmanager" ]; then
    fail "sdkmanager not found at ${CMDLINE_TOOLS_DIR}/bin/sdkmanager after install."
  fi
}

# Bubblewrap's AndroidSdkTools.validatePath() insists on either <SDK>/tools or
# <SDK>/bin existing, while a modern SDK only ships
# <SDK>/cmdline-tools/latest/bin. <SDK>/bin must be a real directory of
# per-file symlinks: the SDK launchers are gradle-style and resolve $0 through
# `while [ -h "$PRG" ]`, which follows file symlinks only. Behind a symlinked
# directory $0 is not itself a link, so APP_HOME lands on <SDK>, the classpath
# misses <SDK>/cmdline-tools/latest/lib, and every tool dies with
# ClassNotFoundException.
ensure_android_bin_links() {
  local bin_dir="${ANDROID_SDK_ROOT}/bin"
  local src_dir="${CMDLINE_TOOLS_DIR}/bin"

  if [ ! -d "${src_dir}" ]; then
    fail "Android cmdline-tools are missing at ${src_dir}; cannot link ${bin_dir}."
  fi

  if [ -L "${bin_dir}" ]; then
    rm -f "${bin_dir}"
    log "Replaced the directory symlink at ${bin_dir}; it breaks the SDK launchers"
  fi
  mkdir -p "${bin_dir}"

  local target name link rel
  for target in "${src_dir}"/*; do
    [ -f "${target}" ] || continue
    name="$(basename "${target}")"
    link="${bin_dir}/${name}"
    rel="../cmdline-tools/latest/bin/${name}"
    if [ "$(readlink "${link}" 2>/dev/null || true)" != "${rel}" ]; then
      rm -f "${link}"
      ln -s "${rel}" "${link}"
    fi
  done
  log "Linked ${bin_dir}/* -> cmdline-tools/latest/bin/* (for bubblewrap)"

  if [ -x "${bin_dir}/sdkmanager" ]; then
    if ! "${bin_dir}/sdkmanager" --version >/dev/null 2>&1; then
      fail "${bin_dir}/sdkmanager does not run; the SDK launchers cannot find their lib dir."
    fi
    log "sdkmanager runs through ${bin_dir}"
  fi
}

ensure_android_sdk_packages() {
  local sdkmanager="${CMDLINE_TOOLS_DIR}/bin/sdkmanager"

  if [ ! -x "${sdkmanager}" ]; then
    fail "sdkmanager missing; install Android cmdline-tools first."
  fi

  # sdkmanager wants ANDROID_HOME / ANDROID_SDK_ROOT set in its env.
  export ANDROID_HOME="${ANDROID_SDK_ROOT}"
  export ANDROID_SDK_ROOT

  # Detect already-installed packages by checking the SDK directory layout.
  # This is robust across sdkmanager versions, unlike parsing `--list` output.
  local needed=()
  [ -d "${ANDROID_SDK_ROOT}/platform-tools" ] \
    || needed+=("platform-tools")
  [ -d "${ANDROID_SDK_ROOT}/platforms/android-${ANDROID_API_LEVEL}" ] \
    || needed+=("platforms;android-${ANDROID_API_LEVEL}")
  [ -d "${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS}" ] \
    || needed+=("build-tools;${ANDROID_BUILD_TOOLS}")

  if [ ${#needed[@]} -eq 0 ]; then
    log "Android SDK packages already installed (platform-tools, platforms;android-${ANDROID_API_LEVEL}, build-tools;${ANDROID_BUILD_TOOLS})"
  else
    log "Installing Android SDK packages: ${needed[*]}"
    # `yes |` exits via SIGPIPE when sdkmanager closes its stdin, which
    # `pipefail` reports as failure even when the install succeeded.
    set +o pipefail
    yes | "${sdkmanager}" --licenses >/dev/null 2>&1 || true
    yes | "${sdkmanager}" "${needed[@]}" >/dev/null
    set -o pipefail
  fi

  # Always (re-)accept licenses; cheap and idempotent.
  log "Accepting Android SDK licenses (non-interactive)"
  set +o pipefail
  yes | "${sdkmanager}" --licenses >/dev/null 2>&1 || true
  set -o pipefail
}

# ---------------------------------------------------------------------------
# 4. @bubblewrap/cli — npm install -g with prefix ~/.local
# ---------------------------------------------------------------------------

NPM_PREFIX="${NPM_PREFIX:-${HOME}/.local}"

ensure_bubblewrap() {
  # Make sure node/npm are on PATH (sourced from nvm in this script).
  if ! command -v npm >/dev/null 2>&1; then
    fail "npm is not on PATH. nvm install must have failed."
  fi

  mkdir -p "${NPM_PREFIX}/bin"

  # Use the script-local PATH so the freshly-installed bubblewrap is visible
  # to the version check below.
  export PATH="${NPM_PREFIX}/bin:${PATH}"

  if command -v bubblewrap >/dev/null 2>&1; then
    local current
    current="$(bubblewrap --version 2>/dev/null | head -n1 || true)"
    log "@bubblewrap/cli already installed (${current:-unknown version})"
    return 0
  fi

  log "Installing @bubblewrap/cli with npm prefix ${NPM_PREFIX}"
  # Set npm prefix only for this install so the user's global npm config is
  # untouched.
  npm install -g --prefix "${NPM_PREFIX}" @bubblewrap/cli

  if ! command -v bubblewrap >/dev/null 2>&1; then
    warn "bubblewrap installed but not on PATH. Check ${NPM_PREFIX}/bin."
  else
    log "bubblewrap: $(bubblewrap --version 2>/dev/null | head -n1)"
  fi
}

# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------

# Sourced instead of executed: the caller wants the functions above, nothing
# installed. The regression test in src/twa.rs takes this route.
if [ "${BASH_SOURCE[0]}" != "${0}" ]; then
  return 0
fi

# Required CLI tools that must be present on the host. None of these can
# be installed without sudo, so fail loudly if missing.
require_host_tool curl
require_host_tool unzip
require_host_tool zip

log "mobux TWA toolchain setup starting"

ensure_sdkman
ensure_jdk17
ensure_nvm
ensure_node_lts
ensure_android_cmdline_tools
ensure_android_bin_links
ensure_android_sdk_packages
ensure_bubblewrap

# ---------------------------------------------------------------------------
# Final PATH / env hints — for a human at a terminal only. The server streams
# this script's output into the install page, where telling the reader to edit
# a shell rc and open a new shell is exactly the instruction the button exists
# to remove.
# ---------------------------------------------------------------------------

if [ ! -t 1 ]; then
  log "TWA toolchain setup complete."
  exit 0
fi

cat <<EOF

==========================================================================
TWA toolchain setup complete.

All tools are installed user-locally. Add the following to your shell rc
(~/.bashrc or ~/.zshrc) so they're available in new shells:

  # SDKMAN (Java)
  export SDKMAN_DIR="${SDKMAN_DIR}"
  [ -s "\$SDKMAN_DIR/bin/sdkman-init.sh" ] && . "\$SDKMAN_DIR/bin/sdkman-init.sh"

  # nvm (Node)
  export NVM_DIR="${NVM_DIR}"
  [ -s "\$NVM_DIR/nvm.sh" ] && . "\$NVM_DIR/nvm.sh"

  # Android SDK
  export ANDROID_HOME="${ANDROID_SDK_ROOT}"
  export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT}"
  export PATH="\$ANDROID_HOME/cmdline-tools/latest/bin:\$ANDROID_HOME/platform-tools:\$PATH"

  # @bubblewrap/cli (npm prefix)
  export PATH="${NPM_PREFIX}/bin:\$PATH"

After updating your rc, open a new shell and verify:
  javac -version
  node --version
  sdkmanager --version
  bubblewrap --version
==========================================================================
EOF