Port the player to Raspberry Pi OS Trixie 64-bit (Linux-only branch)
Replaces the Windows port with a Raspberry Pi / Linux implementation on Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows code is removed here but preserved on the Windows-Player branch. Entry point ----------- linux/run_linux.py replaces windows/run_win.py. src/main.py stays platform-neutral; all Pi-specific behaviour is injected from linux/. Five bugs that prevented the port (all measured on real hardware) ---------------------------------------------------------------- 1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh symlinks the system SDL2 over the bundled filename. 2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough, unlike wlopm. This broke every systemd/cron/autostart launch. linux_display.ensure_session_environment() detects and exports it. 3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a different name crashed the player ~20s after a successful start. 4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the display blanked after 10 minutes. 5. The launchers ran src/main.py directly, bypassing every platform patch and resolving the data directory one level too high. Web links --------- - --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it aborts. The platform is now chosen explicitly. - The keyring password prompt is suppressed via the ENVIRONMENT, not the flags: launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot reach gnome-keyring-daemon. - Teardown kills the whole process group (needs start_new_session=True); previously it silently fell back to terminate() and orphaned children. Video normalisation ------------------- A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at 0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip), triggered by resolution only so already-playable files are untouched. src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker makes the player skip the item while it is being rebuilt, then the converted file is played instead. If nothing is playable at all (a single-item playlist whose only video is converting), the player loops the intro video rather than leaving a blank screen. Also fixed ---------- - network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses NetworkManager; ifconfig and dhclient are not even installed). - Removed the Windows-only focus keeper/guardian from main.py. - main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings "Test connection" now uses tempfile.gettempdir(). - config/app_config.json: credentials blanked so a fresh clone runs the first-run setup flow. Verification ------------ linux/test_media_state.py 18/18, test_linux_patches.py 21/21, test_linux_browser_flags.py 27/27. Verified live against a real DigiServer: image -> weblink -> image -> video with correct durations, zero leaked Chromium processes, and no throttling over a 10 minute monitored run.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""test_linux_browser_flags.py — guard the Chromium keyring bypass and footprint.
|
||||
|
||||
The keyring password prompt is the bug this file exists to prevent from coming
|
||||
back. It was "fixed" once before by adding ``--password-store=basic`` and
|
||||
``--use-mock-keychain`` to a list (``APPLIANCE_FLAGS``) that **nothing ever
|
||||
referenced**, so the flags never reached the command line and the prompt
|
||||
persisted. A second stack-only fix (``--single-process``) also looked right on
|
||||
paper but crashed with a real HTTP URL.
|
||||
|
||||
So these checks are deliberately about *what actually reaches the process*, not
|
||||
about what the constants say:
|
||||
|
||||
1. ``extra_launch_args()`` really contains the keyring + footprint flags.
|
||||
2. ``launch_env()`` really strips the D-Bus session bus, so Chromium cannot
|
||||
reach ``gnome-keyring-daemon`` even if a flag is ever ignored.
|
||||
3. Every module-level flag list is referenced by ``extra_launch_args()`` —
|
||||
dead flag lists are the exact failure mode that hid bug #1.
|
||||
4. The spawned process is in its own session (so process-group teardown works)
|
||||
and its ``/proc/<pid>/environ`` really lacks ``DBUS_SESSION_BUS_ADDRESS``.
|
||||
|
||||
Run:
|
||||
.venv/bin/python linux/test_linux_browser_flags.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent
|
||||
for path in (str(HERE), str(ROOT / 'src')):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
import linux_browser # noqa: E402
|
||||
|
||||
failures: list[str] = []
|
||||
checks = 0
|
||||
|
||||
|
||||
def check(label, condition, detail=''):
|
||||
global checks
|
||||
checks += 1
|
||||
if condition:
|
||||
print(f' PASS {label}')
|
||||
else:
|
||||
print(f' FAIL {label}' + (f' — {detail}' if detail else ''))
|
||||
failures.append(label)
|
||||
|
||||
|
||||
def build_adapter(**kwargs):
|
||||
adapter = linux_browser.LinuxChromiumAdapter(
|
||||
browser_path='/usr/bin/chromium', kiosk=True, **kwargs
|
||||
)
|
||||
adapter._profile_dir = '/tmp/kiwy-flag-test/.kiosk-profile'
|
||||
return adapter
|
||||
|
||||
|
||||
# ── 1. Keyring flags reach the command line ──────────────────────────
|
||||
print('\n[1] Keyring bypass flags are applied')
|
||||
|
||||
adapter = build_adapter()
|
||||
args = adapter.extra_launch_args()
|
||||
|
||||
for flag in ('--password-store=basic', '--use-mock-keychain'):
|
||||
check(f'{flag} present', flag in args,
|
||||
'Chromium would contact gnome-keyring and prompt for a password')
|
||||
|
||||
check('--kiosk present', '--kiosk' in args, 'weblink would not be fullscreen')
|
||||
|
||||
if linux_browser._detect_wayland():
|
||||
check('--ozone-platform=wayland present', '--ozone-platform=wayland' in args,
|
||||
'Chromium 152 aborts without an explicit Ozone platform on labwc')
|
||||
check(
|
||||
'no ineffective --ozone-platform-hint',
|
||||
'--ozone-platform-hint=auto' not in args,
|
||||
'the hint flag does NOT fall back to Wayland and just fails',
|
||||
)
|
||||
|
||||
|
||||
# ── 2. Environment actually disconnects the keyring ──────────────────
|
||||
print('\n[2] launch_env() disconnects the Secret Service')
|
||||
|
||||
env = adapter.launch_env()
|
||||
check('launch_env() returns an environment', env is not None,
|
||||
'None means Popen inherits DBUS_SESSION_BUS_ADDRESS')
|
||||
if env is not None:
|
||||
check('DBUS_SESSION_BUS_ADDRESS removed',
|
||||
'DBUS_SESSION_BUS_ADDRESS' not in env,
|
||||
'Chromium could reach gnome-keyring-daemon and prompt')
|
||||
check('DBUS_SESSION_BUS_PID removed', 'DBUS_SESSION_BUS_PID' not in env)
|
||||
check('GNOME_KEYRING_CONTROL emptied', env.get('GNOME_KEYRING_CONTROL') == '',
|
||||
'points the keyring client at nothing')
|
||||
check('CHROME_PASSWORD_STORE=basic', env.get('CHROME_PASSWORD_STORE') == 'basic')
|
||||
check('PATH preserved', bool(env.get('PATH')), 'browser could not exec')
|
||||
check('session bus is absent from the parent env to begin with',
|
||||
'DBUS_SESSION_BUS_ADDRESS' in os.environ,
|
||||
'precondition: this test only proves something if the player HAS a bus')
|
||||
|
||||
check('start_new_session() is True', adapter.start_new_session() is True,
|
||||
'os.killpg cannot reap Chromium children without it')
|
||||
|
||||
|
||||
# ── 3. No dead flag lists ────────────────────────────────────────────
|
||||
print('\n[3] Every flag list is referenced (no dead code)')
|
||||
|
||||
source = (HERE / 'linux_browser.py').read_text()
|
||||
# Names of module-level lists of flags.
|
||||
lists = re.findall(r'^([A-Z_]+_FLAGS) = \[', source, re.MULTILINE)
|
||||
check('flag lists found', len(lists) >= 5, f'only found {lists}')
|
||||
|
||||
for name in lists:
|
||||
# Count references that are NOT the definition itself.
|
||||
uses = len(re.findall(rf'(?<!^){name}(?!\s*=\s*\[)', source, re.MULTILINE))
|
||||
check(f'{name} is referenced', uses > 0,
|
||||
'a flag list nothing reads is exactly how the keyring prompt hid')
|
||||
|
||||
|
||||
# ── 4. End-to-end: the real process is detached from the bus ─────────
|
||||
print('\n[4] Live launch: process environment and process group')
|
||||
|
||||
browser = linux_browser.find_linux_browser()
|
||||
if not browser:
|
||||
print(' SKIP no Chromium installed')
|
||||
else:
|
||||
os.makedirs(adapter._profile_dir, exist_ok=True)
|
||||
live = build_adapter()
|
||||
started = live.launch('about:blank', 800, 600)
|
||||
check('launch() returned True', started is True)
|
||||
proc = live._proc
|
||||
if started and proc is not None:
|
||||
try:
|
||||
time.sleep(1.5)
|
||||
if proc.poll() is not None:
|
||||
check('browser survived start-up', False,
|
||||
f'exited rc={proc.returncode}')
|
||||
else:
|
||||
check('browser survived start-up', True)
|
||||
|
||||
# Process group: must differ from the player's own group.
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
check('browser is in its own process group',
|
||||
pgid == proc.pid,
|
||||
f'pgid={pgid} pid={proc.pid}; killpg would hit the player')
|
||||
except Exception as exc:
|
||||
check('browser is in its own process group', False, str(exc))
|
||||
|
||||
# /proc environ is authoritative: this is what the process sees.
|
||||
try:
|
||||
raw = Path(f'/proc/{proc.pid}/environ').read_bytes()
|
||||
child_env = dict(
|
||||
item.split('=', 1) for item in
|
||||
raw.decode('utf-8', 'replace').split('\x00') if '=' in item
|
||||
)
|
||||
check('live process has no DBUS_SESSION_BUS_ADDRESS',
|
||||
'DBUS_SESSION_BUS_ADDRESS' not in child_env,
|
||||
'the keyring prompt can still appear')
|
||||
check('live process has basic password store',
|
||||
child_env.get('CHROME_PASSWORD_STORE') == 'basic')
|
||||
except Exception as exc:
|
||||
check('live process environment readable', False, str(exc))
|
||||
finally:
|
||||
live.teardown()
|
||||
time.sleep(0.5)
|
||||
check('teardown() left no process behind', live._proc is None)
|
||||
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────
|
||||
print(f'\n{checks - len(failures)}/{checks} checks passed')
|
||||
if failures:
|
||||
print('\nFailed:')
|
||||
for name in failures:
|
||||
print(f' - {name}')
|
||||
sys.exit(1)
|
||||
print('All checks passed.')
|
||||
Reference in New Issue
Block a user