#!/usr/bin/env python3 """Install a user service from a Pi desktop terminal; no root or SSH changes.""" import argparse import os from pathlib import Path import subprocess import sys HERE = Path(__file__).resolve().parent MARKER = '# Managed by Arcade Escape pi_service.py' def quoted(value): value=str(value) if any(c in value for c in ('\n','\r','\0')): raise ValueError('Paths must not contain control characters') return '"'+value.replace('\\','\\\\').replace('"','\\"').replace('%','%%')+'"' def working_directory(value): # Unlike ExecStart arguments, systemd reads this as a single unquoted path. value = str(value) if any(ord(c) < 32 or ord(c) == 127 for c in value): raise ValueError('Paths must not contain control characters') return value.replace('%', '%%') def reload_service(): subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True) result = subprocess.run( ['systemctl', '--user', 'show', 'arcade-cabinet.service', '--property=LoadState', '--value'], check=True, capture_output=True, text=True) if result.stdout.strip() != 'loaded': raise ValueError( 'systemd rejected arcade-cabinet.service (LoadState: ' + result.stdout.strip() + '). Inspect it with: ' 'systemctl --user status arcade-cabinet.service --no-pager -l') def render_unit(python, launcher, config): return f"""{MARKER} [Unit] Description=Arcade Escape managed cabinet PartOf=graphical-session.target [Service] Type=exec EnvironmentFile=%t/arcade-cabinet-display.env UnsetEnvironment=ARCADE_ROOM_URL ARCADE_CABINET_TOKEN ARCADE_OPERATOR_TOKEN ARCADE_CABINET_TOKENS WorkingDirectory={working_directory(Path(launcher).resolve().parents[2])} ExecStart=:{quoted(python)} {quoted(launcher)} cabinet --config {quoted(config)} Restart=no KillMode=mixed TimeoutStopSec=15 """ def render_desktop(python, script): # Desktop Exec quoting also needs escaping of $ and backticks inside quotes. def desktop_quote(value): return quoted(value).replace('$','\\$').replace('`','\\`') return f"""{MARKER} [Desktop Entry] Type=Application Name=Arcade cabinet display setup Exec={desktop_quote(python)} {desktop_quote(script)} refresh-session Terminal=false NoDisplay=true """ def refresh_session(): if not os.environ.get('DISPLAY') and not os.environ.get('WAYLAND_DISPLAY'): raise ValueError('Run this from a terminal on the Pi desktop, not a plain SSH session') runtime=Path(os.environ.get('XDG_RUNTIME_DIR',f'/run/user/{os.getuid()}')) if not runtime.is_dir(): raise ValueError('The Pi desktop user runtime directory is unavailable') values={k:os.environ[k] for k in ('DISPLAY','WAYLAND_DISPLAY','XAUTHORITY','XDG_RUNTIME_DIR','XDG_SESSION_TYPE') if os.environ.get(k)} text='' for key,value in values.items(): if any(c in value for c in ('\n','\r','\0')): raise ValueError('Invalid desktop environment value') text+=key+'="'+value.replace('\\','\\\\').replace('"','\\"')+'"\n' target=runtime/'arcade-cabinet-display.env' temporary=target.with_suffix('.tmp') temporary.write_text(text,encoding='utf-8') temporary.chmod(0o600) temporary.replace(target) print('Saved display environment for the current desktop login.') def write_managed(path, text): if path.exists() and not path.read_text(encoding='utf-8').startswith(MARKER): raise ValueError('Refusing to overwrite an unrelated file: '+str(path)) path.parent.mkdir(parents=True,exist_ok=True) path.write_text(text,encoding='utf-8') def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('action',choices=['install','refresh-session']) parser.add_argument('--config',default=str(HERE/'launcher.ini')) parser.add_argument('--remote', action='store_true', help='Register from SSH; wait for the desktop helper if no display is available') args=parser.parse_args() try: if not sys.platform.startswith('linux') or os.getuid()==0: raise ValueError('Run as the normal Pi desktop user on Linux, without sudo') if args.action=='refresh-session': refresh_session() return 0 from launch import plan config=Path(args.config).resolve() # Validate against the user's actual config; do not start games or GPIO here. plan(config,'cabinet') if not args.remote or os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY'): refresh_session() else: print('Remote installation: display helper will capture settings at the next desktop login.') home=Path.home()/'.config' unit=home/'systemd/user/arcade-cabinet.service' desktop=home/'autostart/arcade-cabinet-display.desktop' unit_text=render_unit(sys.executable,HERE/'launch.py',config) desktop_text=render_desktop(sys.executable,Path(__file__).resolve()) for target in (unit,desktop): if target.exists() and not target.read_text(encoding='utf-8').startswith(MARKER): raise ValueError('Refusing to overwrite an unrelated file: '+str(target)) write_managed(unit,unit_text) write_managed(desktop,desktop_text) reload_service() print('Installed arcade-cabinet.service (not started and not enabled at boot).') print('Start locally: systemctl --user start arcade-cabinet.service') print('The display helper will refresh settings at future desktop logins.') return 0 except (ValueError,OSError,subprocess.CalledProcessError) as exc: print('Pi service setup failed: '+str(exc),file=sys.stderr) return 1 if __name__=='__main__': sys.exit(main())