"""linux_browser.py — Chromium/Chrome kiosk adapter for Raspberry Pi (Wayland). Why this module exists ---------------------- ``src/weblink_session.py`` already owns the whole web-link lifecycle (launch → verified visibility → interaction watching → teardown) and ships a generic ``ChromiumSubprocessAdapter`` that is the default on Linux. That adapter is correct in structure but was never tuned for Raspberry Pi OS Trixie, where: * the session is **Wayland/labwc**, so Chromium needs an explicit Ozone platform or it may come up as an X11 (XWayland) surface that the compositor will not make fullscreen; * Chromium is ``/usr/bin/chromium`` (there is no ``chromium-browser``); * ``--start-maximized`` is not what makes a window fullscreen on labwc; ``--kiosk`` is; * without a dedicated ``--user-data-dir`` Chromium hands the URL to an already-running instance, the process we launched exits in ~2 s, and the session's ``wait_visible`` reports the item as failed; * ``proc.terminate()`` only kills the parent; Chromium's GPU/zygote/renderer children survive and accumulate over a 24/7 playlist. This adapter is the platform counterpart of the generic ``ChromiumSubprocessAdapter`` and is injected through the existing ``SignagePlayer.weblink_adapter_factory`` hook — no changes to the shared ``play_weblink`` code path are required. """ from __future__ import annotations import os import shutil import signal import sys import subprocess import time # The shared modules live in ../src. Add it explicitly so this file can be # imported standalone (diagnostics, tests) and not only after run_linux.py has # already put src/ on sys.path. _SRC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src') if _SRC_DIR not in sys.path: sys.path.insert(0, _SRC_DIR) from weblink_session import ChromiumSubprocessAdapter # noqa: E402 def _log(message, level='info'): try: from kivy.logger import Logger getattr(Logger, level, Logger.info)(f'[LinuxBrowser] {message}') except Exception: pass #: Wayland flags. The platform MUST be named explicitly. #: #: Measured on Chromium 152 / Raspberry Pi OS Trixie (labwc, no X server): #: #: (no flag) -> "Missing X server or $DISPLAY", aborts #: --ozone-platform-hint=auto -> "Missing X server or $DISPLAY", aborts #: --ozone-platform=wayland -> starts (9 processes) #: #: ``--ozone-platform-hint=auto`` does NOT fall back to Wayland when there is no #: X server, contrary to how it is usually described; it simply fails. So the #: flag is chosen explicitly from the detected session instead of being hinted. WAYLAND_FLAGS = [ '--ozone-platform=wayland', '--enable-features=UseOzonePlatform,WaylandWindowDecorations', ] #: Flags that stop Chromium asking for the **keyring** password. #: #: This is the prompt that appeared at start-up. On Raspberry Pi OS #: ``gnome-keyring-daemon --components=secrets`` runs and #: ``DBUS_SESSION_BUS_ADDRESS`` is set, so Chromium's default password-store #: backend resolves to ``gnome-libsecret`` (Secret Service). Chromium then tries #: to unlock the login keyring to hold its encryption key, which raises a modal #: prompt that cannot be answered on an unattended signage screen. #: #: ``--password-store=basic`` forces the built-in plain store so Chromium never #: contacts the Secret Service. ``--use-mock-keychain`` covers the equivalent #: code path on other platforms. #: #: These used to sit inside ``APPLIANCE_FLAGS``, but that list was never #: referenced from ``extra_launch_args()``, so none of them ever reached the #: command line — the prompt looked unfixable. ``test_linux_browser_flags.py`` #: now asserts they are actually applied. KEYRING_BYPASS_FLAGS = [ '--password-store=basic', '--use-mock-keychain', ] #: Flags that reduce the memory footprint on a Pi 4. #: #: Measured for one real page (https://moto-adv.com/), **PSS** summed over the #: whole browser tree — see ``_probe_chromium_footprint.py``: #: #: default flags 10 procs ~513 MB #: + --disable-gpu 10 procs ~1038 MB <-- WORSE, do not use #: + this list (light) 9 procs ~507 MB #: + --single-process (minimal) 4 procs ~438 MB (~15% less) #: #: Two things worth knowing, both measured rather than assumed: #: #: * ``--disable-gpu`` is deliberately NOT included. Intuitively it should help, #: but it moves rasterization out of the GPU process and into the renderer, #: which *doubled* memory for a real page. #: * Chromium's baseline cost is simply large. The remaining ~500 MB is the #: browser itself, so flag tuning yields only single-digit percentages. #: ``--single-process`` is the only large lever (~15%), and it is opt-in #: because upstream labels it unsupported. #: #: RSS is NOT the right metric here: Chromium shares libraries and file pages #: across its processes, so summing RSS double-counts and produced numbers that #: were ~2x too high and misleadingly ranked a smaller configuration as larger. #: #: Select a profile with KIWY_CHROMIUM_MODE=light|minimal|safe. LIGHT_WEIGHT_FLAGS = [ # One renderer instead of a pool. '--renderer-process-limit=1', # Do not retain a renderer for a window that is not visible. '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-breakpad', # Keep the process count down; each helper is a forked Chromium. '--disable-site-isolation-trials', '--disable-features=site-per-process,IsolateOrigins', # No persisted session state to load on start. '--no-restore-session-state', ] #: ``--single-process`` is the one large lever (~438 MB vs ~507 MB) but Chromium #: upstream labels the mode unsupported. Selected via #: KIWY_CHROMIUM_MODE=minimal; verified stable over repeated launches of the #: real weblink page. #: #: NOTE: ``--disable-gpu`` is intentionally absent from every list. It measured #: WORSE (~1038 MB) and is not worth a dead constant to keep around. MINIMAL_FLAGS = ['--single-process'] #: Everything a static page does not need. Each service is a separate process. SERVICE_REDUCTION_FLAGS = [ '--no-service-autorun', '--disable-component-extensions-with-background-pages', '--disable-default-apps', '--disable-extensions', '--disable-plugins-discovery', '--disable-preconnect', '--disable-domain-reliability', '--disable-client-side-phishing-detection', '--disable-hang-monitor', '--metrics-recording-only', '--no-pings', ] def _chromium_mode(): """Footprint profile: ``light`` (default), ``minimal`` or ``safe``. * ``light`` — safe reductions, no GPU, one renderer (recommended) * ``minimal`` — adds ``--single-process``; smallest, can crash * ``safe`` — no footprint flags at all, for ruling them out when debugging """ value = os.environ.get('KIWY_CHROMIUM_MODE', 'light').strip().lower() if value not in ('light', 'minimal', 'safe'): _log(f'unknown KIWY_CHROMIUM_MODE={value!r}; using "light"', 'warning') return 'light' return value #: Flags that make a signage page behave like an appliance. #: #: NOTE: every list here must be referenced from ``extra_launch_args()``. This #: list was dead code once, which silently disabled the keyring bypass along #: with every kiosk nicety. APPLIANCE_FLAGS = [ '--noerrdialogs', '--disable-infobars', '--no-first-run', '--no-default-browser-check', '--disable-session-crashed-bubble', '--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints,PasswordManagerOnboarding,AutofillServerCommunication,PasswordLeakDetection', '--disable-background-networking', '--disable-component-update', '--disable-sync', '--check-for-update-interval=31536000', '--autoplay-policy=no-user-gesture-required', '--hide-scrollbars', '--disable-pinch', '--overscroll-history-navigation=0', '--force-device-scale-factor=1', '--window-position=0,0', ] #: Never offer to save or autofill credentials — a second source of prompts. #: The pages shown are public, so suppressing this costs nothing. NO_PROMPTS_FLAGS = [ '--disable-save-password-bubble', ] def _browser_env(): """Environment for the browser process only — never the player. Defence in depth for the keyring prompt. Even if Chromium ignores ``--password-store=basic``, an emptied ``GNOME_KEYRING_CONTROL`` and a removed ``DBUS_SESSION_BUS_ADDRESS`` mean the Secret Service cannot be reached, so no unlock prompt can be raised at all. Scoped to the child deliberately: the player keeps its real session bus, which other components may rely on. """ env = dict(os.environ) env['GNOME_KEYRING_CONTROL'] = '' env['CHROME_PASSWORD_STORE'] = 'basic' # Nothing a single static page needs requires the session bus. env.pop('DBUS_SESSION_BUS_ADDRESS', None) env.pop('DBUS_SESSION_BUS_PID', None) # Discourage portal / keyring autostart helpers from being pulled in. env['XDG_DESKTOP_PORTAL_SUPPRESS'] = '1' return env def find_linux_browser(): """Locate a Chromium-family browser, preferring the Debian/RPi names. ``chromium-browser`` is checked first only because older RPi OS releases shipped it as the wrapper name; on Trixie the real binary is ``chromium``. """ for candidate in ( 'chromium-browser', # RPi OS <= Bullseye wrapper 'chromium', # RPi OS Bookworm/Trixie 'google-chrome', 'google-chrome-stable', 'chrome', 'microsoft-edge', ): path = shutil.which(candidate) if path: return path return None class LinuxChromiumAdapter(ChromiumSubprocessAdapter): """Chromium kiosk subprocess for Raspberry Pi OS (Wayland/labwc). Subclasses :class:`ChromiumSubprocessAdapter` so the session's health checking, interaction watching and generation-tokened teardown all keep working; only the Linux-specific behaviour is overridden. """ name = 'chromium-kiosk-linux' embedded = False def __init__(self, browser_path=None, extra_flags=(), kiosk=True, profile_dir=None, use_wayland=None): super().__init__(browser_path=browser_path, extra_flags=extra_flags, kiosk=kiosk) self._profile_dir = profile_dir # Flags follow the detected session. An explicit --ozone-platform is # required on Wayland (see WAYLAND_FLAGS); when there is no Wayland # socket the flags are omitted so Chromium uses its own default (X11). self._use_wayland = _detect_wayland() if use_wayland is None else bool(use_wayland) # Track our own process group so teardown can reap the whole tree. self._pgid = None self._preflight_done = False # ── Launch environment / session ───────────────────────────────── def launch_env(self): """Environment for the browser process only. This — not the ``--password-store`` flag — is what actually removes the keyring password prompt. The base ``Popen`` inherited the player's environment, which includes ``DBUS_SESSION_BUS_ADDRESS``; Chromium could therefore reach the running ``gnome-keyring-daemon`` and tried to unlock the login keyring. """ return _browser_env() def start_new_session(self): """Give the browser its own process group so teardown can reap it. The base implementation used a plain ``Popen``, so the browser shared the player's process group and ``os.killpg`` was never usable — every weblink left Chromium's GPU/zygote/renderer children behind. """ return True # ── Flags ──────────────────────────────────────────────────────── def extra_launch_args(self): """Flags appended to the Chromium command line. Ordering is deliberate: appliance/keyring flags come first so a later list can never accidentally be shadowed, and every module-level list is referenced here. If you add a list above, add it here too — a list that nothing references is how the keyring prompt survived a "fix". """ args = [] # 1. Never touch the Secret Service / keyring (the password prompt). args.extend(KEYRING_BYPASS_FLAGS) args.extend(NO_PROMPTS_FLAGS) # 2. Kiosk behaviour: no browser UI, no error dialogs, no autofill. args.extend(APPLIANCE_FLAGS) args.extend(SERVICE_REDUCTION_FLAGS) # 3. Footprint. A static signage page needs a fraction of Chromium's # defaults; each avoided helper is a process the Pi 4 does not have # RAM for. mode = _chromium_mode() if mode in ('light', 'minimal'): args.extend(LIGHT_WEIGHT_FLAGS) if mode == 'minimal': args.extend(MINIMAL_FLAGS) # 4. Identity and geometry. if self._profile_dir: args.append('--user-data-dir=' + self._profile_dir) if self._kiosk: # --kiosk implies fullscreen and removes all browser UI, which is # the supported path on wlroots compositors. args.append('--kiosk') if self._use_wayland: args.extend(WAYLAND_FLAGS) return args def launch(self, url, width, height): """Prepare the private profile, then delegate to the base launch. Any Chromium still holding the profile is killed first: a surviving instance would swallow the URL and make our process exit immediately. """ self._preflight() if self._profile_dir is None: self._profile_dir = os.path.join( os.environ.get('KIWY_DATA_DIR') or os.getcwd(), '.kiosk-profile' ) try: os.makedirs(self._profile_dir, exist_ok=True) except Exception as exc: _log(f'could not create kiosk profile {self._profile_dir}: {exc}', 'warning') self._kill_browsers_on_profile() self._cleanup_stale_profile_locks() ok = super().launch(url, width, height) if ok and self._proc is not None: self._pgid = _safe_getpgid(self._proc.pid) return ok def _preflight(self): """Warn once when Chromium's platform flags do not match this session. The failure this guards against is silent: on a Wayland session with no X server, Chromium started without ``--ozone-platform=wayland`` aborts after ~1 s with "Missing X server or $DISPLAY" on stderr, which reads identically to a hand-off bug. Detecting the mismatch at launch time turns an unexplained skipped weblink into an actionable log line. """ if getattr(self, '_preflight_done', False): return self._preflight_done = True has_x_server = bool(os.environ.get('DISPLAY')) if self._use_wayland and not has_x_server: _log('session is Wayland-only; launching Chromium with ' '--ozone-platform=wayland (the default and --ozone-platform-hint ' 'both abort with "Missing X server" here)') elif not self._use_wayland and has_x_server: _log('session is X11; launching Chromium without Wayland flags') elif self._use_wayland and has_x_server: _log('both Wayland and X11 available; preferring Wayland') # ── Startup verification ───────────────────────────────────────── def wait_visible(self, timeout): """Wait until the launched Chromium is genuinely up and not a hand-off. A plain "process still alive" check is not enough on Linux: a hand-off launch also stays alive briefly, and a missing Wayland socket produces a fast exit. Two signals are combined: * the process must survive the health grace period; and * a Chromium window/toplevel must be observable for this PID. If the second cannot be established on this compositor we still return success on the first, so a working-but-unprobeable setup is never skipped (that would be worse than a possible blank frame). """ proc = self._proc if proc is None: return False, 'no process' deadline = time.monotonic() + max(1.0, float(timeout)) grace = min(float(self._health_grace), max(0.5, float(timeout))) grace_deadline = time.monotonic() + grace while time.monotonic() < grace_deadline: if proc.poll() is not None: return False, f'browser exited immediately (rc={proc.returncode})' time.sleep(0.1) if _window_exists_for_pid(proc.pid): return True, f'toplevel-for-pid={proc.pid}' while time.monotonic() < deadline: if proc.poll() is not None: return False, f'browser exited early (rc={proc.returncode})' if _window_exists_for_pid(proc.pid): return True, f'toplevel-for-pid={proc.pid}' time.sleep(0.2) if proc.poll() is None: # Alive past the timeout but not probeable — accept rather than # skipping a page that is probably on screen. return True, 'process-alive-unverified' return False, 'browser window never appeared' # ── Teardown ───────────────────────────────────────────────────── def teardown(self): """Terminate the whole Chromium process group. ``proc.terminate()`` (the base behaviour) leaves the GPU, zygote and renderer children behind; over a 24/7 playlist those accumulate until the Pi runs out of memory. Killing the process group reaps them all. """ proc, self._proc = self._proc, None pgid, self._pgid = self._pgid, None if proc is None: return if proc.poll() is None: _kill_process_group(proc, pgid) # Belt and braces: reap anything else still holding this profile. self._kill_browsers_on_profile() self._cleanup_stale_profile_locks() # ── Helpers ────────────────────────────────────────────────────── def _cleanup_stale_profile_locks(self): """Remove the singleton lock a crashed Chromium left behind. Chromium refuses to start on a profile whose ``SingletonLock`` points at a dead PID (or shows the "profile in use" dialog). Because our profile is private to the player, clearing the lock is always safe. """ if not self._profile_dir: return for name in ('SingletonLock', 'SingletonSocket', 'SingletonCookie'): path = os.path.join(self._profile_dir, name) try: if os.path.islink(path) or os.path.exists(path): os.unlink(path) _log(f'cleared stale profile lock {name}', 'debug') except Exception: pass def _kill_browsers_on_profile(self): """Kill any Chromium holding our kiosk profile. Scans ``/proc//cmdline`` rather than shelling out to ``pgrep`` so this works without procps and cannot match the wrong process. """ if not self._profile_dir: return [] marker = self._profile_dir own_uid = os.getuid() killed = [] try: for entry in os.listdir('/proc'): if not entry.isdigit(): continue pid = int(entry) if pid == os.getpid(): continue try: if os.stat(f'/proc/{pid}').st_uid != own_uid: continue with open(f'/proc/{pid}/cmdline', 'rb') as fh: cmdline = fh.read().replace(b'\x00', b' ').decode( 'utf-8', 'replace' ) except (OSError, PermissionError, ProcessLookupError): continue if marker in cmdline and 'chrom' in cmdline.lower(): try: os.kill(pid, signal.SIGTERM) killed.append(pid) except Exception: continue except Exception as exc: _log(f'profile scan failed: {exc}', 'debug') if killed: _log(f'terminated {len(killed)} leaked browser(s) on the kiosk ' f'profile: {killed}') return killed # ── Module-level helpers ───────────────────────────────────────────── def _detect_wayland(): """True when the session looks like Wayland. Uses ``linux_display`` when importable (it also fills in an unset ``WAYLAND_DISPLAY``, which Chromium needs for --ozone-platform-hint), and falls back to a socket probe so this module stays independently testable. """ if os.environ.get('WAYLAND_DISPLAY'): return True try: import linux_display linux_display.ensure_session_environment() return linux_display.is_wayland() except Exception: runtime = os.environ.get('XDG_RUNTIME_DIR') or f'/run/user/{os.getuid()}' return os.path.exists(os.path.join(runtime, 'wayland-0')) def _safe_getpgid(pid): try: return os.getpgid(pid) except Exception: return None def _kill_process_group(proc, pgid): """SIGTERM then SIGKILL the browser's process group.""" target = pgid if pgid and pgid != os.getpgid(0) else None try: if target: os.killpg(target, signal.SIGTERM) else: proc.terminate() except Exception: try: proc.terminate() except Exception: pass # Give Chromium a moment to flush and exit cleanly. deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: if proc.poll() is not None: break time.sleep(0.1) if proc.poll() is None: try: if target: os.killpg(target, signal.SIGKILL) else: proc.kill() except Exception: pass try: proc.wait(timeout=3) except Exception: pass def _window_exists_for_pid(pid): """Best-effort check that ``pid`` owns a visible top-level surface. Two independent probes, because neither works everywhere: 1. ``/proc//fd`` — Chromium holds the Wayland/X11 socket open once it has connected and started creating surfaces. 2. ``/proc//task/*/comm`` — the GPU/renderer children only appear once the browser has actually started rendering. Returns False on any error; the caller falls back to "process alive". """ try: fd_dir = f'/proc/{pid}/fd' socket_links = 0 for fd in os.listdir(fd_dir): try: target = os.readlink(os.path.join(fd_dir, fd)) except OSError: continue if 'wayland' in target or 'X11-unix' in target: socket_links += 1 if socket_links: return True except Exception: pass try: task_dir = f'/proc/{pid}/task' for tid in os.listdir(task_dir): try: with open(os.path.join(task_dir, tid, 'comm')) as fh: comm = fh.read().strip() except OSError: continue if comm in ('Chrome_ChildIOT', 'Chrome_IOThread'): return True except Exception: pass return False