mobux 0.40.2

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
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
# Deploying mobux

mobux runs as a **single self-contained binary**: the entire `web/static`
frontend is embedded with `rust-embed`, so the executable serves the UI from
memory and needs no `web/` directory beside it. That makes `cargo install`
the whole deployment story.

The production instance is a **systemd user service** on `:5151`, running the
**published, installed binary** — completely decoupled from the dev checkout.
Hack on the repo all you want; it does not touch the running app until you
deliberately `cargo install` a new version and restart the service.

> ⚠️ `:5151` is the live instance accessed from the phone. Never run
> `make run` / `make start` / `make restart` against it — those launch a
> nohup process that fights the service's `Restart=always`. See
> [Development]#development-never-touch-5151 below.

## Install

Prebuilt Linux binary from the GitHub release (seconds, no compile; every
release ships `mobux-x86_64-unknown-linux-gnu.tar.gz` and
`mobux-aarch64-unknown-linux-gnu.tar.gz`, each with a `.sha256` checksum file,
as assets). Each is ~137 MB: the binary plus the default speech model
(`stt-models/base.en/`, f16 weights), so a fresh install dictates without
fetching a model from anywhere. The other two checkpoints ship as their own
platform-independent assets, downloaded only if someone picks them —
`mobux-stt-tiny.en.tar.gz` (~67 MB) and `mobux-stt-small.en.tar.gz`
(~424 MB), each with a `.sha256`. `install.sh` picks the platform asset
matching `uname -m`:

```bash
curl -fsSL https://raw.githubusercontent.com/mvhenten/mobux/main/install.sh | bash
```

Each asset carries the voice the reader speaks with alongside the binary, and
`install.sh` unpacks it into `$MOBUX_DATA_DIR/tts-voices` — so a prebuilt
install reads aloud without fetching a model from anywhere. A `cargo install`
build has no voice beside it: build it with `cargo install mobux --locked
--features local-tts` and the first request pulls the same published asset and
checks every file against `src/local_tts/voice.lock.json`. Point
`MOBUX_TTS_MODEL_DIR` at a directory holding the voice to skip that entirely,
which is what an airgapped host wants. Without the feature the reader falls
back to the browser's own speech synthesis.

`install.sh` only reads `MOBUX_DATA_DIR` from the environment, so an instance
whose data dir comes from `paths.data_dir` in `config.json` looks elsewhere for
the voice and fetches its own copy on first use. Run the installer with the
same directory the server uses — `MOBUX_DATA_DIR=… curl … | bash` — or point
`MOBUX_TTS_MODEL_DIR` at where the installer put it.

By hand, naming the triple for your architecture:

```bash
ASSET=mobux-x86_64-unknown-linux-gnu.tar.gz   # or mobux-aarch64-unknown-linux-gnu.tar.gz
curl -fsSLO "https://github.com/mvhenten/mobux/releases/latest/download/$ASSET"
curl -fsSLO "https://github.com/mvhenten/mobux/releases/latest/download/$ASSET.sha256"
sha256sum -c "$ASSET.sha256"
tar -xzf "$ASSET" -C ~/.cargo/bin mobux
mkdir -p ~/.local/share/mobux
tar -xzf "$ASSET" -C ~/.local/share/mobux stt-models   # the speech model
```

From crates.io (released versions; 5-10 min release-mode compile). The crate
carries no weights — crates.io caps a crate near 10 MB — so build with the
engine and it pulls the same release asset on first use, checking every file
against the hashes compiled into it:

```bash
cargo install mobux --locked --features local-stt
# on aarch64, add: RUSTFLAGS="-C target-feature=+fp16"
```

Switching model in settings downloads that checkpoint's asset — from the
release matching the running version, not `latest`, because the hashes are
compiled into the binary — and verifies it against
`src/local_stt/model.lock.json` before loading it. Weights are stored f16 and
run f32, so resident memory is about twice the download: ~290 MB for base.en,
~150 MB for tiny.en, ~970 MB for small.en.

On arm64 the engine needs ARMv8.2 half-precision (FEAT_FP16). candle's gemm
emits those instructions without declaring the target feature, so the build
enables it for the whole binary (`.cargo/config.toml`); an ARMv8.0 core
(Cortex-A72, so a Raspberry Pi 4) cannot execute them, and mobux checks for the
feature before loading anything and reports the local provider as unavailable
instead of taking the process down. For an airgapped
host, or to run a checkpoint mobux does not publish, point
`MOBUX_STT_MODEL_DIR` at a directory holding `config.json`, `tokenizer.json`
and `model.safetensors`. A directory named there is used as given and never
checked against the lock.

A maintainer refreshes a checkpoint with
`node scripts/stt-model.mjs fetch <dir> <model>` followed by
`node scripts/stt-model.mjs lock <dir> <model>`, which rewrites
`src/local_stt/model.lock.json` (`models` lists the catalog, `vendored` names
the one the platform tarball carries). That script is the only thing in the
repo that contacts Hugging Face; nothing does at runtime.

Straight from GitHub (latest `main`, including unreleased commits):

```bash
cargo install --git https://github.com/mvhenten/mobux --locked
# or a specific point:  --tag v0.1.1   /   --branch some-branch
```

`cargo install` always builds the release profile, so the result is the
self-contained binary at `~/.cargo/bin/mobux`. It runs from any directory.

## Configuration

`mobux --help` lists every flag and every environment variable. A flag wins over
the variable next to it, which wins over the config file, which wins over the
defaults. The file is `config.json` in the config directory: `MOBUX_CONFIG_DIR`,
else `$XDG_CONFIG_HOME/mobux`, else `~/.config/mobux`. `--config PATH` names
another file.

Anything on the command line is visible to other users in the process list, so a
long-running instance keeps its PIN in the config file or in `MOBUX_PIN`.

### Config reference

| Config key | Environment | Flag | Default | What it sets |
|---|---|---|---|---|
| `server.port` | `MOBUX_PORT` | `--port` | `8080` | TCP port to listen on |
| `server.base_path` | `MOBUX_BASE_PATH` | `--base-path` | site root | Path prefix a reverse proxy publishes mobux under, e.g. `/mobux` |
| `server.behind_tls_proxy` | `MOBUX_BEHIND_TLS_PROXY` | `--behind-tls-proxy` | `false` | Trust a reverse proxy to terminate TLS |
| `auth.user` | `MOBUX_AUTH_USER` | `--user` | unset | Username that unlocks the web UI |
| `auth.pass` | `MOBUX_AUTH_PASS` | `--pass` | unset | Password that unlocks the web UI |
| `auth.pin` | `MOBUX_PIN` | `--pin` | unset | PIN that unlocks the web UI, 4 to 64 characters |
| `tls.enabled` | `MOBUX_TLS` | `--tls` | `false` | Serve HTTPS with a generated certificate |
| `tls.hosts` | `MOBUX_TLS_HOSTS` | `--tls-host` | empty | Extra hostnames on the generated certificate |
| `tls.cert_file` | `MOBUX_CERT_FILE` | `--cert-file` | unset | Certificate PEM to serve instead of a generated one |
| `tls.key_file` | `MOBUX_KEY_FILE` | `--key-file` | unset | Private key PEM matching the certificate |
| `tls.acme_domains` | `MOBUX_ACME_DOMAINS` | `--acme-domain` | empty | Domains to obtain an ACME certificate for. A non-empty list switches TLS into ACME mode |
| `tls.acme_email` | `MOBUX_ACME_EMAIL` | `--acme-email` | unset | Account contact for the ACME directory. Required in ACME mode |
| `tls.acme_directory` | `MOBUX_ACME_DIRECTORY` | `--acme-directory` | `https://acme-v02.api.letsencrypt.org/directory` | ACME directory URL |
| `tls.acme_http_port` | `MOBUX_ACME_HTTP_PORT` | `--acme-http-port` | `80` | Port the HTTP-01 challenge responder binds |
| `paths.data_dir` | `MOBUX_DATA_DIR` | `--data-dir` | `~/.local/share/mobux` | Directory for the database and other state |
| `session.shell` | `MOBUX_SESSION_SHELL` | `--shell` | `$SHELL`, else `/bin/bash` | Shell to launch inside tmux |
| `app.domain` | `MOBUX_DOMAIN` | `--domain` | unset | Public `host` or `host:port` the Android app is pinned to |
| `app.dev` | `MOBUX_DEV` | `--dev` | `false` | Dev mode, reported through `/api/build-info` |
| `app.service_name` | `MOBUX_SERVICE_NAME` | `--service-name` | `mobux` | systemd unit the self-updater restarts |
| `push.vapid_contact` | `MOBUX_VAPID_CONTACT` | `--vapid-contact` | `mailto:admin@example.com` | VAPID contact, a `mailto:` address or an `https://` URL |
| `update.check_url` | `MOBUX_UPDATE_CHECK_URL` | `--update-check-url` | `https://index.crates.io/mo/bu/mobux` | Where the version list is fetched from |

Config keys nest. `server.port` is `{"server": {"port": 5151}}`, and only the
keys a file states override a default.

Toggles take `--flag` to turn on and `--no-flag` to turn off. `--flag=` also
takes `1`, `true`, `yes`, `on`, `0`, `false`, `no` and `off`. `MOBUX_TLS` reads
any value other than `0` and `false` as on; every other toggle variable wants
`1` or `true`.

List flags repeat, or take one comma-separated value. Their environment
variables are comma separated.

### The schema

`mobux configure --schema` prints the JSON schema for `config.json`. The same
document is committed at [`docs/mobux.schema.json`](docs/mobux.schema.json); no
route serves it. `mobux configure --check [PATH]` validates a file and reports
what is wrong with it, naming the key and, for a near miss, the spelling it
expected. The loader rejects any key it does not know, `$schema` included, so
the file carries no schema pointer of its own.

### Environment only

These four have no config-file key.

| Variable | What it does |
|---|---|
| `PORT` | Deprecated alias for `MOBUX_PORT`. The server warns at startup: `PORT is deprecated; rename it to MOBUX_PORT` |
| `MOBUX_CONFIG_DIR` | Directory holding `config.json`, ahead of `$XDG_CONFIG_HOME/mobux` and `~/.config/mobux` |
| `MOBUX_UPDATE_DISABLE_RUN` | Refuses the in-app update on this host |
| `MOBUX_TMUX_SOCKET` | Names a dedicated tmux server socket, for test isolation |

mobux resolves the listen port as `--port`, `MOBUX_PORT`, `PORT`, then `8080`.

## Run as a boot-persistent service (`:5151`)

The host runs mobux as a **systemd `--user`** service with linger enabled, so
it starts on boot (no login needed) and restarts on crash — no root required.

`mobux service install --port 5151 --user me --pin 12345` does all of this for
you: it writes those settings to `~/.config/mobux/config.json` (mode 600, since
it holds the PIN), writes the unit below pointing at the binary you ran it from
and at that file, reloads systemd, enables the service and turns on linger.
`--config PATH` puts the settings somewhere else and points the unit there.
Run it as the user the service belongs to — under `sudo` it is refused, since
it would install a second service for root; `--allow-root` is there for a
deliberate root install, and `sudo loginctl enable-linger "$USER"` covers the
one step polkit may deny. Behind a proxy that authenticates for mobux, pass
`--no-auth` instead of `--user`/`--pin`: the config is written without
credentials, and both the install and every start say auth is off.
`mobux service status` and `mobux service uninstall` cover the rest, and
`mobux update` installs the latest release and restarts that unit.
Rerun `install` with different flags to rewrite the config and restart the
service. The manual recipe stays here as the reference for what that unit
contains:

```bash
cargo install mobux --locked                 # → ~/.cargo/bin/mobux
loginctl enable-linger "$USER"                # start the user service at boot

mobux configure                               # → ~/.config/mobux/config.json

mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/mobux.service <<'EOF'
[Unit]
Description=mobux — mobile tmux web frontend (:5151)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=%h/.cargo/bin/mobux --config %h/.config/mobux/config.json
# The self-updater runs `cargo install`; the default unit PATH lacks ~/.cargo/bin.
Environment=PATH=%h/.cargo/bin:/usr/local/bin:/usr/bin:/bin
Restart=always
RestartSec=5
# Only kill the mobux process itself — the tmux server it spawned lives in the
# same cgroup, and the default would kill it (and every session) on restart.
KillMode=process

[Install]
WantedBy=default.target
EOF

systemctl --user daemon-reload
systemctl --user enable --now mobux
```

Units written by an older release carry the port, username and PIN as
`Environment=` lines instead. They keep working — the environment still
outranks the config file — and rerunning `mobux service install` migrates them.

`mobux service install` leaves `tls.enabled` off unless you pass `--tls`, so
the service serves plain HTTP. With TLS on, the cert is auto-generated (and
reused across restarts) at
`~/.config/mobux/leaf.crt`; the data dir (sessions, push subscriptions, and
the Android package once built) is `~/.local/share/mobux`. Nothing depends on
the working directory.

The Android APK is built from the `/install` page's **Generate package**
button, which signs it for the address the request arrived on (override with
`Environment=MOBUX_DOMAIN=...`). The button installs the JDK, Node and Android
SDK it needs on first use, so there is no terminal step; only `zip`, `unzip` and
`curl` come from the OS package manager, and the page names them if they are
missing. The signing keystore stays at `~/.config/mobux/twa-signing.keystore`,
so fingerprints survive rebuilds and reinstalls.

Verify the embed + service:

```bash
curl -s -u "$MOBUX_AUTH_USER:$MOBUX_PIN" http://localhost:5151/static/style.css   # 200 → served from the binary
# with TLS on: curl -sk … https://localhost:5151/static/style.css
systemctl --user status mobux
journalctl --user -u mobux -f
```

### Redeploy a new version

```bash
cargo install mobux --locked        # or the --git form
systemctl --user restart mobux      # sub-second swap; :5151 barely blinks
```

## Behind a reverse proxy

mobux builds every URL relative to the page it serves, so a proxy can publish it
under any path prefix. Three settings cover what a relative URL cannot.

```json
{
  "tls": { "enabled": false },
  "server": { "behind_tls_proxy": true, "base_path": "/mobux" }
}
```

`tls.enabled` false is the default: the proxy terminates TLS and mobux binds
plain HTTP.

`server.behind_tls_proxy` true keeps the `Secure` flag on the session cookie and
silences the clear-text warning. Set it only when TLS really terminates in
front. On a plain-HTTP deployment the browser refuses a `Secure` cookie, and
every request then falls back to a fresh Basic-auth prompt.

`server.base_path` is the prefix the browser is on. The proxy strips it before
mobux sees the request, so routing never reads it. It scopes the session
cookie's `Path`: without it the cookie is scoped to `/` and travels to every
other app the same proxy fronts. The value must start with `/` and must not
contain `..` or `;`. A trailing slash is normalised away.

Nothing else needs configuring. Assets, redirects and API calls are all relative
to the served page.

### Limits behind a path prefix

The Android app cannot be installed from a prefixed mount. Android fetches the
Digital Asset Links file from `https://<host>/.well-known/assetlinks.json` at
the origin root ([Android
docs](https://developer.android.com/training/app-links/verify-android-applinks)).
mobux serves that file under its own mount, so behind `/mobux` it answers at
`/mobux/.well-known/assetlinks.json` and verification never finds it. Serve
mobux at the origin root to install the app.

Set `app.domain` to the public address. Left unset, the APK is pinned to the
host on the request's `Host` header, which behind a proxy is whatever the proxy
forwards rather than the address the phone uses.

## Upgrade notes

TLS is off by default. A deployment where mobux terminates HTTPS itself must ask
for it: `tls.enabled` true, `MOBUX_TLS=1`, or `--tls`. With auth on, TLS off and
no `server.behind_tls_proxy`, the server prints a clear-text warning at startup.

`GET /` answers 307 with `Location: app`, resolved against the request URL, so
the redirect lands inside a proxy's path prefix.

An unmatched path answers 200 with the SPA shell, which routes it client-side.

`GET /app/<rest>` answers 307 back to `app`, one `../` per segment of `<rest>`.

## Release & publish (crates.io)

Releasing is owned by **semantic-release** (driven by conventional commits —
single source of truth, don't hand-pick versions). There is **no release PR**
and **no commit back to `main`** (branch protection forbids it). The pipeline is
**fully automatic** from merge to crates.io:

1. Merge feature PRs to `main` with conventional-commit messages. The version
   bump follows the commit types:
   - `feat:` → **minor**
   - `fix:` / `perf:` → **patch**
   - breaking change (`feat!:`, or `BREAKING CHANGE:` footer) → **major**
   - `chore:` / `docs:` / `ci:` / `test:` / `refactor:` / `style:` → **no
     release**
2. The push to `main` runs **CI** (`check` + `e2e`). When CI succeeds, the
   separate **Release** workflow (`.github/workflows/release.yml`, triggered by
   `workflow_run` on CI) runs `npx semantic-release`. It computes the next
   version from the conventional commits since the latest `v*` tag, then:
   creates the **git tag** (`vX.Y.Z`), a **GitHub Release** with generated
   notes plus **prebuilt Linux x86_64 and aarch64 binaries**
   (`mobux-<triple>-unknown-linux-gnu.tar.gz` + `.sha256`, built by
   `scripts/build-release-asset.sh` after the version is patched in, with
   `--features local-stt` and the default speech model packed alongside;
   aarch64 is cross-compiled with the `gcc-aarch64-linux-gnu` toolchain the
   workflow installs), **the two on-demand speech models**
   (`mobux-stt-<model>.tar.gz` + `.sha256`, platform-independent, listed in
   `.releaserc.json` like every other asset), and
   **publishes to crates.io**. The in-app self-updater consumes that asset, so
   updates take seconds instead of a 5-10 min compile.

### The tag is the version truth

There is **no version-bump commit**. The in-repo `Cargo.toml` `version` stays at
the last value that was committed by hand and is therefore **historical** — do
not trust it as the released version; the latest `v*` git tag / GitHub Release /
crates.io is the truth. At publish time the cargo plugin
(`@semantic-release-cargo/semantic-release-cargo`) patches the computed version
into `Cargo.toml` **in the workflow workspace only** before `cargo publish`, so
the crates.io artifact carries the real version while the repo tree is left
untouched. semantic-release derives the next version from the latest `v*` tag,
so the in-repo `Cargo.toml` value is irrelevant to versioning.

### Holding back / skipping a release

- Commit with a non-releasing type (`chore:`, `docs:`, `ci:`, `test:`,
  `refactor:`, `style:`) — semantic-release will find no releasable change and
  do nothing.
- Add `[skip ci]` to the commit message to skip CI entirely (the Release
  workflow only fires on a *successful* CI run, so skipping CI also skips the
  release).

### Dry run

`semantic-release` needs a `GITHUB_TOKEN` even in dry-run mode (it queries the
GitHub API). To preview the next version and notes locally:

```bash
GITHUB_TOKEN=<a token with repo read> npx semantic-release --dry-run --no-ci
```

Without a token the run fails at the GitHub verifyConditions step; that's
expected. To only sanity-check that the config and plugins load (no token
needed), the parse/verify-config portion of `npx semantic-release --dry-run
--no-ci` output is enough — it lists the loaded plugins before hitting auth.

### Prerequisites

The only secret needed is **`CARGO_REGISTRY_TOKEN`** (crates.io publish);
`GITHUB_TOKEN` is the built-in Actions token, and the Release workflow grants it
`contents: write` for tagging + release creation. The old release-plz secrets
(`RELEASE_PLZ_DEPLOY_KEY`, the "release-plz CI trigger" deploy key) and the
"Allow GitHub Actions to create and approve pull requests" repo setting are **no
longer used** and can be removed.

Deploying to hosts stays a separate concern: manual (see above) or the in-app
self-updater (issue #130). The updater downloads the release's prebuilt binary
asset for the running architecture, verifies its sha256, and atomically
replaces the binary `ExecStart` points at (`~/.cargo/bin/mobux`) — it takes
only the binary out of the asset and leaves the speech model alone, so an
update that changes the pinned weights re-fetches them on first use — then
restarts
the unit and health-checks the new version (rollback on failure). Releases
without the asset (≤ v0.1.10) fall back to `cargo install`, which is why the
unit PATH should still include `~/.cargo/bin`.

## Development (never touch `:5151`)

`:5151` is the live instance the phone connects to. Run dev/experimental
builds on a **different port**, detached.

**Quick, throwaway test** (ephemeral, isolated, torn down after):

```bash
make smoke-start        # throwaway instance on :8281 (HTTP, isolated data dir)
make smoke-stop
make test-smoke         # full Playwright suite against the smoke instance
```

The `make run` / `make start` / `make restart` targets bind `:5151` directly
and will collide with the systemd service — use them only on a host where
mobux is **not** running as a service.

### Installable dev instance (parallel to prod, isolated config)

You can install and run a **dev build the same way as prod** — via cargo —
just with its own binary path, port, and data dir so it never touches the
`:5151` instance. `cargo install` defaults to `~/.cargo/bin/mobux`, which is
the prod binary, so a dev build must go to a separate `--root`:

```bash
# install a branch/main build into its OWN location (doesn't overwrite prod)
cargo install --git https://github.com/mvhenten/mobux \
  --branch my-feature --root ~/.local/mobux-dev --locked
# → ~/.local/mobux-dev/bin/mobux
```

Run it with a **different context** — distinct port + data dir (keep its
sessions/push state separate from prod). The TLS cert under
`~/.config/mobux/` is shared (same host), which is fine:

```bash
MOBUX_PORT=5152 \
MOBUX_DATA_DIR=~/.local/share/mobux-dev \
MOBUX_AUTH_USER=me MOBUX_PIN=changeme MOBUX_TLS=1 \
~/.local/mobux-dev/bin/mobux
```

For a persistent dev instance you can reach from the phone, mirror the prod
unit as `~/.config/systemd/user/mobux-dev.service` with
`ExecStart=%h/.local/mobux-dev/bin/mobux`, `Environment=MOBUX_PORT=5152`,
`Environment=MOBUX_DATA_DIR=%h/.local/share/mobux-dev`, `Environment=MOBUX_TLS=1`, and its own
`WorkingDirectory`. Enable it alongside `mobux.service`; the two run
independently on `:5151` and `:5152`. Update it with
`cargo install --git … --root ~/.local/mobux-dev && systemctl --user restart mobux-dev`.

Port map: **`:5151`** prod (systemd, installed release) · **`:5152`** dev
(installed branch build) · **`:8281`** ephemeral smoke/test.

#### Dev TWA app

`make twa-dev` builds a separate **Mobux Dev** Android app — package id
`io.github.mvhenten.mobux.dev`, host `sandbox:5152` — into the repo-local
staging dir `twa/dist-dev/`, reusing the **same signing keystore** as prod
(the assetlinks fingerprint is per-key; only `package_name` differs). Because
it has a different package id, it **coexists** with the prod Mobux app on the
same device — both install side by side.

Deploy it to the `:5152` instance by copying both files into that instance's
data dir (`$MOBUX_DATA_DIR`):

```bash
make twa-dev
mkdir -p "$MOBUX_DATA_DIR/install" "$MOBUX_DATA_DIR/.well-known"
cp twa/dist-dev/install/mobux.apk         "$MOBUX_DATA_DIR/install/mobux.apk"
cp twa/dist-dev/.well-known/assetlinks.json "$MOBUX_DATA_DIR/.well-known/assetlinks.json"
```

Then install it from `https://sandbox:5152/install`.

Prod builds are unchanged in what they produce: an APK plus an assetlinks with
`package_name` `io.github.mvhenten.mobux`.

## Reboot behaviour

- **mobux** — comes back automatically (systemd user service + linger).
- **tailscale** — `tailscaled` is an enabled system service with persisted
  state; it reconnects on its own. The phone/tablet reach the host as
  `sandbox:5151` over the tailnet (MagicDNS) — that exact host is baked into
  the TWA app, so keep it stable.
- **tmux sessions** — do **not** survive a reboot. mobux only *attaches* to a
  running tmux server; there's no tmux-resurrect/continuum configured.