465 lines
15 KiB
Bash
Executable File
465 lines
15 KiB
Bash
Executable File
#!/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="<missing-couch-uid>"
|
|
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='<?xml version="1.0" encoding="UTF-8"?>
|
|
<channel name="xfwm4" version="1.0">
|
|
<property name="general" type="empty">
|
|
<property name="use_compositing" type="bool" value="false"/>
|
|
</property>
|
|
</channel>
|
|
'
|
|
local power_content='<?xml version="1.0" encoding="UTF-8"?>
|
|
<channel name="xfce4-power-manager" version="1.0">
|
|
<property name="xfce4-power-manager" type="empty">
|
|
<property name="blank-on-ac" type="int" value="0"/>
|
|
<property name="dpms-enabled" type="bool" value="false"/>
|
|
<property name="inactivity-on-ac" type="int" value="0"/>
|
|
<property name="logind-handle-lid-switch" type="bool" value="false"/>
|
|
</property>
|
|
</channel>
|
|
'
|
|
local notifyd_content='<?xml version="1.0" encoding="UTF-8"?>
|
|
<channel name="xfce4-notifyd" version="1.0">
|
|
<property name="do-not-disturb" type="bool" value="true"/>
|
|
</channel>
|
|
'
|
|
local screensaver_content='<?xml version="1.0" encoding="UTF-8"?>
|
|
<channel name="xfce4-screensaver" version="1.0">
|
|
<property name="lock" type="bool" value="false"/>
|
|
<property name="saver" type="empty">
|
|
<property name="enabled" type="bool" value="false"/>
|
|
<property name="mode" type="int" value="0"/>
|
|
</property>
|
|
</channel>
|
|
'
|
|
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
|