Files
Kiwy-Signage/linux/test_linux_browser_flags.py
T
ske087 3ac7f836c4 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.
2026-09-13 21:57:49 +03:00

181 lines
7.3 KiB
Python

"""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.')