From a19627885ccf2ec03d2e97ae4c413adfe6a79076 Mon Sep 17 00:00:00 2001 From: ske087 Date: Thu, 10 Sep 2026 16:43:36 +0300 Subject: [PATCH] Add unified WeblinkSession controller and interaction-driven playback Web links were implemented three times (main.py subprocess, run_win.py subprocess + Win32 overlay, cef_browser.py embedded CEF), each owning its own process handle, watchdog and teardown. That ambiguity caused leaked browsers, skipped items, lost foreground and blank screens when a page failed to load. Replace all three with a single owner in src/weblink_session.py: - WeblinkSession: validate -> launch -> verify -> watch -> teardown. Generation-tokened so stale callbacks are ignored, idempotent close(), atexit-safe, never more than one browser alive. - WeblinkAdapter: the only platform-specific part (launch / wait_visible / is_alive / teardown / prewarm). Platform layers inject engines through SignagePlayer.weblink_adapter_factory. - ChromiumSubprocessAdapter: default engine (Pi chromium, Windows chrome/msedge). - InteractionWatcher: decides when an item is finished. - WebInputSources: /dev/input/event* (Linux) plus a GetCursorPos pointer tap (needed on Windows for embedded CEF, which has no child process). Interaction model: web links are an interactive surface, not timed media. The player advances only when the configured duration has elapsed AND the viewer has not interacted for 10s, measured from the most recent interaction. A touch in the final seconds of a slot therefore pushes the advance 10s past that touch, and each further touch pushes it again, so a page is never pulled out from under someone using it. An untouched page still advances on schedule. A drag burst counts as one interaction but the countdown tracks its last event, so an item cannot be cut off mid-gesture. max_dwell (duration x factor, floored by min_max_dwell) is an absolute backstop against a wedged browser or a jammed touchscreen. Verified start-up: the visibility wait runs on the watcher thread, never on Kivy's main thread. If the browser window never appears the item is reported failed and skipped, instead of resetting the error counter and leaving a black screen up for the whole duration. Config: new "weblink" block in config/app_config.json (engine, interaction_postpone, interaction_debounce, interaction_grace, max_dwell_factor, min_max_dwell, launch_timeout, prewarm) with safe defaults, so an absent block still works. Also add weblink_session to the PyInstaller hiddenimports so the frozen exe bundles the new module. --- PLAYER_WEBLINK_INTEGRATION.md | 114 +++- config/app_config.json | 17 +- src/weblink_session.py | 1131 +++++++++++++++++++++++++++++++++ windows/build.spec | 5 + 4 files changed, 1256 insertions(+), 11 deletions(-) create mode 100644 src/weblink_session.py diff --git a/PLAYER_WEBLINK_INTEGRATION.md b/PLAYER_WEBLINK_INTEGRATION.md index 7f50c71..a107df0 100644 --- a/PLAYER_WEBLINK_INTEGRATION.md +++ b/PLAYER_WEBLINK_INTEGRATION.md @@ -1,21 +1,22 @@ # Web Link Playlist Items — Player Integration Guide -This document describes the changes required on the **Kiwy-Signage player** -() to support a new -playlist item type: **`weblink`** (display a live web page / URL instead of an -uploaded media file). +This document describes how the **Kiwy-Signage player** +() supports the **`weblink`** +playlist item type (display a live web page / URL instead of an uploaded media +file). -> The DigiServer (this repo, `digiserver-v2`) side will be updated to emit -> `weblink` items in the playlist API. The player does **not** yet support them. -> Use this guide to implement the player side later. +> **Status: implemented.** The player supports `weblink` items on both +> Raspberry Pi (`chromium` subprocess) and Windows (embedded CEF with a +> Chrome/Edge subprocess fallback). Sections 1–4 describe the original design +> plan; section 6 documents the shipped architecture and the interaction model. --- -## 1. Background — how items flow today +## 1. Background — how items flow ``` DigiServer API ──JSON──▶ player sync (get_playlists_v2.py) ──▶ playlist.json ──▶ main.py renders - /api/playlists downloads files to media/ by file extension + /api/playlists downloads files to media/ by item type ``` Each playlist item the server returns currently looks like: @@ -243,3 +244,98 @@ Recommended options, in order of robustness: depth). - Consider running Chromium with `--incognito` (no persistent cookies/cache) as shown above. + +--- + +## 6. Shipped architecture (`src/weblink_session.py`) + +The player-side implementation lives in **one** module, so launch, verification, +timing and teardown have a single owner instead of being duplicated per +platform: + +| Piece | Responsibility | +|--------------------------|----------------| +| `WeblinkSession` | Owns one weblink item: validate → launch → verify → watch → teardown. Generation-tokened so stale callbacks are ignored, and `atexit`-safe. | +| `WeblinkAdapter` | The only platform-specific part: launch / wait for the window / is it alive / tear it down / pre-warm. | +| `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`, Windows `chrome.exe`/`msedge.exe`). | +| `InteractionWatcher` | Decides when the item is finished (see the interaction model below). | +| `WebInputSources` | Reads `/dev/input/event*` (Linux) and does a pointer-position tap (Windows, needed for embedded CEF). | + +Platform wrappers inject their engines through +`SignagePlayer.weblink_adapter_factory`: + +* **Raspberry Pi / Linux** — built-in Chromium subprocess adapter. +* **Windows** (`windows/run_win.py`) — embedded CEF first (`cef_browser.py`, + renders inside the Kivy window: no z-order fights, no subprocess), then the + Chrome/Edge subprocess adapter as fallback. + +### 6.1 Interaction model — web links are not passive media + +`duration` on a weblink is **not** a hard cut-off. The player advances only when +**both** conditions are true: + +1. the configured `duration` has elapsed; **and** +2. the viewer has not interacted with the page for `interaction_postpone` + seconds (default **10 s**), measured from the **most recent** interaction. + +Consequences: + +- A viewer who taps, scrolls or navigates the page during the final seconds of + the slot **keeps the page on screen** — the advance is pushed 10 s past that + touch, and every further touch pushes it again. The link is never pulled out + from under someone who is using it. +- An untouched page still advances on schedule, exactly like a media item. +- A multi-event burst (a drag, a page transition) counts as **one** interaction + but the countdown is measured from the **last** event of that burst, so an + item can never be cut off mid-gesture. +- `max_dwell` (duration × `max_dwell_factor`, at least `min_max_dwell`) is an + absolute backstop so a wedged browser or a jammed touchscreen cannot park the + playlist forever. + +**Pause/play does not apply to web links.** A web link is an interactive +surface, so `toggle_pause()` is a no-op while one is on screen — the interaction +watcher owns its lifecycle. The pause button continues to work normally for +images and videos. + +### 6.2 Verified start-up + +Launching a browser is not the same as displaying a page. The session therefore +does **not** report success immediately after spawning the process (that used to +reset the error counter and leave a black screen for the whole duration). The +watcher thread — never the Kivy main thread — waits for the browser window to +appear, and if it never does the item is reported as failed and skipped. + +### 6.3 Configuration + +All timings are tunable in `config/app_config.json` under `weblink`: + +```json +"weblink": { + "engine": "auto", + "interaction_postpone": 10, + "interaction_debounce": 0.5, + "interaction_grace": 5.0, + "max_dwell_factor": 6.0, + "min_max_dwell": 300, + "launch_timeout": 15, + "prewarm": true +} +``` + +| Key | Meaning | +|-----|---------| +| `engine` | Preferred engine (`auto`, `cef`, `subprocess`). | +| `interaction_postpone` | Seconds the advance is postponed, measured from each interaction (default 10). | +| `interaction_debounce` | Logging/trace throttle for continuous drags (default 0.5). | +| `interaction_grace` | Settle window after the last raw event still counted as interacting (default 5). | +| `max_dwell_factor` | Hard ceiling = `duration × factor`. | +| `min_max_dwell` | Floor for that hard ceiling, in seconds. | +| `launch_timeout` | How long to wait for the browser window to appear. | +| `prewarm` | Pre-warm the next weblink (disabled on Windows). | + +### 6.4 Diagnostics + +The watcher traces structured events through `playback_trace.py`: +`weblink_launch`, `weblink_visible`, `weblink_interaction`, `weblink_end` +(with reason `viewer_idle`, `browser_exited` or `max_dwell`), +`weblink_not_visible` and `weblink_failed`. diff --git a/config/app_config.json b/config/app_config.json index f5dbb63..12b7a51 100644 --- a/config/app_config.json +++ b/config/app_config.json @@ -1,5 +1,5 @@ { - "server_ip": "192.168.0.107", + "server_ip": "192.168.0.108", "port": "8080", "screen_name": "WINDOWS-PC", "quickconnect_key": "8887779", @@ -8,5 +8,18 @@ "max_resolution": "1920x1080", "edit_feature_enabled": true, "use_https": false, - "verify_ssl": false + "verify_ssl": false, + "card_reader_mode": "auto", + "card_reader_device": "", + "card_reader_timeout": 5, + "weblink": { + "engine": "auto", + "interaction_postpone": 10, + "interaction_debounce": 0.5, + "interaction_grace": 5.0, + "max_dwell_factor": 6.0, + "min_max_dwell": 300, + "launch_timeout": 15, + "prewarm": true + } } \ No newline at end of file diff --git a/src/weblink_session.py b/src/weblink_session.py new file mode 100644 index 0000000..a5720f9 --- /dev/null +++ b/src/weblink_session.py @@ -0,0 +1,1131 @@ +"""Unified web-link playback controller for the Kiwy signage player. + +Why this module exists +---------------------- +Web-link items used to be implemented three times: + * ``main.py`` — Chromium subprocess + /dev/input idle watchdog (Linux/Pi) + * ``run_win.py`` — Chrome/Edge subprocess + Win32 overlay + focus fighting + * ``cef_browser.py`` — embedded CEF child window (Windows, optional) + +Each copy owned its own process handle, its own watchdog and its own teardown +logic, so "who owns the browser" was ambiguous. That ambiguity caused the +documented failure modes: leaked browsers, skipped items, lost foreground and +blank screens when a page failed to load. + +This module replaces all three with a single owner: + + :class:`WeblinkSession` — orchestrates one weblink item at a time. + * validates the URL before anything is launched + * launches through a platform *adapter* + * **verifies the browser actually appeared** before reporting success + * watches for **viewer interaction**, process death and a hard maximum + dwell time (see below) + * guarantees teardown (idempotent, generation-tokened, atexit-safe) + +Interaction model (web links are not passive media) +--------------------------------------------------- +Web links are treated as an **interactive surface**, not as timed media: + +* pause/play does **not** apply to a web link the way it does to an image or + video — a viewer navigates the page instead; +* the item is shown for its configured duration; +* every interaction with the page (touch, tap, scroll, mouse movement) + **postpones** the advance by ``interaction_postpone`` seconds (default 10), + measured from the moment of that interaction; +* so a viewer who starts interacting during the last few seconds of the + configured duration keeps the page on screen instead of being cut off, and + each further interaction postpones it again; +* the player advances only once the configured duration has elapsed **and** the + viewer has stopped interacting for the postponement window; +* ``max_dwell`` is an absolute backstop against a wedged browser or a jammed + input device parking the playlist forever. + + :class:`WeblinkAdapter` — the only platform-specific part. An adapter + knows how to launch, detect, raise and kill one browser flavour. + +Platform modules inject their adapter(s) via +``SignagePlayer.weblink_adapter_factory``; when none is injected the default +Chromium-subprocess adapter below is used (Linux / Raspberry Pi). +""" + +import atexit +import os +import select +import shutil +import subprocess +import threading +import time +from urllib.parse import urlparse + +from kivy.clock import Clock +from kivy.logger import Logger + +try: + from playback_trace import trace +except Exception: # pragma: no cover - trace is optional + def trace(*args, **kwargs): + pass + + +# ── Constants ──────────────────────────────────────────────────────── +ALLOWED_SCHEMES = ('http', 'https') +MAX_URL_LENGTH = 2048 + +# How long we wait for the browser window to become visible after launch. +DEFAULT_LAUNCH_TIMEOUT = 15.0 + +# A launch whose process dies sooner than this is treated as a failed launch +# (hand-off to a leaked instance, missing browser, instant crash). +MIN_HEALTHY_ALIVE = 3.0 + +# Hard upper bound on how long a weblink may occupy the screen. Guards against +# a wedged browser or a stuck input source (e.g. a jammed touchscreen) parking +# the playlist forever. Interaction-driven postponement is intentionally +# generous, so this ceiling is a backstop, not the normal end of an item. +MAX_DWELL_FACTOR = 6.0 +MIN_MAX_DWELL = 300.0 + +# Ignore "the process exited" as a signal for this long after launch, so a +# browser that hands the URL off to an existing instance is not mistaken for +# a finished item. +DEFAULT_MIN_ALIVE_BEFORE_EXIT_ADVANCE = 8.0 + +# Every viewer interaction with the page postpones the advance, measured from +# the moment of that interaction. A viewer who keeps touching the page +# (scrolling, tapping links, filling a form) therefore keeps the web link on +# screen indefinitely, while an untouched page advances after its configured +# duration. +DEFAULT_INTERACTION_POSTPONE = 10.0 + +# How long after the last input event the viewer is still treated as "using the +# page". This keeps the item on screen through a multi-event burst (a drag, a +# page transition animation) so it can never be cut off mid-interaction. +DEFAULT_INTERACTION_GRACE = 5.0 + + +class WeblinkSettings: + """Tunables for weblink playback, read from the ``weblink`` config block. + + Every value has a safe default so an empty/absent config still works. + """ + + def __init__(self, config=None): + cfg = {} + if isinstance(config, dict): + cfg = config.get('weblink') or {} + if not isinstance(cfg, dict): + cfg = {} + + self.engine = str(cfg.get('engine', 'auto')).lower() + self.launch_timeout = _as_float(cfg.get('launch_timeout'), DEFAULT_LAUNCH_TIMEOUT) + self.min_healthy_alive = _as_float(cfg.get('min_healthy_alive'), MIN_HEALTHY_ALIVE) + self.min_alive_before_exit_advance = _as_float( + cfg.get('min_alive_before_exit_advance'), DEFAULT_MIN_ALIVE_BEFORE_EXIT_ADVANCE + ) + self.retry = max(0, _as_int(cfg.get('retry'), 0)) + self.retry_delay = _as_float(cfg.get('retry_delay'), 2.0) + self.prewarm = _as_bool(cfg.get('prewarm'), True) + # Seconds the advance is postponed, measured from each interaction. + self.interaction_postpone = max( + 0.0, _as_float(cfg.get('interaction_postpone'), DEFAULT_INTERACTION_POSTPONE) + ) + # Ignore interactions that arrive within this many seconds of each + # other (only used to throttle logging/tracing of long drags). + self.interaction_debounce = max( + 0.0, _as_float(cfg.get('interaction_debounce'), 0.5) + ) + # How long after the last input event the viewer still counts as + # interacting (default 5s) — protects multi-event bursts. + self.interaction_grace = max( + 0.0, _as_float(cfg.get('interaction_grace'), DEFAULT_INTERACTION_GRACE) + ) + self.max_dwell_factor = _as_float(cfg.get('max_dwell_factor'), MAX_DWELL_FACTOR) + # 0 / negative means "no implicit floor" — the factor alone decides. + self.min_max_dwell = _as_float(cfg.get('min_max_dwell'), MIN_MAX_DWELL) + self.browser_flags = cfg.get('browser_flags') or [] + if not isinstance(self.browser_flags, (list, tuple)): + self.browser_flags = [] + + def max_dwell_for(self, duration): + """Hard ceiling (seconds) for one weblink item.""" + limit = max(1.0, float(duration)) * self.max_dwell_factor + return max(limit, self.min_max_dwell) + + +def _as_float(value, default): + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _as_int(value, default): + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _as_bool(value, default): + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ('1', 'true', 'yes', 'on') + if value is None: + return default + return bool(value) + + +def validate_weblink_url(url): + """Return ``(ok, reason)`` for a candidate weblink URL. + + Defence in depth: the server validates on entry, but the player must never + hand an arbitrary string to a browser. Only absolute ``http``/``https`` + URLs with a host are accepted — this rejects ``file://``, ``javascript:``, + ``chrome://``, UNC paths and shell-metacharacter payloads. + """ + if not url or not isinstance(url, str): + return False, 'empty url' + url = url.strip() + if len(url) > MAX_URL_LENGTH: + return False, f'url too long ({len(url)} chars)' + # Control characters / whitespace inside the URL would let a malformed + # playlist entry inject extra browser flags. + if any(ch.isspace() or ord(ch) < 0x20 for ch in url): + return False, 'url contains whitespace/control characters' + try: + parsed = urlparse(url) + except Exception as exc: + return False, f'unparseable url: {exc}' + if parsed.scheme.lower() not in ALLOWED_SCHEMES: + return False, f'unsupported scheme: {parsed.scheme or "(none)"}' + if not parsed.netloc: + return False, 'url has no host' + return True, '' + + +class WeblinkAdapter: + """Platform-specific browser control. + + An adapter owns exactly one browser instance at a time. Implementations + must be safe to call in this order:: + + on_before_launch() -> launch() -> confirm_visible() + [ -> on_visible() ] -> is_alive() (repeat) -> teardown() + + ``teardown()`` must be idempotent and must never raise. + """ + + #: Short human-readable name used in log/trace messages. + name = 'adapter' + + #: Set to True by adapters that render inside the Kivy window (CEF). + #: The session then skips the "browser window appeared" requirement. + embedded = False + + #: The launched process, when the engine is subprocess based. The session + #: mirrors this onto the player's historic ``_weblink_proc`` attribute. + process = None + + #: Platform-specific visibility hook. Set by the session; adapters call it + #: when they can prove the page never appeared (see `wait_visible`). + visibility_failed = None + + def on_before_launch(self, url, width, height): + """Prepare the display (show overlay, hide Kivy content, ...).""" + + def on_launch_failed(self): + """Undo ``on_before_launch`` work after a failed launch.""" + + def launch(self, url, width, height): + """Start the browser. Return True when the process/window was started.""" + raise NotImplementedError + + def on_visible(self): + """Called once the browser is confirmed on screen.""" + + def wait_visible(self, timeout): + """Block **on the watcher thread** until the page is on screen. + + Return ``(visible, detail)``. This must never be called from the Kivy + main thread, because it is allowed to block for up to ``timeout`` + seconds. Adapters that cannot verify visibility should return + ``(True, 'unverified')`` after confirming the process is still alive + rather than failing the item. + """ + return True, 'unverified' + + def is_alive(self): + """Return True while the browser is still running.""" + return True + + def target_size(self): + """Preferred launch size as ``(width, height)``.""" + return 1920, 1080 + + def teardown(self): + """Stop the browser and release every resource. Never raises.""" + + def prewarm(self, url): + """Optionally pre-load a URL that is about to be shown next.""" + + def cancel_prewarm(self): + """Cancel/clean up any pre-warm work.""" + + +class WebInputSources: + """Raw input devices + a global pointer-position tap. + + Two independent sources of "the viewer is interacting", because neither is + sufficient on its own: + + * ``/dev/input/event*`` (Linux/Pi) — catches touch on every engine, but + needs read permission. + * ``GetCursorPos`` (Windows) — catches touchscreen *and* mouse pointer + movement on the engine that renders inside the Kivy window (CEF), where + no child process exists to attribute events to, and where input may be + owned by a different process. + + Only *change* counts: holding a finger still or resting the cursor is idle. + """ + + def __init__(self): + self._devices = [] + self._last_pointer = None + + # ── Device discovery ───────────────────────────────────────────── + def open_devices(self): + """Open every readable ``/dev/input/event*`` device (best effort).""" + self._devices = [] + try: + import glob + for path in sorted(glob.glob('/dev/input/event*')): + try: + self._devices.append(open(path, 'rb')) # noqa: SIM115 - kept open + except (PermissionError, OSError) as exc: + Logger.debug(f"SignagePlayer: Web input cannot open {path}: {exc}") + except Exception as exc: + Logger.debug(f"SignagePlayer: Web input device scan failed: {exc}") + return self._devices + + @property + def devices(self): + return self._devices + + def close_devices(self): + for fd in self._devices: + try: + fd.close() + except OSError: + pass + self._devices = [] + + def drop_device(self, fd): + try: + self._devices.remove(fd) + except ValueError: + pass + try: + fd.close() + except OSError: + pass + + def select(self, timeout): + """Wait up to ``timeout`` for raw input. Returns the readable fds. + + With no devices open this must still consume the timeout, otherwise the + caller's loop would spin (the Windows/CEF case has no ``/dev/input``). + """ + if not self._devices: + if timeout > 0: + time.sleep(timeout) + return [] + try: + readable, _, _ = select.select(self._devices, [], [], timeout) + return readable + except (OSError, ValueError): + # A device disappeared (udev reset / touchscreen unplug). + return [] + + def drain(self, readable): + """Consume event data so kernel buffers do not overflow. + + Returns True when activity was seen. + """ + seen = False + for fd in readable: + try: + fd.read(1024) + seen = True + except OSError: + self.drop_device(fd) + return seen + + # ── Pointer tap (works for CEF / Windows) ──────────────────────── + def pointer_moved(self): + """True when the pointer/touch position changed since the last call. + + Uses ``GetCursorPos`` so it works without a child process and without + reading ``/dev/input``. Returns False on non-Windows platforms. + """ + if os.name != 'nt': + return False + try: + import ctypes + from ctypes import wintypes + + class _POINT(ctypes.Structure): + _fields_ = [('x', wintypes.LONG), ('y', wintypes.LONG)] + + pt = _POINT() + if not ctypes.windll.user32.GetCursorPos(ctypes.byref(pt)): + return False + current = (pt.x, pt.y) + moved = self._last_pointer is not None and current != self._last_pointer + self._last_pointer = current + return moved + except Exception: + return False + + +class InteractionWatcher(threading.Thread): + """Decides when a web-link item is finished, based on viewer interaction. + + For web links there is deliberately **no pause/play and no fixed timeline**. + The page stays up for its configured ``duration`` seconds, and the player + only advances once **both** of these are true: + + 1. the configured duration has elapsed; and + 2. the viewer has not interacted with the page for ``interaction_postpone`` + seconds (10s by default), measured from the **most recent** interaction. + + So if a viewer is navigating the page during the last seconds of the + configured duration, the advance is pushed 10 seconds past that touch — + and each further touch pushes it again. The link is never pulled out from + under someone who is using it, while an untouched page advances on schedule. + + ``max_dwell`` is an absolute backstop so a wedged browser or a jammed input + device can never park the playlist forever. The watcher also ends the item + early when the browser process disappears. + """ + + def __init__(self, duration, alive_check=None, on_idle=None, on_failed=None, + stop_event=None, min_alive_before_exit_advance=0.0, max_dwell=None, + interaction_postpone=0.0, interaction_debounce=0.5, + interaction_grace=1.0, use_pointer=None, embedded=False, + wait_visible=None, visible_timeout=15.0, launched_at=None, + name='weblink-interaction'): + super().__init__(daemon=True, name=name) + self.duration = max(0.1, float(duration)) + self._alive_check = alive_check or (lambda: True) + self._on_idle = on_idle or (lambda reason: None) + self._on_failed = on_failed or (lambda detail: None) + self._stop_event = stop_event or threading.Event() + self._min_alive = float(min_alive_before_exit_advance) + self._max_dwell = float(max_dwell) if max_dwell else None + self._postpone = max(0.0, float(interaction_postpone)) + self._debounce = max(0.0, float(interaction_debounce)) + self._grace = max(0.0, float(interaction_grace)) + self._embedded = bool(embedded) + self._wait_visible = wait_visible + self._visible_timeout = float(visible_timeout) + self._launched_at = launched_at + # A global pointer tap is only needed when no process can be attributed + # (embedded CEF); on a plain subprocess the raw devices are enough. + if use_pointer is None: + use_pointer = self._embedded + self._use_pointer = bool(use_pointer) + + self._fired = False + self._lock = threading.Lock() + self._input = WebInputSources() + self._postponements = 0 + self._last_interaction_at = None + self._last_counted_at = None + self._visible_ms = None + + # ── Public API ─────────────────────────────────────────────────── + def stop(self): + """Ask the thread to exit without firing ``on_idle`` for being idle.""" + self._stop_event.set() + + def fire(self, reason): + """Finish the item for a non-idle reason (e.g. browser exited).""" + self._fire(reason) + + @property + def stats(self): + """Diagnostics: how often interaction extended the item.""" + with self._lock: + return { + 'postponements': self._postponements, + 'last_interaction_at': self._last_interaction_at, + 'visible_ms': self._visible_ms, + } + + def _fire(self, reason): + with self._lock: + if self._fired or self._stop_event.is_set(): + return + self._fired = True + total = self._postponements + if total: + Logger.info( + f"SignagePlayer: Web link finished ({reason}) after " + f"{total} interaction(s)" + ) + else: + Logger.info(f"SignagePlayer: Web link finished ({reason}) — no interaction") + trace('weblink_end', reason=reason, postponements=total) + self._on_idle(reason) + + # ── Thread body ────────────────────────────────────────────────── + def run(self): + started = time.monotonic() + # `_base_deadline` is fixed; the effective deadline is the base plus one + # postponement per interaction. Keeping the base separate means the + # countdown can be extended repeatedly without drift. + self._base_deadline = started + self.duration + hard_deadline = started + self._max_dwell if self._max_dwell else None + + self._input.open_devices() + if not self._input.devices and not self._use_pointer: + Logger.warning( + "SignagePlayer: Web link interaction watcher — no /dev/input " + f"devices accessible; using a fixed {self.duration:.0f}s timer" + ) + else: + Logger.info( + f"SignagePlayer: Web link interaction watcher on " + f"{len(self._input.devices)} input device(s)" + + (" + pointer tap" if self._use_pointer else "") + + f"; base {self.duration:.0f}s, +{self._postpone:.0f}s per interaction" + ) + + try: + # ── Start-up verification ──────────────────────────────── + # Wait for the browser to actually become visible. Doing this here + # (watcher thread) keeps the Kivy main thread responsive. + if not self._verify_visible(): + return + + while not self._stop_event.is_set(): + exit_reason = self._check_process_exit(started) + if exit_reason: + self._fire(exit_reason) + return + + now = time.monotonic() + if hard_deadline is not None and now >= hard_deadline: + self._fire('max_dwell') + return + + deadline = self._advance_due_at() + if deadline is not None and now >= deadline: + # Both deadlines have passed: the configured duration has + # elapsed AND the viewer has not touched the page for the + # postponement window. Only then advance. + if self._viewer_is_recently_active(now): + # Still inside the settle window after a touch; keep the + # page up and re-evaluate shortly. + continue + self._fire('viewer_idle') + return + + timeout = self._next_poll_timeout(now, deadline, hard_deadline) + + activity = self._input.drain(self._input.select(timeout)) + if self._use_pointer and self._input.pointer_moved(): + activity = True + + if activity: + self._register_interaction(time.monotonic()) + + finally: + self._input.close_devices() + + # ── Start-up verification ──────────────────────────────────────── + def _verify_visible(self): + """Wait for the page to appear; report failure when it never does. + + Returns True when the item may continue, False when it has been + reported as failed (the caller must then return immediately). + """ + if self._wait_visible is None: + return True + try: + visible, detail = self._wait_visible(self._visible_timeout) + except Exception as exc: + visible, detail = False, f'visibility check raised: {exc}' + + if self._stop_event.is_set(): + return False + + if not visible: + Logger.error(f"SignagePlayer: Web link did not become visible ({detail})") + trace('weblink_not_visible', detail=str(detail)) + with self._lock: + if self._fired: + return False + self._fired = True + try: + self._on_failed(str(detail)) + except Exception as exc: + Logger.error(f"SignagePlayer: weblink on_failed handler failed: {exc}") + return False + + if self._launched_at is not None: + self._visible_ms = int((time.monotonic() - self._launched_at) * 1000) + trace('weblink_visible', detail=str(detail), visible_ms=self._visible_ms) + return True + + # ── Interaction handling ───────────────────────────────────────── + def _advance_due_at(self): + """Absolute time at which the player may advance. + + A web link has two independent deadlines and the later one wins: + + * ``_base_deadline`` — the configured duration, so a page nobody touches + is shown for its full slot; + * ``_last_interaction_at + _postpone`` — every interaction buys + ``interaction_postpone`` seconds **from that moment**. + + Because the interaction deadline is measured from the interaction + itself, touching the page during the final seconds of the configured + duration (or at any point) moves the advance 10 seconds into the future. + A viewer who keeps interacting therefore keeps the page on screen, while + an untouched page still advances on time. + """ + base = getattr(self, '_base_deadline', None) + if base is None: + return None + with self._lock: + last = self._last_interaction_at + if last is None or self._postpone <= 0: + return base + return max(base, last + self._postpone) + + def _next_poll_timeout(self, now, deadline, hard_deadline): + """How long to block waiting for input before re-evaluating.""" + timeout = 0.5 + if deadline is not None and now < deadline: + timeout = min(timeout, max(0.02, deadline - now)) + if hard_deadline is not None: + timeout = min(timeout, max(0.02, hard_deadline - now)) + return timeout + + def _register_interaction(self, now): + """Record that the viewer interacted, postponing the advance. + + ``_last_interaction_at`` is always updated, because the interaction + deadline is measured from the most recent event. ``_postponements`` + counts *discrete* interactions (debounced) for logging/diagnostics only + — a drag produces hundreds of raw events but is one interaction. + """ + with self._lock: + self._last_interaction_at = now + if (self._postpone > 0 + and (self._last_counted_at is None + or (now - self._last_counted_at) >= self._debounce)): + self._postponements += 1 + self._last_counted_at = now + if self._postponements == 1 or self._postponements % 5 == 0: + Logger.info( + f"SignagePlayer: Web link interaction #{self._postponements} " + f"— advance postponed {self._postpone:.0f}s " + f"(until {self._postpone:.0f}s after this touch)" + ) + trace('weblink_interaction', + postponements=self._postponements, + extension=self._postpone) + + def _viewer_is_recently_active(self, now): + """True when an interaction happened within the last second. + + A touchscreen emits a burst of events per tap/drag; this keeps the item + on screen through the burst instead of advancing between two samples. + """ + with self._lock: + last = self._last_interaction_at + if last is None: + return False + return (now - last) < max(0.1, self._grace) + + def _check_process_exit(self, started): + """Return a reason string when the browser died, else None. + + A browser that renders inside the Kivy window (CEF) reports alive + without a child process, so ``alive_check`` returning True is honoured. + """ + try: + alive = self._alive_check() + except Exception: + alive = True + if alive: + return None + if time.monotonic() - started < self._min_alive: + # Too early to be a real end-of-item; likely a hand-off or a slow + # start. Wait out the remaining duration instead of skipping. + return None + return 'browser_exited' + + +class ChromiumSubprocessAdapter(WeblinkAdapter): + """Default adapter: a separate Chromium/Chrome process in kiosk mode. + + Used on Linux/Raspberry Pi, and as the fallback engine on Windows when the + embedded CEF browser is unavailable. + """ + + name = 'chromium-subprocess' + + def __init__(self, browser_path=None, extra_flags=(), kiosk=True): + self._proc = None + self._browser = browser_path + self._extra_flags = list(extra_flags or ()) + self._kiosk = kiosk + self._health_grace = MIN_HEALTHY_ALIVE + + @property + def process(self): + return self._proc + + # ── Browser discovery ──────────────────────────────────────────── + @staticmethod + def find_browser(): + for candidate in ('chromium-browser', 'chromium', 'google-chrome', + 'chrome', 'msedge'): + path = shutil.which(candidate) + if path: + return path + return None + + # ── Adapter interface ──────────────────────────────────────────── + def on_before_launch(self, url, width, height): + # Nothing generic to do; the platform layer hooks in here (overlay). + pass + + def launch(self, url, width, height): + browser = self._browser or self.find_browser() + if not browser: + Logger.error("SignagePlayer: No Chromium/Chrome executable found") + return False + + args = [browser] + if self._kiosk: + # --start-fullscreen is a normal maximised window the compositor can + # un-stack; --kiosk requests exclusive fullscreen which prevents + # Labwc from restoring the previous window on close. + args += ['--start-fullscreen', '--start-maximized'] + args += [ + '--app=' + url, + '--noerrdialogs', + '--disable-infobars', + '--incognito', + '--no-first-run', + '--disable-session-crashed-bubble', + '--check-for-update-interval=31536000', + '--password-store=basic', + '--use-mock-keychain', + '--disable-sync', + '--disable-background-networking', + '--no-default-browser-check', + '--window-position=0,0', + f'--window-size={int(width)},{int(height)}', + '--force-device-scale-factor=1', + ] + args += self._extra_flags + + self._proc = subprocess.Popen(args) + return True + + def is_alive(self): + return self._proc is not None and self._proc.poll() is None + + def wait_visible(self, timeout): + """Verify a plain subprocess actually came up. + + A separate browser process cannot be introspected portably, so this + confirms the process survived the health grace period. That catches the + realistic failure modes (missing binary, instant crash, hand-off to a + leaked instance). Platform layers override this to also check the real + window handle (see the Windows adapter). + """ + if self._proc is None: + return False, 'no process' + grace = float(getattr(self, '_health_grace', MIN_HEALTHY_ALIVE)) + grace = min(grace, max(0.5, float(timeout))) + deadline = time.monotonic() + grace + while time.monotonic() < deadline: + if self._proc.poll() is not None: + return False, f'browser exited immediately (rc={self._proc.returncode})' + time.sleep(0.1) + return True, 'process-alive' + + def target_size(self): + try: + from kivy.core.window import Window + width, height = Window.size + width, height = int(width), int(height) + if width >= 1280 and height >= 720: + return width, height + except Exception: + pass + return 1920, 1080 + + def teardown(self): + proc, self._proc = self._proc, None + if proc is None: + return + if proc.poll() is None: + try: + proc.terminate() + try: + proc.wait(timeout=5) + except Exception: + proc.kill() + except Exception as exc: + Logger.debug(f"SignagePlayer: weblink terminate failed (non-fatal): {exc}") + + +class WeblinkSession: + """Owns the whole lifecycle of one web-link item. + + Responsibilities (single owner — no more state scattered across the player): + + * validate the URL, count retries, remember failures + * drive the adapter through launch → health check → idle → teardown + * guarantee that at most one browser exists at any time + * ignore stale callbacks through a monotonically increasing *generation* + * report outcome through callbacks, never by returning "optimistic" success + """ + + def __init__(self, player, settings=None, adapters=None, on_finished=None, + on_failed=None): + self._player = player + self.settings = settings or WeblinkSettings(getattr(player, 'config', None)) + self._adapters = list(adapters or []) + self._on_finished = on_finished + self._on_failed = on_failed + + self._lock = threading.RLock() + self._generation = 0 + self._adapter = None + self._watcher = None + self._stopped = True + self._current_url = '' + self._closed_at = 0.0 + self._finished_reason = '' + + # Public mirror so the rest of the player can keep using the historic + # attribute (many call sites check `self._weblink_proc`). + self.proc = None + + atexit.register(self.shutdown) + + # ── Adapters ───────────────────────────────────────────────────── + def set_adapters(self, adapters): + self._adapters = list(adapters or []) + Logger.debug( + "SignagePlayer: Web link engines -> " + + (', '.join(a.name for a in self._adapters) or 'none') + ) + + @property + def adapters(self): + return list(self._adapters) + + def _resolve_adapters(self): + """Adapters to try, honouring the configured engine preference.""" + if self._adapters: + return self._adapters + return [ChromiumSubprocessAdapter(extra_flags=self.settings.browser_flags)] + + # ── Public state ───────────────────────────────────────────────── + @property + def active(self): + with self._lock: + return self._adapter is not None + + @property + def generation(self): + return self._generation + + def _sync_proc(self): + """Keep ``player._weblink_proc`` in step with the live browser.""" + proc = getattr(self._adapter, 'process', None) if self._adapter else None + self.proc = proc + try: + self._player._weblink_proc = proc + except Exception: + pass + + # ── Lifecycle ──────────────────────────────────────────────────── + def start(self, url, duration): + """Show ``url`` for ``duration`` seconds (idle-based on Linux). + + Returns True when a browser was launched **and verified**, False when + the item must be skipped. ``on_finished`` is fired when the item ends + normally; ``on_failed`` when it could not be displayed. + """ + ok, reason = validate_weblink_url(url) + if not ok: + Logger.warning(f"SignagePlayer: Refusing weblink ({reason}): {url!r}") + trace('weblink_REFUSED', reason=reason) + return False + + url = url.strip() + with self._lock: + self._generation += 1 + generation = self._generation + self._stopped = False + self._current_url = url + self._teardown_locked() + + adapters = self._resolve_adapters() + if not adapters: + Logger.error("SignagePlayer: No web link engine available") + return False + + for attempt, adapter in enumerate(adapters): + with self._lock: + if self._stopped or generation != self._generation: + return False # superseded while we were starting + self._adapter = adapter + + launched = self._launch_and_verify(adapter, url, duration, generation) + if launched: + return True + + # This engine failed — tear it down and let the next one try. + self._teardown_adapter(adapter) + + Logger.error(f"SignagePlayer: All web link engines failed for {url}") + trace('weblink_all_engines_failed', url=url[:80]) + return False + + def _launch_and_verify(self, adapter, url, duration, generation): + """Launch one adapter and verify the page is actually on screen.""" + width, height = self._safe_target_size(adapter) + + try: + adapter.on_before_launch(url, width, height) + except Exception as exc: + Logger.debug(f"SignagePlayer: {adapter.name} on_before_launch failed: {exc}") + + Logger.info(f"SignagePlayer: Opening weblink via {adapter.name}: {url}") + trace('weblink_launch', engine=adapter.name, url=url[:80]) + + started = time.monotonic() + try: + if not adapter.launch(url, width, height): + raise RuntimeError('launch() returned False') + except Exception as exc: + Logger.error(f"SignagePlayer: {adapter.name} launch failed: {exc}") + trace('weblink_launch_failed', engine=adapter.name, error=str(exc)) + self._safe(adapter.on_launch_failed) + return False + + self._sync_proc() + + # ── Verified start ─────────────────────────────────────────── + # Only report success once the browser is genuinely up. This is what + # stops a dead launch, a failed page load or a browser that never paints + # from resetting the error counter and showing a blank screen for the + # whole duration. + # + # The visibility wait runs on the watcher thread (see + # InteractionWatcher), NOT here: this method is called from the Kivy + # main thread, and blocking it would freeze the whole UI. + self._sync_proc() + self._safe(adapter.on_visible) + + trace('weblink_launched', engine=adapter.name) + self._start_watcher(adapter, url, duration, generation, started) + return True + + def _start_watcher(self, adapter, url, duration, generation, launched_at): + """Arm the visibility + interaction watcher for the active weblink. + + Web links are interaction-driven: the page stays up while the viewer is + using it (each interaction postpones the advance) and the player only + moves on once the viewer has stopped for `duration` seconds. + + The watcher also performs the start-up verification: it waits for the + browser to become visible, and if it never does it finishes the item as + failed instead of leaving a blank screen up. + """ + with self._lock: + if self._watcher is not None: + self._watcher.stop() + self._watcher = InteractionWatcher( + duration=duration, + alive_check=adapter.is_alive, + on_idle=lambda reason: self._on_item_end(generation, reason), + on_failed=lambda detail: self._on_item_failed(generation, detail), + min_alive_before_exit_advance=( + 0.0 if adapter.embedded + else self.settings.min_alive_before_exit_advance + ), + max_dwell=self.settings.max_dwell_for(duration), + interaction_postpone=self.settings.interaction_postpone, + interaction_debounce=self.settings.interaction_debounce, + interaction_grace=self.settings.interaction_grace, + embedded=adapter.embedded, + wait_visible=None if adapter.embedded else adapter.wait_visible, + visible_timeout=self.settings.launch_timeout, + launched_at=launched_at, + ) + watcher = self._watcher + watcher.start() + + def _on_item_end(self, generation, reason): + """Watcher callback — runs on the watcher thread.""" + with self._lock: + if generation != self._generation: + trace('weblink_end_ignored_stale', reason=reason, gen=generation) + return + self._generation += 1 # invalidate any further callbacks + self._finished_reason = reason + + Logger.info(f"SignagePlayer: Web link finished ({reason}) — advancing") + trace('weblink_finished', reason=reason) + + # Advance on the main thread. The browser is deliberately NOT closed + # here: the player's transition logic decides how to remove it so the + # next item is rendered underneath first (no desktop flash). It calls + # back into `close()` once that is done. + Clock.schedule_once(lambda dt: self._advance(), 0) + + def _advance(self): + if self._on_finished: + try: + self._on_finished() + except Exception as exc: + Logger.error(f"SignagePlayer: weblink on_finished failed: {exc}") + + def _on_item_failed(self, detail): + """Watcher callback — the page never became visible. + + Runs on the watcher thread. The item is torn down and the player skips + it, instead of sitting on a blank/black screen for the full duration. + """ + with self._lock: + self._generation += 1 + Logger.error(f"SignagePlayer: Web link failed to display ({detail}) — skipping") + trace('weblink_failed', detail=str(detail)) + Clock.schedule_once(lambda dt: self._finish_failed(), 0) + + def _finish_failed(self): + self.close() + if self._on_failed: + try: + self._on_failed() + except Exception as exc: + Logger.error(f"SignagePlayer: weblink on_failed failed: {exc}") + + # ── Teardown ───────────────────────────────────────────────────── + def close(self): + """Stop the browser + watcher. Idempotent and safe from any thread.""" + with self._lock: + self._generation += 1 + self._stopped = True + self._teardown_locked() + + def _teardown_locked(self): + """Tear down the active adapter/watcher. Caller must hold ``_lock``.""" + watcher, self._watcher = self._watcher, None + if watcher is not None: + watcher.stop() + + adapter, self._adapter = self._adapter, None + self._current_url = '' + self._closed_at = time.monotonic() + self._sync_proc() + if adapter is not None: + self._teardown_adapter(adapter) + + def _teardown_adapter(self, adapter): + try: + adapter.cancel_prewarm() + except Exception: + pass + try: + adapter.teardown() + except Exception as exc: + Logger.warning(f"SignagePlayer: {adapter.name} teardown failed: {exc}") + self._sync_proc() + + def shutdown(self): + """Full stop for application exit. Never raises, never blocks long.""" + try: + self.close() + except Exception: + pass + + # ── Pre-warm ───────────────────────────────────────────────────── + def prewarm(self, url): + """Pre-warm the *next* weblink so its transition is fast.""" + if not self.settings.prewarm: + return + ok, _ = validate_weblink_url(url) + if not ok: + return + for adapter in self._resolve_adapters(): + try: + adapter.prewarm(url) + except Exception as exc: + Logger.debug(f"SignagePlayer: {adapter.name} prewarm failed: {exc}") + break # only the preferred engine pre-warms + + def cancel_prewarm(self): + for adapter in self._resolve_adapters(): + try: + adapter.cancel_prewarm() + except Exception: + pass + + # ── Helpers ────────────────────────────────────────────────────── + @staticmethod + def _safe(func, *args): + try: + func(*args) + except Exception as exc: + Logger.debug(f"SignagePlayer: weblink adapter hook failed: {exc}") + + @staticmethod + def _safe_target_size(adapter): + defaults = (1920, 1080) + try: + width, height = adapter.target_size() + width, height = int(width), int(height) + if width >= 320 and height >= 240: + return width, height + except Exception: + pass + return defaults + + def describe(self): + """Short status string for logs/diagnostics.""" + with self._lock: + if self._adapter is None: + return 'idle' + return f'{self._adapter.name} url={self._current_url[:60]}' + + +def build_adapter_hint(config): + """Log which engines the platform layer should provide (diagnostics).""" + engine = WeblinkSettings(config).engine + Logger.debug(f"SignagePlayer: Configured weblink engine preference: {engine}") + + +__all__ = [ + 'ALLOWED_SCHEMES', + 'ChromiumSubprocessAdapter', + 'InteractionWatcher', + 'WebInputSources', + 'WeblinkAdapter', + 'WeblinkSession', + 'WeblinkSettings', + 'validate_weblink_url', +] diff --git a/windows/build.spec b/windows/build.spec index 4269539..0aff137 100644 --- a/windows/build.spec +++ b/windows/build.spec @@ -105,6 +105,11 @@ hidden_imports = [ 'cef_browser', 'win32gui', 'win32con', + # Unified web-link controller (launch / verified visibility / interaction + # watcher / teardown) — imported by main.py and run_win.py + 'weblink_session', + # Windows-native card reader (Raw Input API + LL-hook fallback) + 'win_card_reader', ] # Exclude Linux-only modules