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