#!/usr/bin/env bash set -Eeuo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MODE="apply" PHASE="all" CHECK_FAILURES=0 COUCH_USER="couch" COUCH_HOME="/home/${COUCH_USER}" INSTALL_ROOT="${COUCH_HOME}/couchos" RUNTIME_CONFIG_PATH="${COUCH_HOME}/.config/couchd/runtime.json" TOKEN_PATH="${COUCH_HOME}/.config/couchd/token" COUCH_UID="" MISSING_UID_PLACEHOLDER="" TOKEN_PLACEHOLDER="$(<"${ROOT_DIR}/templates/couchd.token.example")" usage() { cat <<'EOF' Usage: ./setup.sh [--phase root|couch|all] [--check] [--dry-run] Safe, idempotent CouchOS setup entrypoint. - `--check` reports PASS/FAIL and exits nonzero when any validation fails. - `--dry-run` prints intended changes without applying them. - `--phase root` covers system setup and stable install location. - `--phase couch` covers runtime config and per-user desktop/session state. EOF } log() { printf '[setup] %s\n' "$*" } record_check() { local status="$1" shift printf '[check] %s %s\n' "${status}" "$*" if [[ "${status}" == "FAIL" ]]; then CHECK_FAILURES=$((CHECK_FAILURES + 1)) fi } run_cmd() { if [[ "${MODE}" == "check" ]]; then return 0 fi if [[ "${MODE}" == "dry-run" ]]; then printf '[dry-run] %s\n' "$*" return 0 fi "$@" } need_root() { if [[ "${EUID}" -ne 0 && "${MODE}" != "check" ]]; then printf 'root privileges are required for this phase\n' >&2 exit 1 fi } resolve_couch_uid() { COUCH_UID="$(id -u "${COUCH_USER}")" } effective_couch_uid() { if [[ -n "${COUCH_UID}" ]]; then printf '%s\n' "${COUCH_UID}" else printf '%s\n' "${MISSING_UID_PLACEHOLDER}" fi } check_path_exists() { local path="$1" if [[ -e "${path}" ]]; then record_check PASS "${path} exists" else record_check FAIL "${path} missing" fi } check_command_success() { local description="$1" shift if "$@" >/dev/null 2>&1; then record_check PASS "${description}" else record_check FAIL "${description}" fi } install_file() { local source="$1" local target="$2" local mode="$3" local owner="$4" local group="$5" log "install ${target}" if [[ "${MODE}" == "check" ]]; then if [[ -f "${target}" ]]; then record_check PASS "${target} present" else record_check FAIL "${target} missing" fi return 0 fi run_cmd install -D -m "${mode}" -o "${owner}" -g "${group}" "${source}" "${target}" } install_text_file() { local target="$1" local mode="$2" local owner="$3" local group="$4" local content="$5" if [[ "${MODE}" == "check" ]]; then check_path_exists "${target}" return 0 fi run_cmd install -d -m 0755 -o "${owner}" -g "${group}" "$(dirname "${target}")" local tmp tmp="$(mktemp)" printf '%s' "${content}" >"${tmp}" run_cmd install -m "${mode}" -o "${owner}" -g "${group}" "${tmp}" "${target}" rm -f "${tmp}" } ensure_couch_user() { if id -u "${COUCH_USER}" >/dev/null 2>&1; then log "user ${COUCH_USER} already present" resolve_couch_uid else if [[ "${MODE}" == "check" ]]; then record_check FAIL "couch user missing" COUCH_UID="" return 0 fi run_cmd useradd --create-home --home-dir "${COUCH_HOME}" --shell /bin/bash --user-group "${COUCH_USER}" if id -u "${COUCH_USER}" >/dev/null 2>&1; then resolve_couch_uid else COUCH_UID="" fi fi } remove_group_membership() { local group_name="$1" if ! getent group "${group_name}" >/dev/null 2>&1; then return 0 fi if id -nG "${COUCH_USER}" | tr ' ' '\n' | grep -Fxq "${group_name}"; then if [[ "${MODE}" == "check" ]]; then record_check FAIL "${COUCH_USER} is still a member of ${group_name}" else run_cmd gpasswd -d "${COUCH_USER}" "${group_name}" fi elif [[ "${MODE}" == "check" ]]; then record_check PASS "${COUCH_USER} is not a member of ${group_name}" fi } ensure_repo_install() { if [[ "${MODE}" == "check" ]]; then check_path_exists "${INSTALL_ROOT}/src/couchd/app.py" return 0 fi run_cmd install -d -m 0700 -o "${COUCH_USER}" -g "${COUCH_USER}" "${INSTALL_ROOT}" run_cmd bash -lc "cd '${ROOT_DIR}' && tar --exclude='.git' --exclude='__pycache__' --exclude='.pytest_cache' --exclude='artifacts' -cf - . | tar -C '${INSTALL_ROOT}' -xf -" run_cmd chown -R "${COUCH_USER}:${COUCH_USER}" "${INSTALL_ROOT}" run_cmd chmod 0700 "${COUCH_HOME}" } ensure_runtime_config() { local controller_address="${COUCHOS_XBOX_BLUETOOTH_ADDRESS:-}" local bind_address="${COUCHOS_BIND_ADDRESS:-100.64.0.15}" local existing_runtime="{}" local runtime_uid runtime_uid="$(effective_couch_uid)" if [[ -f "${RUNTIME_CONFIG_PATH}" ]]; then existing_runtime="$(cat "${RUNTIME_CONFIG_PATH}")" fi if [[ "${MODE}" == "check" ]]; then check_path_exists "${RUNTIME_CONFIG_PATH}" if [[ -f "${TOKEN_PATH}" ]]; then local token_mode token_mode="$(stat -c '%a' "${TOKEN_PATH}" 2>/dev/null || true)" if [[ "${token_mode}" == "600" ]]; then record_check PASS "${TOKEN_PATH} mode is 0600" else record_check FAIL "${TOKEN_PATH} mode is not 0600" fi if [[ "$(tr -d '\r\n' <"${TOKEN_PATH}")" == "${TOKEN_PLACEHOLDER}" ]]; then record_check FAIL "${TOKEN_PATH} still uses the template placeholder token" else record_check PASS "${TOKEN_PATH} does not use the template placeholder token" fi else record_check FAIL "${TOKEN_PATH} missing" fi return 0 fi run_cmd install -d -m 0700 -o "${COUCH_USER}" -g "${COUCH_USER}" "${COUCH_HOME}/.config/couchd" if [[ ! -f "${TOKEN_PATH}" ]]; then if [[ "${MODE}" == "dry-run" ]]; then printf '[dry-run] generate random bearer token at %s (mode 0600)\n' "${TOKEN_PATH}" else local token_tmp token_tmp="$(mktemp)" python3 - <<'PY' >"${token_tmp}" import secrets print(secrets.token_urlsafe(32)) PY install -m 0600 -o "${COUCH_USER}" -g "${COUCH_USER}" "${token_tmp}" "${TOKEN_PATH}" rm -f "${token_tmp}" fi fi if [[ "${MODE}" == "dry-run" ]]; then printf '[dry-run] generate %s with xdg_runtime_dir=/run/user/%s\n' "${RUNTIME_CONFIG_PATH}" "${runtime_uid}" return 0 fi EXISTING_RUNTIME_JSON="${existing_runtime}" \ RUNTIME_TARGET="${RUNTIME_CONFIG_PATH}" \ CONTROLLER_ADDRESS="${controller_address}" \ BIND_ADDRESS="${bind_address}" \ COUCH_UID="${runtime_uid}" \ python3 <<'PY' import json import os from pathlib import Path existing = json.loads(os.environ["EXISTING_RUNTIME_JSON"]) target = Path(os.environ["RUNTIME_TARGET"]) couch_home = Path("/home/couch") payload = { "bind_address": os.environ["BIND_ADDRESS"], "port": int(existing.get("port", 8765)), "token_file": str(couch_home / ".config" / "couchd" / "token"), "couch_home": str(couch_home), "iso_path": existing.get("iso_path"), "state_file": str(couch_home / ".local" / "state" / "couchd" / "state.json"), "display": existing.get("display", ":0"), "xdg_runtime_dir": f"/run/user/{os.environ['COUCH_UID']}", "xbox_bluetooth_address": os.environ["CONTROLLER_ADDRESS"] or existing.get("xbox_bluetooth_address"), "launcher_shortcut": existing.get("launcher_shortcut"), } target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") PY run_cmd chown "${COUCH_USER}:${COUCH_USER}" "${RUNTIME_CONFIG_PATH}" run_cmd chmod 0640 "${RUNTIME_CONFIG_PATH}" } configure_user_session_files() { local xprofile_content='xset s off xset -dpms xset s noblank ' local xfwm4_content=' ' local power_content=' ' local notifyd_content=' ' local screensaver_content=' ' local autostart_override='[Desktop Entry] Type=Application Hidden=true X-GNOME-Autostart-enabled=false ' local couchd_autostart='[Desktop Entry] Type=Application Version=1.0 Name=CouchOS Control Daemon OnlyShowIn=XFCE; Exec=sh -lc "systemctl --user import-environment DISPLAY XAUTHORITY XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS; systemctl --user daemon-reload; systemctl --user start couchd.service" X-GNOME-Autostart-enabled=true ' local display_audio_autostart='[Desktop Entry] Type=Application Version=1.0 Name=CouchOS HDMI Display And Audio OnlyShowIn=XFCE; Exec=sh -lc "systemctl --user import-environment DISPLAY XAUTHORITY XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS; systemctl --user daemon-reload; systemctl --user start couch-display-audio.service" X-GNOME-Autostart-enabled=true ' install_text_file "${COUCH_HOME}/.xprofile" 0644 "${COUCH_USER}" "${COUCH_USER}" "${xprofile_content}" install_text_file "${COUCH_HOME}/.config/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml" 0644 "${COUCH_USER}" "${COUCH_USER}" "${xfwm4_content}" install_text_file "${COUCH_HOME}/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-power-manager.xml" 0644 "${COUCH_USER}" "${COUCH_USER}" "${power_content}" install_text_file "${COUCH_HOME}/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-notifyd.xml" 0644 "${COUCH_USER}" "${COUCH_USER}" "${notifyd_content}" install_text_file "${COUCH_HOME}/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-screensaver.xml" 0644 "${COUCH_USER}" "${COUCH_USER}" "${screensaver_content}" install_text_file "${COUCH_HOME}/.config/autostart/xfce4-notifyd.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${autostart_override}" install_text_file "${COUCH_HOME}/.config/autostart/update-notifier.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${autostart_override}" install_text_file "${COUCH_HOME}/.config/autostart/light-locker.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${autostart_override}" install_text_file "${COUCH_HOME}/.config/autostart/xscreensaver.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${autostart_override}" install_text_file "${COUCH_HOME}/.config/autostart/couchd.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${couchd_autostart}" install_text_file "${COUCH_HOME}/.config/autostart/couch-display-audio.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${display_audio_autostart}" } root_phase() { need_root log "root phase" ensure_couch_user run_cmd apt-get update run_cmd apt-get install -y \ lightdm xfce4 xfce4-goodies unattended-upgrades x11-xserver-utils pulseaudio pavucontrol \ bluez blueman rfkill mesa-utils vulkan-tools jq curl install_file "${ROOT_DIR}/config/lightdm/50-couchos.conf" "/etc/lightdm/lightdm.conf.d/50-couchos.conf" 0644 root root install_file "${ROOT_DIR}/config/logind/50-couchos.conf" "/etc/systemd/logind.conf.d/50-couchos.conf" 0644 root root install_file "${ROOT_DIR}/systemd/systemd-user/couchd.service" "/etc/systemd/user/couchd.service" 0644 root root install_file "${ROOT_DIR}/systemd/systemd-user/couch-display-audio.service" "/etc/systemd/user/couch-display-audio.service" 0644 root root install_text_file "/etc/apt/apt.conf.d/52couchos-unattended" 0644 root root $'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' remove_group_membership sudo remove_group_membership adm ensure_repo_install if [[ "${MODE}" == "check" ]]; then check_command_success "couch home mode is 0700" test "$(stat -c '%a' "${COUCH_HOME}" 2>/dev/null)" = "700" else run_cmd loginctl enable-linger "${COUCH_USER}" run_cmd systemctl daemon-reload fi } couch_phase() { need_root log "couch phase" ensure_couch_user run_cmd install -d -m 0700 -o "${COUCH_USER}" -g "${COUCH_USER}" \ "${COUCH_HOME}/Applications" "${COUCH_HOME}/Slippi" "${COUCH_HOME}/bin" "${COUCH_HOME}/.local/state/couchd" ensure_runtime_config configure_user_session_files if [[ "${MODE}" != "check" ]]; then run_cmd ln -sfn "${INSTALL_ROOT}/scripts/launch_melee.sh" "${COUCH_HOME}/bin/launch_melee.sh" run_cmd ln -sfn "${INSTALL_ROOT}/scripts/open_slippi.sh" "${COUCH_HOME}/bin/open_slippi.sh" run_cmd ln -sfn "${INSTALL_ROOT}/scripts/stop_game.sh" "${COUCH_HOME}/bin/stop_game.sh" run_cmd ln -sfn "${INSTALL_ROOT}/scripts/slippi_first_run_helper.sh" "${COUCH_HOME}/bin/slippi_first_run_helper.sh" run_cmd chown -h "${COUCH_USER}:${COUCH_USER}" "${COUCH_HOME}/bin/"*.sh fi } check_runtime_sanity() { if [[ ! -f "${RUNTIME_CONFIG_PATH}" ]]; then record_check FAIL "${RUNTIME_CONFIG_PATH} missing" return 0 fi if [[ -z "${COUCH_UID}" ]]; then record_check FAIL "runtime config could not be verified because couch uid is unresolved" return 0 fi if RUNTIME_TARGET="${RUNTIME_CONFIG_PATH}" COUCH_UID="${COUCH_UID}" python3 <<'PY' import json import os from pathlib import Path target = Path(os.environ["RUNTIME_TARGET"]) data = json.loads(target.read_text(encoding="utf-8")) failures = [] if data.get("xdg_runtime_dir") != f"/run/user/{os.environ['COUCH_UID']}": failures.append("runtime xdg_runtime_dir does not match couch uid") if data.get("launcher_shortcut") == ["/usr/bin/xdg-open", "slippi://play"]: failures.append("runtime launcher_shortcut still uses unverified slippi://play") iso_path = data.get("iso_path") if iso_path is not None and not str(iso_path).startswith("/home/couch/"): failures.append("runtime iso_path is outside /home/couch") if failures: for failure in failures: print(failure) raise SystemExit(1) PY then record_check PASS "runtime config placeholders resolved safely" else record_check FAIL "runtime config placeholders resolved safely" fi } main() { while [[ $# -gt 0 ]]; do case "$1" in --phase) PHASE="$2" shift 2 ;; --check) MODE="check" shift ;; --dry-run) MODE="dry-run" shift ;; --help|-h) usage exit 0 ;; *) printf 'unknown argument: %s\n' "$1" >&2 usage >&2 exit 1 ;; esac done case "${PHASE}" in root) root_phase ;; couch) couch_phase ;; all) root_phase couch_phase ;; *) printf 'unknown phase: %s\n' "${PHASE}" >&2 exit 1 ;; esac if [[ "${MODE}" == "check" ]]; then if id -u "${COUCH_USER}" >/dev/null 2>&1; then resolve_couch_uid fi check_runtime_sanity if [[ "${CHECK_FAILURES}" -gt 0 ]]; then exit 1 fi fi } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi