This commit is contained in:
135
scripts/backup_restore.sh
Executable file
135
scripts/backup_restore.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
MODE="${1:-}"
|
||||
ARCHIVE_PATH="${2:-}"
|
||||
COUCH_HOME="${COUCH_HOME:-/home/couch}"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/backup_restore.sh backup [archive.tar.gz]
|
||||
./scripts/backup_restore.sh restore <archive.tar.gz>
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ -z "${MODE}" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_backup_manifest() {
|
||||
python3 - <<'PY' "${COUCH_HOME}"
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
couch_home = Path(sys.argv[1])
|
||||
roots = [
|
||||
Path(".config/couchd/runtime.json"),
|
||||
Path(".config/dolphin-emu"),
|
||||
Path(".config/Slippi"),
|
||||
Path(".local/share/dolphin-emu"),
|
||||
Path("Slippi"),
|
||||
]
|
||||
excluded_dirs = tuple(
|
||||
item.lower()
|
||||
for item in (
|
||||
".config/Slippi Launcher",
|
||||
".local/share/accounts",
|
||||
".local/share/sessions",
|
||||
"Slippi/Replay",
|
||||
"Slippi/Replays",
|
||||
)
|
||||
)
|
||||
excluded_suffixes = (".slp", ".iso", ".gcm", ".wbfs", ".nkit.iso")
|
||||
|
||||
for root in roots:
|
||||
source = couch_home / root
|
||||
if not source.exists():
|
||||
continue
|
||||
if source.is_file():
|
||||
print(str(root), end="\0")
|
||||
continue
|
||||
for current_root, dir_names, file_names in os.walk(source):
|
||||
rel_root = Path(current_root).relative_to(couch_home)
|
||||
dir_names[:] = [
|
||||
name
|
||||
for name in dir_names
|
||||
if str(rel_root / name).lower() not in excluded_dirs
|
||||
]
|
||||
for file_name in file_names:
|
||||
rel_path = rel_root / file_name
|
||||
rel_lower = str(rel_path).lower()
|
||||
if any(rel_lower == item or rel_lower.startswith(f"{item}/") for item in excluded_dirs):
|
||||
continue
|
||||
if rel_lower.endswith(excluded_suffixes):
|
||||
continue
|
||||
print(str(rel_path), end="\0")
|
||||
PY
|
||||
}
|
||||
|
||||
validate_restore_archive() {
|
||||
python3 - <<'PY' "${ARCHIVE_PATH}" "${COUCH_HOME}"
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
archive_path = Path(sys.argv[1])
|
||||
couch_home = Path(sys.argv[2]).resolve(strict=False)
|
||||
|
||||
with tarfile.open(archive_path, "r:*") as archive:
|
||||
for member in archive.getmembers():
|
||||
member_path = Path(member.name)
|
||||
if member_path.is_absolute():
|
||||
raise SystemExit(f"unsafe archive entry: {member.name}")
|
||||
if any(part == ".." for part in member_path.parts):
|
||||
raise SystemExit(f"unsafe archive entry: {member.name}")
|
||||
if member.isdev():
|
||||
raise SystemExit(f"unsafe archive entry: {member.name}")
|
||||
target_path = (couch_home / member_path).resolve(strict=False)
|
||||
if couch_home != target_path and couch_home not in target_path.parents:
|
||||
raise SystemExit(f"unsafe archive entry: {member.name}")
|
||||
if member.issym() or member.islnk():
|
||||
link_target = Path(member.linkname)
|
||||
if link_target.is_absolute():
|
||||
raise SystemExit(f"unsafe archive entry: {member.name}")
|
||||
resolved_link = (target_path.parent / link_target).resolve(strict=False)
|
||||
if couch_home != resolved_link and couch_home not in resolved_link.parents:
|
||||
raise SystemExit(f"unsafe archive entry: {member.name}")
|
||||
PY
|
||||
}
|
||||
|
||||
case "${MODE}" in
|
||||
backup)
|
||||
ARCHIVE_PATH="${ARCHIVE_PATH:-${COUCH_HOME}/Slippi/couchos-backup-${STAMP}.tar.gz}"
|
||||
umask 077
|
||||
mapfile -d '' manifest < <(build_backup_manifest)
|
||||
if [[ "${#manifest[@]}" -eq 0 ]]; then
|
||||
printf 'no backup sources found under %s\n' "${COUCH_HOME}" >&2
|
||||
exit 1
|
||||
fi
|
||||
tar -C "${COUCH_HOME}" -czf "${ARCHIVE_PATH}" \
|
||||
--exclude='.config/couchd/token' \
|
||||
"${manifest[@]}"
|
||||
chmod 0600 "${ARCHIVE_PATH}"
|
||||
printf 'backup written to %s\n' "${ARCHIVE_PATH}"
|
||||
;;
|
||||
restore)
|
||||
if [[ -z "${ARCHIVE_PATH}" || ! -f "${ARCHIVE_PATH}" ]]; then
|
||||
printf 'restore archive is required\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! validation_error="$(validate_restore_archive 2>&1)"; then
|
||||
printf '%s\n' "${validation_error}" >&2
|
||||
exit 1
|
||||
fi
|
||||
tar -C "${COUCH_HOME}" -xzf "${ARCHIVE_PATH}"
|
||||
printf 'restored from %s\n' "${ARCHIVE_PATH}"
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
62
scripts/check.sh
Executable file
62
scripts/check.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
|
||||
run_step() {
|
||||
local label="$1"
|
||||
shift
|
||||
printf '[check] %s\n' "${label}"
|
||||
"$@"
|
||||
}
|
||||
|
||||
secret_scan() {
|
||||
python3 - "${REPO_ROOT}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
rom_suffixes = (".iso", ".gcm", ".wbfs", ".nkit.iso")
|
||||
secret_pattern = re.compile(
|
||||
r"BEGIN [A-Z ]+PRIVATE KEY|Authorization:\s*Bearer\s+[A-Za-z0-9._~-]{20,}"
|
||||
)
|
||||
findings: list[str] = []
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file() or any(part in {".git", "__pycache__", "artifacts"} for part in path.parts):
|
||||
continue
|
||||
relative = path.relative_to(root)
|
||||
if path.name.lower().endswith(rom_suffixes):
|
||||
findings.append(f"ROM file: {relative}")
|
||||
continue
|
||||
if relative in {Path("templates/couchd.token.example"), Path("scripts/check.sh")}:
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
if secret_pattern.search(text):
|
||||
findings.append(f"possible secret: {relative}")
|
||||
if findings:
|
||||
print("\n".join(findings))
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
run_step "unit tests" python3 -m unittest discover -v
|
||||
run_step "setup shell tests" bash "${REPO_ROOT}/tests/test_setup_modes.sh"
|
||||
run_step "shell syntax" bash -n "${REPO_ROOT}/setup.sh" "${REPO_ROOT}"/scripts/*.sh
|
||||
run_step "python compile" python3 -m py_compile "${REPO_ROOT}"/src/couchd/*.py "${REPO_ROOT}"/tests/*.py
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
run_step "dashboard JS syntax" node --check "${REPO_ROOT}/dashboard/app.js"
|
||||
else
|
||||
printf '[check] dashboard JS syntax skipped: node not installed\n'
|
||||
fi
|
||||
if git -C "${REPO_ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
run_step "git diff check" git -C "${REPO_ROOT}" diff --check
|
||||
else
|
||||
printf '[check] git diff check skipped: deployed tree has no .git directory\n'
|
||||
fi
|
||||
run_step "secret and ROM scan" secret_scan
|
||||
85
scripts/configure_display_audio.sh
Executable file
85
scripts/configure_display_audio.sh
Executable file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
log() {
|
||||
printf '[display-audio] %s\n' "$*"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf '[display-audio] WARN: %s\n' "$*" >&2
|
||||
logger -t couch-display-audio -- "$*" 2>/dev/null || true
|
||||
}
|
||||
|
||||
pick_hdmi_refresh() {
|
||||
awk '
|
||||
$1 == "HDMI-1" && $2 == "connected" { in_hdmi=1; next }
|
||||
in_hdmi && $0 !~ /^[[:space:]]/ { exit }
|
||||
in_hdmi && $1 == "1920x1080" {
|
||||
for (i = 2; i <= NF; i++) {
|
||||
gsub(/[+*]/, "", $i)
|
||||
if ($i ~ /^[0-9]+(\.[0-9]+)?$/) {
|
||||
value = $i + 0
|
||||
if (value > best) {
|
||||
best = value
|
||||
best_text = $i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (best_text != "") {
|
||||
print best_text
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
choose_hdmi_profile() {
|
||||
pactl list cards | awk '
|
||||
/^Card #[0-9]+/ { card="" }
|
||||
/^\tName: / { sub(/^\tName: /, "", $0); card=$0 }
|
||||
/^\t\toutput:hdmi/ && /available: yes/ {
|
||||
profile=$1
|
||||
sub(/:$/, "", profile)
|
||||
print card "\t" profile
|
||||
exit
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
choose_hdmi_sink() {
|
||||
pactl list short sinks | awk '$2 ~ /\.hdmi/ { print $2; exit }'
|
||||
}
|
||||
|
||||
xrandr_output="$(xrandr --query)"
|
||||
if ! grep -q '^HDMI-1 connected' <<<"${xrandr_output}"; then
|
||||
warn "HDMI-1 is not connected; deferring display/audio switch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
refresh="$(printf '%s\n' "${xrandr_output}" | pick_hdmi_refresh)"
|
||||
if [[ -z "${refresh}" ]]; then
|
||||
warn "HDMI-1 is connected but no 1920x1080 refresh was discovered"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "setting HDMI-1 primary at 1920x1080@${refresh} and disabling eDP-1"
|
||||
xrandr --output HDMI-1 --primary --mode 1920x1080 --rate "${refresh}" --output eDP-1 --off
|
||||
|
||||
profile_line="$(choose_hdmi_profile || true)"
|
||||
if [[ -z "${profile_line}" ]]; then
|
||||
warn "HDMI audio sink/profile not available yet; display configured and audio switch deferred"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
card_name="${profile_line%%$'\t'*}"
|
||||
profile_name="${profile_line#*$'\t'}"
|
||||
log "selecting HDMI audio profile ${profile_name} on ${card_name}"
|
||||
pactl set-card-profile "${card_name}" "${profile_name}"
|
||||
pactl list short sinks >/dev/null 2>&1 || true
|
||||
sink_name="$(choose_hdmi_sink || true)"
|
||||
if [[ -z "${sink_name}" ]]; then
|
||||
warn "HDMI sink still unavailable after profile switch; audio switch deferred"
|
||||
exit 0
|
||||
fi
|
||||
pactl set-default-sink "${sink_name}"
|
||||
8
scripts/discover_controller.sh
Executable file
8
scripts/discover_controller.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ADDRESS="${1:-C8:3F:26:12:69:7B}"
|
||||
|
||||
bluetoothctl info "${ADDRESS}" || true
|
||||
printf '\nKnown-safe template: templates/controllers/xbox-standard-controller.profile.ini\n'
|
||||
printf 'Do not fill in SDL GUIDs or Dolphin device IDs until you confirm them on this machine.\n'
|
||||
77
scripts/install_slippi.sh
Executable file
77
scripts/install_slippi.sh
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
API_URL="${SLIPPI_RELEASE_API_URL:-https://api.github.com/repos/project-slippi/slippi-launcher/releases/latest}"
|
||||
INSTALL_DIR="${SLIPPI_INSTALL_DIR:-/home/couch/Applications}"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||
|
||||
log() {
|
||||
printf '[install_slippi] %s\n' "$*"
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
printf 'missing required command: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
require_cmd install
|
||||
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
|
||||
validate_asset_url() {
|
||||
local url="$1"
|
||||
local expected_name_regex="$2"
|
||||
if [[ ! "${url}" =~ ^https://github\.com/project-slippi/slippi-launcher/releases/download/ ]]; then
|
||||
printf 'unexpected asset host or path: %s\n' "${url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
local name
|
||||
name="$(basename "${url}")"
|
||||
if [[ ! "${name}" =~ ${expected_name_regex} ]]; then
|
||||
printf 'unexpected asset name: %s\n' "${name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
release_json="${TMP_DIR}/release.json"
|
||||
curl -fsSL "${API_URL}" -o "${release_json}"
|
||||
|
||||
appimage_url="$(jq -r '[.assets[] | select(.name | test("^Slippi-Launcher-.*x86_64\\.AppImage$")) | .browser_download_url] | first // empty' "${release_json}")"
|
||||
checksum_url="$(jq -r '[.assets[] | select(.name | test("(sha256|checksums?)(\\.txt)?$"; "i")) | .browser_download_url] | first // empty' "${release_json}")"
|
||||
|
||||
if [[ -z "${appimage_url}" ]]; then
|
||||
printf 'could not find official Slippi Launcher AppImage asset\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
validate_asset_url "${appimage_url}" '^Slippi-Launcher-.*x86_64\.AppImage$'
|
||||
|
||||
appimage_name="$(basename "${appimage_url}")"
|
||||
appimage_path="${TMP_DIR}/${appimage_name}"
|
||||
curl -fsSL "${appimage_url}" -o "${appimage_path}"
|
||||
|
||||
if [[ -n "${checksum_url}" ]]; then
|
||||
validate_asset_url "${checksum_url}" '^(sha256|checksums?)(\.[A-Za-z0-9]+)?$'
|
||||
checksum_path="${TMP_DIR}/checksums.txt"
|
||||
curl -fsSL "${checksum_url}" -o "${checksum_path}"
|
||||
if grep -F "${appimage_name}" "${checksum_path}" >/dev/null 2>&1; then
|
||||
(
|
||||
cd "${TMP_DIR}"
|
||||
grep -F "${appimage_name}" "${checksum_path}" | sha256sum --check --status
|
||||
)
|
||||
log "checksum verified for ${appimage_name}"
|
||||
else
|
||||
printf 'checksum asset present but does not include %s\n' "${appimage_name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log "no upstream checksum asset published for this release; upstream verification unavailable"
|
||||
fi
|
||||
|
||||
install -m 0755 "${appimage_path}" "${INSTALL_DIR}/${appimage_name}"
|
||||
ln -sfn "${INSTALL_DIR}/${appimage_name}" "${INSTALL_DIR}/SlippiLauncher.AppImage"
|
||||
log "installed ${INSTALL_DIR}/${appimage_name}"
|
||||
9
scripts/launch_melee.sh
Executable file
9
scripts/launch_melee.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
CONFIG_PATH="${COUCHOS_CONFIG:-/home/couch/.config/couchd/runtime.json}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
|
||||
exec python3 -m couchd --config "${CONFIG_PATH}" launch-melee
|
||||
9
scripts/open_slippi.sh
Executable file
9
scripts/open_slippi.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
CONFIG_PATH="${COUCHOS_CONFIG:-/home/couch/.config/couchd/runtime.json}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
|
||||
exec python3 -m couchd --config "${CONFIG_PATH}" open-slippi
|
||||
346
scripts/preflight.sh
Executable file
346
scripts/preflight.sh
Executable file
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
MODE="${1:-quick}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
ARTIFACT_DIR="${ARTIFACT_DIR:-${REPO_ROOT}/artifacts}"
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
LOG_FILE="${ARTIFACT_DIR}/preflight-${MODE}.log"
|
||||
STATE_FILE="${ARTIFACT_DIR}/five-reboots.jsonl"
|
||||
SOAK_FILE="${ARTIFACT_DIR}/soak-30m.json"
|
||||
FAILURES=0
|
||||
COUCH_USER="couch"
|
||||
RUNTIME_CONFIG="/home/couch/.config/couchd/runtime.json"
|
||||
|
||||
record() {
|
||||
local status="$1"
|
||||
shift
|
||||
printf '%s %s\n' "${status}" "$*" | tee -a "${LOG_FILE}"
|
||||
if [[ "${status}" == "FAIL" ]]; then
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
run_as_couch() {
|
||||
if [[ "${EUID}" -eq 0 ]]; then
|
||||
runuser -u "${COUCH_USER}" -- "$@"
|
||||
elif [[ "$(id -un)" == "${COUCH_USER}" ]]; then
|
||||
"$@"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
runtime_field() {
|
||||
local field="$1"
|
||||
python3 - <<'PY' "${RUNTIME_CONFIG}" "${field}"
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
field = sys.argv[2]
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
value = data.get(field)
|
||||
if value is None:
|
||||
raise SystemExit(1)
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
controller_snapshot() {
|
||||
local address
|
||||
address="$(runtime_field xbox_bluetooth_address 2>/dev/null || true)"
|
||||
if [[ -z "${address}" ]]; then
|
||||
printf '{"address": null, "paired": false, "trusted": false, "connected": false}'
|
||||
return 0
|
||||
fi
|
||||
local output
|
||||
output="$(bluetoothctl info "${address}" 2>/dev/null || true)"
|
||||
python3 - <<'PY' "${address}" "${output}"
|
||||
import json
|
||||
import sys
|
||||
|
||||
address = sys.argv[1]
|
||||
text = sys.argv[2]
|
||||
print(json.dumps({
|
||||
"address": address,
|
||||
"paired": "Paired: yes" in text,
|
||||
"trusted": "Trusted: yes" in text,
|
||||
"connected": "Connected: yes" in text,
|
||||
}))
|
||||
PY
|
||||
}
|
||||
|
||||
status_json() {
|
||||
python3 -m couchd --config "${RUNTIME_CONFIG}" status
|
||||
}
|
||||
|
||||
finish() {
|
||||
if [[ "${FAILURES}" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
quick_mode() {
|
||||
: >"${LOG_FILE}"
|
||||
if id "${COUCH_USER}" >/dev/null 2>&1; then
|
||||
record PASS "couch user exists"
|
||||
else
|
||||
record FAIL "couch user missing"
|
||||
fi
|
||||
|
||||
if [[ "$(stat -c '%a' /home/jackmhny 2>/dev/null || true)" == "700" ]]; then
|
||||
record PASS "/home/jackmhny mode is 0700"
|
||||
else
|
||||
record FAIL "/home/jackmhny mode is not 0700"
|
||||
fi
|
||||
|
||||
if run_as_couch bash -lc 'test ! -r /home/jackmhny && test ! -x /home/jackmhny'; then
|
||||
record PASS "couch cannot read or traverse /home/jackmhny"
|
||||
else
|
||||
record FAIL "couch can read or traverse /home/jackmhny or couch access test could not run"
|
||||
fi
|
||||
|
||||
if id -nG "${COUCH_USER}" 2>/dev/null | tr ' ' '\n' | grep -Eq '^(sudo|adm)$'; then
|
||||
record FAIL "couch is still in sudo/adm"
|
||||
else
|
||||
record PASS "couch is not in sudo/adm"
|
||||
fi
|
||||
|
||||
grep -Fq 'autologin-user=couch' /etc/lightdm/lightdm.conf.d/50-couchos.conf 2>/dev/null && \
|
||||
grep -Fq 'autologin-session=xfce' /etc/lightdm/lightdm.conf.d/50-couchos.conf 2>/dev/null \
|
||||
&& record PASS "LightDM autologin is set to couch/XFCE" \
|
||||
|| record FAIL "LightDM autologin is not set to couch/XFCE"
|
||||
|
||||
grep -Fq 'HandleLidSwitchExternalPower=ignore' /etc/systemd/logind.conf.d/50-couchos.conf 2>/dev/null \
|
||||
&& record PASS "logind ignores lid close on AC" \
|
||||
|| record FAIL "logind lid policy on AC is not configured"
|
||||
|
||||
grep -Fq 'use_compositing" type="bool" value="false' /home/couch/.config/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml 2>/dev/null \
|
||||
&& record PASS "XFCE compositing disabled" \
|
||||
|| record FAIL "XFCE compositing not disabled"
|
||||
|
||||
grep -Fq 'do-not-disturb" type="bool" value="true' /home/couch/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-notifyd.xml 2>/dev/null \
|
||||
&& record PASS "notifications DND enabled" \
|
||||
|| record FAIL "notifications DND not enabled"
|
||||
|
||||
grep -Fq 'dpms-enabled" type="bool" value="false' /home/couch/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-power-manager.xml 2>/dev/null \
|
||||
&& record PASS "power blanking/suspend disabled" \
|
||||
|| record FAIL "power blanking/suspend not disabled"
|
||||
|
||||
if [[ -f "${RUNTIME_CONFIG}" ]]; then
|
||||
record PASS "runtime config present"
|
||||
else
|
||||
record FAIL "runtime config missing"
|
||||
fi
|
||||
|
||||
if [[ -f /home/couch/.config/couchd/token && "$(stat -c '%a' /home/couch/.config/couchd/token)" == "600" ]]; then
|
||||
if [[ "$(tr -d '\r\n' </home/couch/.config/couchd/token)" == "replace-with-long-random-token" ]]; then
|
||||
record FAIL "token still uses the template placeholder"
|
||||
else
|
||||
record PASS "token mode is 0600 and not placeholder"
|
||||
fi
|
||||
else
|
||||
record FAIL "token missing or not 0600"
|
||||
fi
|
||||
|
||||
local bind_address
|
||||
bind_address="$(runtime_field bind_address 2>/dev/null || true)"
|
||||
if [[ "${bind_address}" == "100.64.0.15" ]]; then
|
||||
record PASS "runtime bind address is 100.64.0.15"
|
||||
else
|
||||
record FAIL "runtime bind address is not 100.64.0.15"
|
||||
fi
|
||||
|
||||
if ss -ltn sport = :8765 2>/dev/null | awk '{print $4}' | grep -Fxq '100.64.0.15:8765'; then
|
||||
record PASS "couchd listens only on 100.64.0.15:8765"
|
||||
else
|
||||
record FAIL "couchd listener is not exactly 100.64.0.15:8765"
|
||||
fi
|
||||
|
||||
local controller
|
||||
controller="$(controller_snapshot)"
|
||||
if python3 - <<'PY' "${controller}"
|
||||
import json
|
||||
import sys
|
||||
|
||||
snapshot = json.loads(sys.argv[1])
|
||||
if snapshot["address"] and snapshot["paired"] and snapshot["trusted"] and snapshot["connected"]:
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
then
|
||||
record PASS "controller is paired/trusted/connected"
|
||||
else
|
||||
record FAIL "controller is not paired/trusted/connected"
|
||||
fi
|
||||
|
||||
local status_payload
|
||||
if status_payload="$(status_json 2>/dev/null)"; then
|
||||
record PASS "couchd status command succeeded"
|
||||
if python3 - <<'PY' "${status_payload}"
|
||||
import json
|
||||
import sys
|
||||
|
||||
payload = json.loads(sys.argv[1])
|
||||
mode = payload.get("display", {}).get("mode")
|
||||
name = payload.get("display", {}).get("name")
|
||||
if name == "HDMI-1" and mode == "1920x1080@60.00":
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
then
|
||||
record PASS "HDMI-1 active mode is 1920x1080@60.00"
|
||||
else
|
||||
record FAIL "HDMI-1 active mode is not 1920x1080@60.00"
|
||||
fi
|
||||
else
|
||||
record FAIL "couchd status command failed"
|
||||
fi
|
||||
|
||||
if pactl info 2>/dev/null | grep -Eiq '^Default Sink: .*hdmi'; then
|
||||
record PASS "default audio sink is HDMI"
|
||||
else
|
||||
record FAIL "default audio sink is not HDMI"
|
||||
fi
|
||||
|
||||
local couch_uid
|
||||
couch_uid="$(id -u "${COUCH_USER}" 2>/dev/null || true)"
|
||||
if [[ -n "${couch_uid}" ]] && XDG_RUNTIME_DIR="/run/user/${couch_uid}" run_as_couch systemctl --user is-active couchd.service >/dev/null 2>&1; then
|
||||
record PASS "couchd user service is active"
|
||||
else
|
||||
record FAIL "couchd user service is not active"
|
||||
fi
|
||||
if [[ -f /home/couch/.config/autostart/couchd.desktop && -f /home/couch/.config/autostart/couch-display-audio.desktop ]]; then
|
||||
record PASS "XFCE autostart entries exist for couch session units"
|
||||
else
|
||||
record FAIL "XFCE autostart entries for couch session units are missing"
|
||||
fi
|
||||
|
||||
finish
|
||||
}
|
||||
|
||||
five_reboots_mode() {
|
||||
: >"${LOG_FILE}"
|
||||
local boot_id
|
||||
boot_id="$(cat /proc/sys/kernel/random/boot_id)"
|
||||
local controller
|
||||
controller="$(controller_snapshot)"
|
||||
local status_payload
|
||||
status_payload="$(status_json 2>/dev/null || printf '{}')"
|
||||
|
||||
python3 - <<'PY' "${STATE_FILE}" "${boot_id}" "${controller}" "${status_payload}"
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
boot_id = sys.argv[2]
|
||||
controller = json.loads(sys.argv[3])
|
||||
status_payload = json.loads(sys.argv[4])
|
||||
existing = []
|
||||
if path.exists():
|
||||
existing = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
if not any(item.get("boot_id") == boot_id for item in existing):
|
||||
existing.append({
|
||||
"boot_id": boot_id,
|
||||
"timestamp": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
|
||||
"controller": controller,
|
||||
"display_mode": status_payload.get("display", {}).get("mode"),
|
||||
"operator_login_observed": "SKIP",
|
||||
"operator_fps_observed": "SKIP",
|
||||
})
|
||||
path.write_text("\n".join(json.dumps(item, sort_keys=True) for item in existing) + ("\n" if existing else ""), encoding="utf-8")
|
||||
print(len(existing))
|
||||
PY
|
||||
local count
|
||||
count="$(python3 - <<'PY' "${STATE_FILE}"
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
path = Path(sys.argv[1])
|
||||
print(len([json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]))
|
||||
PY
|
||||
)"
|
||||
record PASS "recorded reboot observation for boot ${boot_id} (${count}/5 unique boots)"
|
||||
record SKIP "operator_login_observed remains SKIP until human session confirmation is recorded"
|
||||
record SKIP "operator_fps_observed remains SKIP until human 60 FPS confirmation is recorded"
|
||||
finish
|
||||
}
|
||||
|
||||
soak_30m_start() {
|
||||
: >"${LOG_FILE}"
|
||||
python3 - <<'PY' "${SOAK_FILE}" "$(cat /proc/sys/kernel/random/boot_id)" "$(controller_snapshot)"
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
Path(sys.argv[1]).write_text(json.dumps({
|
||||
"start_boot_id": sys.argv[2],
|
||||
"started_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"controller_start": json.loads(sys.argv[3]),
|
||||
"operator_fps_observed": "SKIP",
|
||||
}, sort_keys=True) + "\n", encoding="utf-8")
|
||||
PY
|
||||
record PASS "30-minute soak started"
|
||||
record SKIP "operator_fps_observed remains SKIP until a human records it after the session"
|
||||
finish
|
||||
}
|
||||
|
||||
soak_30m_finish() {
|
||||
: >"${LOG_FILE}"
|
||||
if [[ ! -f "${SOAK_FILE}" ]]; then
|
||||
record FAIL "soak-30m-start was not run"
|
||||
finish
|
||||
fi
|
||||
python3 - <<'PY' "${SOAK_FILE}" "$(cat /proc/sys/kernel/random/boot_id)" "$(controller_snapshot)" "${ARTIFACT_DIR}/soak-30m-finish.json"
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
start = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
finished_at = datetime.now()
|
||||
started_at = datetime.fromisoformat(start["started_at"])
|
||||
elapsed = int((finished_at - started_at).total_seconds())
|
||||
payload = {
|
||||
**start,
|
||||
"finish_boot_id": sys.argv[2],
|
||||
"finished_at": finished_at.isoformat(timespec="seconds"),
|
||||
"elapsed_seconds": elapsed,
|
||||
"controller_finish": json.loads(sys.argv[3]),
|
||||
}
|
||||
Path(sys.argv[4]).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(elapsed)
|
||||
PY
|
||||
local elapsed
|
||||
elapsed="$(python3 - <<'PY' "${ARTIFACT_DIR}/soak-30m-finish.json"
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
print(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["elapsed_seconds"])
|
||||
PY
|
||||
)"
|
||||
if [[ "${elapsed}" -ge 1800 ]]; then
|
||||
record PASS "30-minute soak elapsed ${elapsed} seconds"
|
||||
else
|
||||
record FAIL "30-minute soak only ran ${elapsed} seconds"
|
||||
fi
|
||||
record SKIP "operator_fps_observed remains SKIP until a human records the observed FPS stability"
|
||||
finish
|
||||
}
|
||||
|
||||
case "${MODE}" in
|
||||
quick) quick_mode ;;
|
||||
five-reboots) five_reboots_mode ;;
|
||||
soak-30m-start) soak_30m_start ;;
|
||||
soak-30m-finish) soak_30m_finish ;;
|
||||
*)
|
||||
printf 'unknown mode: %s\n' "${MODE}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
22
scripts/slippi_first_run_helper.sh
Executable file
22
scripts/slippi_first_run_helper.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
COUCH_HOME="${COUCH_HOME:-/home/couch}"
|
||||
REPLAY_DIR="${COUCH_HOME}/Slippi"
|
||||
CHECKLIST="${REPLAY_DIR}/FIRST_RUN_CHECKLIST.md"
|
||||
|
||||
install -d -m 0700 "${REPLAY_DIR}"
|
||||
|
||||
cat <<EOF
|
||||
Created/validated ${REPLAY_DIR}.
|
||||
|
||||
Manual first-run checklist:
|
||||
1. Launch Slippi Launcher and install/update Slippi Dolphin.
|
||||
2. Point Dolphin at your own NTSC 1.02 Melee image.
|
||||
3. In Dolphin graphics, set Vulkan, fullscreen, 4:3, internal resolution 2x, and V-Sync off.
|
||||
4. Confirm replays save under ${REPLAY_DIR}.
|
||||
5. Create and save the named Xbox controller profile after device discovery.
|
||||
|
||||
Reference template:
|
||||
${CHECKLIST}
|
||||
EOF
|
||||
9
scripts/stop_game.sh
Executable file
9
scripts/stop_game.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
CONFIG_PATH="${COUCHOS_CONFIG:-/home/couch/.config/couchd/runtime.json}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
export PYTHONPATH="${REPO_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
|
||||
exec python3 -m couchd --config "${CONFIG_PATH}" stop
|
||||
Reference in New Issue
Block a user