3ac7f836c4
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.
604 lines
24 KiB
Python
604 lines
24 KiB
Python
"""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/<pid>/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/<pid>/fd`` — Chromium holds the Wayland/X11 socket open once it
|
|
has connected and started creating surfaces.
|
|
2. ``/proc/<pid>/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
|