#!/usr/bin/bash
#
# smilebasic-launcher -- start SmileBASIC (or PasocomMini) under QEMU on this
# machine.  It launches the app if the Docker image is already installed; it does
# NOT download/install/remove the image (`docker load -i sbqemu.tar` to install,
# `docker rmi` to remove).  Steps:
#
#   1. checks the Docker image is present (else tells you how to load it, exits);
#   2. starts the container if it is not already running;
#   3. starts the host video/audio daemon (smilebasic-daemon) if not running;
#   4. authorises local X clients and runs the chosen app inside the container;
#   5. when the app exits, stops the container + daemon so nothing lingers in the
#      background (an idle container is ~0% CPU but still holds ~30 MB, a GL
#      context and mounts).  Pass -k to keep them running for instant relaunch.
#
# Usage:
#   smilebasic-launcher [-p] [-8] [-f] [-r WxH] [-k] [-m] [-t] [-h] [--desktop]
#     -p            PasocomMini MZ-80C mode (run PCM instead of SmileBASIC)
#     -8            PasocomMini PC-8001 mode (NEC PC-8001 / N-BASIC)
#     -f            fast: uncap the frame rate (no vsync, runs as fast as it can)
#     -r WxH        force render resolution, e.g. -r 640x480 (default 1280x720)
#     -k            keep the container + daemon running after the app exits
#                   (faster relaunch; skips the shutdown in step 5)
#     -m            start in fullscreen with the cursor grabbed
#     -t            let Tab reach SmileBASIC (default: Tab is the toggle key --
#                   tap frees/grabs the cursor, hold >1s toggles fullscreen)
#     --desktop     create a desktop + app-menu launcher icon, then exit (does
#                   not start SmileBASIC).  Removed when the package is removed.
#     -h            show this help
#
# Env overrides:
#   SB_IMAGE            Docker image      (default: sbqemu:latest)
#   SB_CONTAINER        container name    (default: sbqemu)
#   DISPLAY             X display         (default: :0)
#   VIRGL_TEST_SERVER   virgl server the daemon should launch (default:
#                       autodetected -- see "GPU quirks" below)
#
set -uo pipefail

IMAGE="${SB_IMAGE:-sbqemu:latest}"
CONTAINER="${SB_CONTAINER:-sbqemu}"
DISPLAY="${DISPLAY:-:0}"
DAEMON_BIN="$(command -v smilebasic-daemon || echo /usr/bin/smilebasic-daemon)"
DAEMON_LOG="${TMPDIR:-/tmp}/smilebasic-daemon.log"
VIRGL_SOCK="/tmp/.X11-unix/.virgl_test"
QUIRK_DIR="${SB_QUIRK_DIR:-/usr/lib/smilebasic}"

msg() { printf 'smilebasic: %s\n' "$*"; }
err() { printf 'smilebasic: %s\n' "$*" >&2; }

usage() { sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }

# --- desktop / app-menu launcher icon (--desktop) ---------------------------
# Writes a .desktop entry to the Desktop and the application menu, pointing at
# this launcher and the packaged icon.  The icon PNG is installed by the package
# (so it goes away when the package is removed); the .desktop files are created
# at runtime, and the package's postrm / %postun removes them by name.
ICON_PATH="/usr/share/pixmaps/smilebasic-launcher.png"
DESKTOP_BASENAME="smilebasic-launcher.desktop"
create_desktop() {
    local self desk_dir app_dir entry f
    self="$(command -v smilebasic-launcher || echo /usr/bin/smilebasic-launcher)"
    desk_dir="$(xdg-user-dir DESKTOP 2>/dev/null || true)"; [ -n "$desk_dir" ] || desk_dir="$HOME/Desktop"
    app_dir="$HOME/.local/share/applications"
    entry="[Desktop Entry]
Type=Application
Version=1.0
Name=SmileBASIC
GenericName=SmileBASIC (Raspberry Pi) under QEMU
Comment=Launch SmileBASIC
Exec=$self
Icon=$ICON_PATH
Terminal=false
Categories=Game;Development;
StartupNotify=true"
    [ -f "$ICON_PATH" ] || err "note: icon '$ICON_PATH' not found (is the package installed?)"
    local made=0
    for f in "$desk_dir/$DESKTOP_BASENAME" "$app_dir/$DESKTOP_BASENAME"; do
        mkdir -p "$(dirname "$f")" 2>/dev/null || { err "cannot create $(dirname "$f")"; continue; }
        printf '%s\n' "$entry" > "$f" || { err "cannot write $f"; continue; }
        chmod +x "$f" 2>/dev/null || true
        # some desktops require the .desktop be marked trusted before it will run
        command -v gio >/dev/null 2>&1 && gio set "$f" metadata::trusted true >/dev/null 2>&1 || true
        msg "created $f"
        made=1
    done
    command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$app_dir" >/dev/null 2>&1 || true
    [ "$made" = 1 ] || { err "failed to create any desktop entry."; exit 1; }
    msg "done -- 'SmileBASIC' is now on your desktop and in the app menu."
    exit 0
}

# ---------------------------------------------------------------------------
# 0. Options
# ---------------------------------------------------------------------------
APP=sb                 # sb | pcm | pc8
FAST=0
FORCE_W= ; FORCE_H=
# Keep the container + daemon alive after the app exits?  -k, --keep-alive, or
# SB_KEEP_ALIVE=1 in the environment.  Default: shut them down.
KEEP_ALIVE=0; [ -n "${SB_KEEP_ALIVE:-}" ] && KEEP_ALIVE=1
TAB_PASSTHROUGH=0      # -t: let Tab reach SmileBASIC (default: Tab = toggle key)
START_FULLSCREEN=0     # -m: start fullscreen + cursor grabbed
MK_DESKTOP=0           # --desktop: create launcher icon and exit
while getopts ":p8fr:kmth-:" opt; do
    case "$opt" in
        p) APP=pcm ;;
        8) APP=pc8 ;;
        f) FAST=1 ;;
        k) KEEP_ALIVE=1 ;;
        m) START_FULLSCREEN=1 ;;
        t) TAB_PASSTHROUGH=1 ;;
        r) if [[ "$OPTARG" =~ ^([0-9]+)[xX]([0-9]+)$ ]]; then
               FORCE_W="${BASH_REMATCH[1]}"; FORCE_H="${BASH_REMATCH[2]}"
           else err "bad -r resolution '$OPTARG' (use WxH, e.g. 640x480)"; exit 2; fi ;;
        h) usage 0 ;;
        -) case "$OPTARG" in
               pasocom) APP=pcm ;; pc8001|pasocom8) APP=pc8 ;;
               fast) FAST=1 ;; keep-alive) KEEP_ALIVE=1 ;;
               maximize|fullscreen) START_FULLSCREEN=1 ;; tab-passthrough) TAB_PASSTHROUGH=1 ;;
               desktop) MK_DESKTOP=1 ;;
               help) usage 0 ;;
               *) err "unknown option --$OPTARG"; usage 2 ;;
           esac ;;
        \?) err "unknown option -$OPTARG"; usage 2 ;;
        :) err "option -$OPTARG needs a value"; exit 2 ;;
    esac
done

# --desktop is an action: create the launcher icon and exit (before touching Docker).
[ "$MK_DESKTOP" = 1 ] && create_desktop

case "$APP" in
    sb)  RUN_CMD=/root/run-sb.sh;  APP_NAME=SmileBASIC-R ;;
    pcm) RUN_CMD=/root/run-pcm.sh; APP_NAME="PasocomMini MZ-80C" ;;
    pc8) RUN_CMD=/root/run-pcm8.sh; APP_NAME="PasocomMini PC-8001" ;;
esac

# Per-app env passed into the container (resolution / fast / window behaviour).
RUN_ENV=()
[ -n "$FORCE_W" ] && RUN_ENV+=(-e "SBGL_WIDTH=$FORCE_W" -e "SBGL_HEIGHT=$FORCE_H")
[ "$FAST" = 1 ]   && RUN_ENV+=(-e "SBGL_FPS=0")
[ "$TAB_PASSTHROUGH" = 1 ]  && RUN_ENV+=(-e "SBGL_TAB_PASSTHROUGH=1")
[ "$START_FULLSCREEN" = 1 ] && RUN_ENV+=(-e "SBGL_START_FULLSCREEN=1")

need() {
    command -v "$1" >/dev/null 2>&1 && return 0
    err "required command '$1' not found. Please install it (${2:-}).";
    exit 1
}
need docker "docker.io or docker-ce"

# ---------------------------------------------------------------------------
# GPU quirks: this virgl_test_server build + some vendor Mesas need help.  We ship
# two LD_PRELOAD wrappers:
#   * virgl_test_server_cx4     -- Zhaoxin cx4 iGPU (ZX C-nnnn): proven cx4 shim.
#   * virgl_test_server_generic -- everything else: virgl_gpu_compat.so, a
#     GPU-agnostic shim (GL-version retry, compat profile, GLSL #version bump,
#     extension softening, GL-error suppression), which makes e.g. the Moore
#     Threads MTT S80 render; all its fixups are no-ops where unneeded.
#
# Pick by the GPU the X server ACTUALLY renders on (glxinfo), NOT merely which
# render nodes exist -- a machine can have a cx4 iGPU present yet render on a
# different card, where the cx4 shim would GLXBadFBConfig and crash the video half.
# ---------------------------------------------------------------------------
if [ -z "${VIRGL_TEST_SERVER:-}" ]; then
    _renderer=""
    if command -v glxinfo >/dev/null 2>&1; then
        _renderer="$(DISPLAY="$DISPLAY" glxinfo -B 2>/dev/null | sed -n 's/.*OpenGL renderer string: //p' | head -1)"
    fi
    case "$_renderer" in
        *"ZX "*|*Zhaoxin*|*zhaoxin*)
            if [ -x "$QUIRK_DIR/virgl_test_server_cx4" ]; then
                export VIRGL_TEST_SERVER="$QUIRK_DIR/virgl_test_server_cx4"
                msg "Zhaoxin cx4 GPU ($_renderer) -- using cx4 virgl wrapper"
            fi ;;
        "")
            # glxinfo absent: fall back to render-node driver name
            if grep -qs '^DRIVER=cx4$' /sys/class/drm/renderD*/device/uevent 2>/dev/null \
               && [ -x "$QUIRK_DIR/virgl_test_server_cx4" ]; then
                export VIRGL_TEST_SERVER="$QUIRK_DIR/virgl_test_server_cx4"
                msg "cx4 render node present (glxinfo absent) -- using cx4 virgl wrapper"
            elif [ -x "$QUIRK_DIR/virgl_test_server_generic" ]; then
                export VIRGL_TEST_SERVER="$QUIRK_DIR/virgl_test_server_generic"
                msg "GPU unknown (glxinfo absent) -- using generic virgl compat wrapper"
            fi ;;
        *)
            if [ -x "$QUIRK_DIR/virgl_test_server_generic" ]; then
                export VIRGL_TEST_SERVER="$QUIRK_DIR/virgl_test_server_generic"
                msg "GPU: $_renderer -- using generic virgl compat wrapper"
            fi ;;
    esac
fi

# ---------------------------------------------------------------------------
# Server capability probe.  The vtest command line is NOT stable across
# virglrenderer releases, and distros pin wildly different ones (UOS Server 20:
# 1.1.0; Deepin 25: 0.8.2 from 2019).  0.8.2 has neither --multi-clients nor
# --socket-path, so smilebasic-daemon's invocation makes it exit on a usage error: no
# socket, and the container silently falls back to llvmpipe.  We ship our own
# 1.1.0 in $QUIRK_DIR, so this should only fire if that has gone missing.
# ---------------------------------------------------------------------------
if [ -n "${VIRGL_TEST_SERVER:-}" ] && [ -x "${VIRGL_TEST_SERVER:-}" ]; then
    # vtest has no --help: it rejects it, prints usage, and exits NON-ZERO.  The
    # old form `"$SERVER" --help 2>&1 | grep -q -- '--multi-clients'` therefore
    # tripped over the `set -o pipefail` above -- pipefail promotes the server's
    # exit 1 to the pipeline's status even when grep MATCHED, so the probe
    # reported "too old" for EVERY server including our own 1.1.0.  That made the
    # warning permanent and self-contradicting: it announced software rendering
    # while the run went hardware anyway.  Capture the usage text first so only
    # the match decides, never the exit status.
    _vhelp="$("$VIRGL_TEST_SERVER" --help 2>&1 || true)"
    case "$_vhelp" in
        *--multi-clients*) : ;;   # >= 1.0: good
        *)
            err "warning: virgl server '$VIRGL_TEST_SERVER' is too old: no --multi-clients."
            err "         needs virglrenderer >= 1.0 -- video will be SOFTWARE (slow)."
            err "         the bundled server ($QUIRK_DIR/virgl_test_server) is missing;"
            err "         reinstall smilebasic-launcher to restore it."
            ;;
    esac
fi

# ---------------------------------------------------------------------------
# 1. Is the image loaded?
# ---------------------------------------------------------------------------
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
    err "Docker image '$IMAGE' is not installed."
    err ""
    err "  Download the SmileBASIC image tarball, then load it with:"
    err "      docker load -i sbqemu.tar"
    err ""
    err "  (This launcher does not download or install the image.)"
    exit 1
fi

# ---------------------------------------------------------------------------
# 2. Is the container running?  If not, start it.
#    User programs are saved into the vfat workspace, which lives in the
#    container's WRITABLE LAYER (not a volume) -- so a stopped container is
#    RESUMED with `docker start` to keep those saves, and only recreated from
#    scratch when there is no container yet or the image has changed underneath
#    it (a deliberate upgrade).  `docker rm` would discard the user's programs.
# ---------------------------------------------------------------------------
running="$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)"
if [ "$running" != "true" ]; then
    xhost +local: >/dev/null 2>&1 || true
    reuse=0
    if docker inspect "$CONTAINER" >/dev/null 2>&1; then
        # Container exists but is stopped.  Reuse it unless its image differs
        # from the one we would launch now (compare resolved image IDs).
        cimg="$(docker inspect -f '{{.Image}}' "$CONTAINER" 2>/dev/null || true)"
        wimg="$(docker inspect -f '{{.Id}}' "$IMAGE" 2>/dev/null || true)"
        if [ -n "$cimg" ] && [ "$cimg" = "$wimg" ]; then
            reuse=1
        else
            msg "image changed since container was created -- recreating (in-container saves are lost)"
            docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
        fi
    fi
    if [ "$reuse" = 1 ]; then
        msg "resuming container '$CONTAINER'"
        if ! docker start "$CONTAINER" >/dev/null; then
            err "failed to resume container '$CONTAINER'."; exit 1
        fi
    else
        msg "starting container '$CONTAINER'"
        if ! docker run -d --name "$CONTAINER" --privileged --network host \
                -e DISPLAY="$DISPLAY" -v /tmp/.X11-unix:/tmp/.X11-unix \
                -v "$HOME/.Xauthority:/root/.Xauthority:ro" -e XAUTHORITY=/root/.Xauthority \
                "$IMAGE" sleep infinity >/dev/null; then
            err "failed to start container '$CONTAINER'."
            exit 1
        fi
    fi
fi

# ---------------------------------------------------------------------------
# 3. Is the host daemon (video + audio server) running?  If not, start it.
#    A daemon that is running WITHOUT its video half (no virgl socket -- e.g.
#    started before the GPU quirk wrapper existed) is restarted so hardware
#    rendering actually engages.
# ---------------------------------------------------------------------------
# Match by comm, NOT by full cmdline (a cmdline pattern can self-match wrapper
# shells quoting this script).  Kill via `kill $(pgrep ...)`, NOT pkill: on some
# hosts (observed on UOS Server 20) /usr/bin/pkill is a broken copy of pgrep that
# lists pids without killing.
# The kernel truncates comm to TASK_COMM_LEN-1 = 15 chars, and pgrep -x matches
# that truncated comm -- so the patterns are the 15-char forms ("smilebasic-daem"
# for smilebasic-daemon, "virgl_test_serv" for virgl_test_server); the full names
# would never match and silently break detection.
DAEMON_COMM="smilebasic-daem"
VIRGL_COMM="virgl_test_serv"
daemon_running() { pgrep -x "$DAEMON_COMM" >/dev/null 2>&1; }
virgl_running()  { pgrep -x "$VIRGL_COMM"  >/dev/null 2>&1; }
# Kill the daemon AND any virgl_test_server (its child can be orphaned -- survive
# the daemon and keep holding the socket, which is the §33 "lost connection to
# rendering server" failure).  kill $(pgrep ...), NOT pkill (broken on some hosts).
daemon_kill() {
    local p
    p="$(pgrep -x "$DAEMON_COMM")" && kill ${1:-} $p 2>/dev/null
    p="$(pgrep -x "$VIRGL_COMM")"  && kill ${1:-} $p 2>/dev/null
    true
}

# Restart the daemon if it is up but its VIDEO half is not working: either no
# virgl socket, or the socket lingers but the virgl_test_server process is dead
# (orphaned).  Trusting the socket file alone is what let a stale/dead virgl serve
# SmileBASIC a broken connection.
if daemon_running && { [ ! -S "$VIRGL_SOCK" ] || ! virgl_running; }; then
    msg "host daemon is up but its video (virgl) is not working -- restarting it"
    daemon_kill
    for _ in $(seq 1 20); do { daemon_running || virgl_running; } || break; sleep 0.1; done
    if daemon_running || virgl_running; then
        daemon_kill -9
        for _ in $(seq 1 20); do { daemon_running || virgl_running; } || break; sleep 0.1; done
    fi
    rm -f "$VIRGL_SOCK" 2>/dev/null || true   # clear the stale socket for a clean rebind
fi

if ! daemon_running; then
    if [ ! -x "$DAEMON_BIN" ]; then
        err "daemon '$DAEMON_BIN' not found or not executable."
        exit 1
    fi
    msg "starting host daemon (video + audio)"
    # Detach fully so it survives this launcher and any controlling terminal.
    # (VIRGL_TEST_SERVER, if set above, is inherited by the daemon here.)
    setsid "$DAEMON_BIN" >"$DAEMON_LOG" 2>&1 </dev/null &
    # Wait briefly for it to come up (virgl socket appears when video is ready).
    for _ in $(seq 1 30); do
        [ -S "$VIRGL_SOCK" ] && break
        daemon_running || break
        sleep 0.1
    done
    if ! daemon_running; then
        err "failed to start smilebasic-daemon (see $DAEMON_LOG)."
        exit 1
    fi
    if [ ! -S "$VIRGL_SOCK" ]; then
        msg "note: no virgl socket after startup -- video may be software (see $DAEMON_LOG)"
    fi
fi

# ---------------------------------------------------------------------------
# 4. Launch the app inside the container.
# ---------------------------------------------------------------------------
xhost +local: >/dev/null 2>&1 || true

# Use a TTY only when we actually have one (so GUI/.desktop launches don't fail
# with "the input device is not a TTY").
# Allocate a pseudo-terminal (-t) only for SmileBASIC.  PasocomMini's pcm_execute
# does console job-control (ioctl(TIOCGPGRP) -> exits if it is not the terminal's
# foreground process group).  It is a windowed app here (X output + evdev input),
# so under `docker exec -it` it is a background child of the terminal and quits
# right after its splash.  Running it WITHOUT a TTY avoids that entirely; PCM
# needs no console (keyboard comes via evdev, not stdin).
EXEC_FLAGS=(-i)
if [ "$APP" = sb ] && [ -t 0 ] && [ -t 1 ]; then
    EXEC_FLAGS=(-it)
fi

# ---------------------------------------------------------------------------
# Shut down the container + daemon when the app exits, so nothing lingers in the
# background (unless -k/--keep-alive/SB_KEEP_ALIVE).  Armed only now -- after we
# have committed to launching -- so an early error above never tears down state
# that was already running.  sync first to flush the vfat workspace (user saves)
# to the image layer before the container stops.
cleanup() {
    trap - EXIT INT TERM HUP    # run once
    [ "$KEEP_ALIVE" = 1 ] && exit "${1:-0}"
    msg "shutting down (pass -k to keep it running for faster relaunch)"
    # sync flushes the vfat workspace (user saves) to the image layer, THEN kill:
    # PID 1 is `sleep infinity`, which has no SIGTERM handler, and the kernel
    # ignores un-handled signals to PID 1 -- so `docker stop` would just wait its
    # full timeout and SIGKILL anyway.  `docker kill` after sync is fast and safe.
    docker exec "$CONTAINER" sync >/dev/null 2>&1 || true
    docker kill "$CONTAINER" >/dev/null 2>&1 || true
    daemon_kill      # stop the host video/audio daemon too
    daemon_running && sleep 0.3 && daemon_kill -9
    exit "${1:-0}"
}
trap 'cleanup $?' EXIT
trap 'cleanup 130' INT
trap 'cleanup 143' TERM HUP

msg "launching $APP_NAME (Ctrl-C to stop)"
# NOT exec: control must return here so the cleanup trap can run when the app
# (window) exits.  Interactive docker exec forwards the app's exit status.
docker exec "${EXEC_FLAGS[@]}" "${RUN_ENV[@]}" "$CONTAINER" "$RUN_CMD"

# PasocomMini is MULTIPLE processes: pcm_execute bootstraps the emulator
# (a qemu-arm-pcm process that owns the on-screen window) and then EXITS, so the
# docker exec above returns while PCM is still running.  Tearing down now would
# kill the container out from under it.  So for PCM, find the window-owning
# process (the one that mmaps libbrcmEGL) and wait for it to exit first.
# SmileBASIC does not need this: run-sb.sh execs smilebasic.data (the window
# owner), so the docker exec already blocked until it closed.
if [ "$APP" != sb ]; then
    winpid=""
    for _ in $(seq 1 40); do   # up to ~20s for the window app to come up
        winpid="$(docker exec "$CONTAINER" sh -c '
            for m in /proc/[0-9]*/maps; do
                grep -ql libbrcmEGL "$m" 2>/dev/null && { p=${m#/proc/}; echo "${p%%/*}"; exit 0; }
            done' 2>/dev/null)"
        [ -n "$winpid" ] && break
        # stop waiting if the container itself went away
        [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null)" = true ] || break
        sleep 0.5
    done
    if [ -n "$winpid" ]; then
        msg "$APP_NAME running (close its window to quit)"
        # Wait for the window owner to exit.  Test the process STATE, not just the
        # existence of /proc/PID: pcm_execute exits early, so the emulator is
        # reparented to the container's PID 1 (`sleep infinity`), which never
        # wait()s -- the finished process therefore lingers as a ZOMBIE and
        # /proc/PID still exists.  Treating "Z" as gone is what lets the teardown
        # actually fire when you close the window.
        while docker exec "$CONTAINER" sh -c \
              "s=\$(awk '/^State:/{print \$2}' /proc/$winpid/status 2>/dev/null); [ -n \"\$s\" ] && [ \"\$s\" != Z ]" \
              >/dev/null 2>&1; do sleep 1; done
    fi
fi
# EXIT trap runs cleanup with this status.
