diff --git a/.dev-updater.ron b/.dev-updater.ron index 54f3613..1c249c0 100644 --- a/.dev-updater.ron +++ b/.dev-updater.ron @@ -18,17 +18,9 @@ components: [ name: "backend", build: "cargo build --release", cwd: "server", - // Dev Updater's own built-in service implementation, generated - // into its data directory and driven through the same interface a - // project-supplied script would be. ai-app carried a script of its - // own until 2026-08-28; it ran `target/release/ai-server` with no - // arguments and no environment, which is the generic case exactly, - // so it was two copies of one thing and the copy that could not be - // tested from here -- the OpenRC branch -- was duplicated with it. - // - // Resolved against this component's `cwd`, so this is - // `server/target/release/ai-server`. - service: Managed("target/release/ai-server"), + // Installs into whichever user-service manager is here, and is how + // ai-server is started, stopped and asked about. See the script. + service: "server/service", ), Apk( name: "app", diff --git a/AGENTS.md b/AGENTS.md index 971bdf3..f8e4013 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,21 +48,10 @@ repo is in PLAN.md's "Backend layout" section. event model mirror; `ServerConfig.kt` settings + Keystore-sealed token; screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. - `.dev-updater.ron` — what Dev Updater is asked to do with this checkout: - the backend (built in `server/`, run as `service: Managed(...)`) and then - the APK (built in `app/`), in that order. The project it serves is the - repository, not either half of it, which is why this sits at the root - rather than in `app/`. - `Managed` means Dev Updater supervises `ai-server` with its own built-in - service implementation rather than a script kept here. ai-app had such a - script until 2026-08-28 and it was the generic case exactly — no - arguments, no environment — so the two projects were maintaining one - behaviour twice, including the OpenRC branch neither can test from a - systemd machine. - Worth knowing before pressing it: **Stop** on the backend card stops the - server that a phone reaches through the tunnel, so on that phone it stays - down until someone starts it again from Dev Updater. Dev Updater reaches - it over its own port and is unaffected, which is what makes the button - safe to press and easy to regret. + the backend (built in `server/`, installed and controlled through + `server/service`) and then the APK (built in `app/`), in that order. The + project it serves is the repository, not either half of it, which is why + this sits at the root rather than in `app/`. - `wg-app-link/` — a **git submodule**, and the half of this backend that dev-updater also needed: the pinned CA and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0 binding and the certificate's SANs diff --git a/server/service b/server/service new file mode 100755 index 0000000..7a48358 --- /dev/null +++ b/server/service @@ -0,0 +1,233 @@ +#!/bin/sh +# Installs and controls ai-server as a user service, for Dev Updater to +# drive from the phone -- and for you to drive by hand, which is the same +# thing. +# +# Declared as the Server component of this checkout in `.dev-updater.ron` +# at the repository root, so the card drives it like any other project's. +# One button is worth knowing about before pressing it: on a phone that +# reaches ai-server through the tunnel rather than through Dev Updater, +# Stop leaves this server down until someone starts it again here. +# +# ./service install | uninstall | start | stop | restart | status | logs +# +# `status` prints exactly one of `running`, `stopped`, `failed` or +# `not-installed` +# and exits 0. Anything else it prints, or any non-zero exit, means it +# could not tell -- which the card shows as "couldn't check" rather than +# as a service that is down. +# +# Nothing here ever prompts. Dev Updater runs this with stdin closed and no +# terminal, so a `sudo` password prompt would not fail, it would hang until +# the timeout with the card stuck mid-action. Anything needing root exits +# with a message telling you to run it yourself once instead. +# +# User services on purpose: a system unit needs root to install, and a +# build machine's own account is where a dev server belongs. Note that a +# user service stops at logout unless lingering is enabled +# (`loginctl enable-linger $USER`), which you want for this one: the phone +# reaches this server whether or not anyone is logged in at the desk. +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +NAME=ai-server +BINARY="$SCRIPT_DIR/target/release/$NAME" + +# Where this service's output goes, and the one generation kept behind it. +# +# Under $XDG_DATA_HOME rather than the checkout: a log is generated data, +# it outlives any one build, and the repository is shared with a machine +# that should not be able to read it. Rotated on start rather than by size +# or age, so what is kept is exactly "this run and the one before" -- which +# is the pair worth having after a crash and a restart, and is the reason +# the file is not simply appended to forever. +LOG_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/$NAME" +LOG="$LOG_DIR/$NAME.log" +LOG_PREVIOUS="$LOG.1" + +# Which init system is here, decided by asking rather than by looking for a +# binary: a machine can carry both, and an OpenRC older than 0.60 has +# rc-service but no --user at all. +detect() { + if command -v systemctl >/dev/null 2>&1 && + systemctl --user show-environment >/dev/null 2>&1; then + echo systemd + elif command -v rc-service >/dev/null 2>&1 && + rc-service --user --help >/dev/null 2>&1; then + echo openrc + else + echo none + fi +} + +MANAGER=$(detect) +if [ "$MANAGER" = none ]; then + echo "No user-service manager here: this needs systemd with a user bus," >&2 + echo "or OpenRC 0.60+ (older ones have no --user). Run $NAME by hand." >&2 + exit 1 +fi + +# OpenRC keeps user-service state under XDG_RUNTIME_DIR and refuses without +# it. Worth saying plainly: from a server started outside a login session +# it can be unset, and the failure otherwise reads as a broken service +# rather than a missing variable. +if [ "$MANAGER" = openrc ] && [ -z "${XDG_RUNTIME_DIR:-}" ]; then + echo "XDG_RUNTIME_DIR is unset, and OpenRC stores user-service state in it." >&2 + echo "Set it at login (elogind or pam_xdg) and try again." >&2 + exit 1 +fi + +SYSTEMD_UNIT="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$NAME.service" +OPENRC_UNIT="${XDG_CONFIG_HOME:-$HOME/.config}/rc/init.d/$NAME" + +installed() { + case "$MANAGER" in + systemd) [ -f "$SYSTEMD_UNIT" ] ;; + openrc) [ -f "$OPENRC_UNIT" ] ;; + esac +} + +# Whether the service fell over, as opposed to being stopped on purpose. +# +# OpenRC prints `crashed` *and* exits non-zero for this, so the word is +# read rather than the exit code -- leaning on the code would report +# "couldn't check", which is a different and less useful thing to say. +# The `running` check above stays on its exit code, which already worked +# and does not depend on wording. +crashed() { + case "$MANAGER" in + systemd) systemctl --user --quiet is-failed "$NAME" ;; + openrc) rc-service --user "$NAME" status 2>/dev/null | grep -qw crashed ;; + esac +} + +require_binary() { + [ -x "$BINARY" ] && return 0 + echo "No built server at $BINARY -- build it first." >&2 + exit 1 +} + +do_install() { + require_binary + mkdir -p "$LOG_DIR" + case "$MANAGER" in + systemd) + mkdir -p "$(dirname "$SYSTEMD_UNIT")" + cat > "$SYSTEMD_UNIT" </dev/null + ;; + openrc) + mkdir -p "$(dirname "$OPENRC_UNIT")" + cat > "$OPENRC_UNIT" </dev/null + ;; + esac +} + +do_uninstall() { + installed || return 0 + case "$MANAGER" in + systemd) + systemctl --user disable --now "$NAME" >/dev/null 2>&1 || true + rm -f "$SYSTEMD_UNIT" + systemctl --user daemon-reload + ;; + openrc) + rc-service --user "$NAME" stop >/dev/null 2>&1 || true + rc-update --user del "$NAME" >/dev/null 2>&1 || true + rm -f "$OPENRC_UNIT" + ;; + esac +} + +# Keeps the finished run and starts a fresh file for the next one. +rotate() { + mkdir -p "$LOG_DIR" + [ -f "$LOG" ] && mv -f "$LOG" "$LOG_PREVIOUS" + : > "$LOG" +} + +control() { + installed || { echo "$NAME is not installed" >&2; exit 1; } + case "$MANAGER" in + systemd) systemctl --user "$1" "$NAME" ;; + openrc) rc-service --user "$NAME" "$1" ;; + esac +} + +case "${1:-}" in + install) do_install ;; + uninstall) do_uninstall ;; + start | restart) + # Rotated before the manager is asked, so the file the service + # opens is the new one. + rotate + control "$1" + ;; + stop) control stop ;; + logs) + # One path per line, newest first, and nothing else: the caller + # wants somewhere to read from, not a formatted report. Printing + # nothing at all is the answer for a service with no log yet, + # which reads as "not supported here" and costs nobody anything. + [ -f "$LOG" ] && echo "$LOG" + [ -f "$LOG_PREVIOUS" ] && echo "$LOG_PREVIOUS" + exit 0 + ;; + status) + if ! installed; then + echo not-installed + elif case "$MANAGER" in + systemd) systemctl --user --quiet is-active "$NAME" ;; + openrc) rc-service --user "$NAME" status >/dev/null 2>&1 ;; + esac then + echo running + elif crashed; then + # Before `stopped`, because a crashed service satisfies + # neither of the other two and would otherwise be reported as + # a state somebody chose. + echo failed + else + echo stopped + fi + ;; + *) + echo "usage: $0 install|uninstall|start|stop|restart|status|logs" >&2 + exit 2 + ;; +esac