From 3ac7f836c4acfdb03d1045d733a7a6546f737fbe Mon Sep 17 00:00:00 2001 From: ske087 Date: Sun, 13 Sep 2026 21:57:49 +0300 Subject: [PATCH] Port the player to Raspberry Pi OS Trixie 64-bit (Linux-only branch) Replaces the Windows port with a Raspberry Pi / Linux implementation on Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows code is removed here but preserved on the Windows-Player branch. Entry point ----------- linux/run_linux.py replaces windows/run_win.py. src/main.py stays platform-neutral; all Pi-specific behaviour is injected from linux/. Five bugs that prevented the port (all measured on real hardware) ---------------------------------------------------------------- 1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh symlinks the system SDL2 over the bundled filename. 2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough, unlike wlopm. This broke every systemd/cron/autostart launch. linux_display.ensure_session_environment() detects and exports it. 3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a different name crashed the player ~20s after a successful start. 4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the display blanked after 10 minutes. 5. The launchers ran src/main.py directly, bypassing every platform patch and resolving the data directory one level too high. Web links --------- - --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it aborts. The platform is now chosen explicitly. - The keyring password prompt is suppressed via the ENVIRONMENT, not the flags: launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot reach gnome-keyring-daemon. - Teardown kills the whole process group (needs start_new_session=True); previously it silently fell back to terminate() and orphaned children. Video normalisation ------------------- A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at 0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip), triggered by resolution only so already-playable files are untouched. src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker makes the player skip the item while it is being rebuilt, then the converted file is played instead. If nothing is playable at all (a single-item playlist whose only video is converting), the player loops the intro video rather than leaving a blank screen. Also fixed ---------- - network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses NetworkManager; ifconfig and dhclient are not even installed). - Removed the Windows-only focus keeper/guardian from main.py. - main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings "Test connection" now uses tempfile.gettempdir(). - config/app_config.json: credentials blanked so a fresh clone runs the first-run setup flow. Verification ------------ linux/test_media_state.py 18/18, test_linux_patches.py 21/21, test_linux_browser_flags.py 27/27. Verified live against a real DigiServer: image -> weblink -> image -> video with correct durations, zero leaked Chromium processes, and no throttling over a 10 minute monitored run. --- ...kiwy-build-and-development.instructions.md | 273 -- .../kiwy-linux-rpi.instructions.md | 253 ++ .gitignore | 43 +- PLAYER_WEBLINK_INTEGRATION.md | 78 +- check_player_status.sh | 3 +- config/app_config.json | 10 +- .../CODE_SIGNING_SMART_APP_CONTROL.md | 108 - linux/RPI_TRIXIE_PORT_PLAN.md | 265 ++ linux/_probe_chromium_footprint.py | 126 + linux/_probe_video.py | 70 + linux/_probe_weblink.py | 92 + linux/development-track.md | 509 ++++ linux/fix_kivy_sdl2.sh | 166 ++ linux/linux_browser.py | 603 +++++ linux/linux_display.py | 425 +++ linux/monitor_player.py | 467 ++++ linux/run_linux.py | 555 ++++ linux/test_linux_browser_flags.py | 180 ++ linux/test_linux_patches.py | 186 ++ linux/test_media_state.py | 171 ++ linux/video_normalizer.py | 545 ++++ media/edited_media/5/eye_e_v2.jpg | Bin 395259 -> 0 bytes run_player.sh | 14 +- src/edit_popup.py | 8 +- src/get_playlists_v2.py | 142 +- src/main.py | 544 ++-- src/media_state.py | 301 +++ src/network_monitor.py | 137 +- src/playback_trace.py | 4 +- src/video_safety.py | 9 +- src/weblink_session.py | 128 +- start.sh | 15 +- stop_player.sh | 6 +- windows/2.3.0 | 34 - windows/README_WINDOWS_BUILD.md | 286 -- windows/_check_syntax.py | 18 - windows/app_icon.ico | Bin 43979 -> 0 bytes windows/archive_list.txt | 317 --- windows/build.spec | 369 --- windows/build_last.txt | 5 - windows/build_win.bat | 158 -- windows/cef_browser.py | 192 -- windows/create_self_signed_cert.ps1 | 110 - windows/development-track.md | 317 --- windows/launch_player.bat | 27 - windows/pyi_runtime_hook.py | 169 -- windows/requirements_win.txt | 51 - windows/run_win.py | 2306 ----------------- windows/sign_exe.ps1 | 156 -- windows/start_player_watchdog.bat | 48 - windows/test_first_run_setup.py | 111 - windows/test_import_fix.py | 39 - windows/test_video_hang.py | 139 - windows/test_watchdog.py | 438 ---- windows/test_webview2_embed.py | 169 -- windows/test_webview2_navigation.py | 186 -- windows/test_webview2_offline.py | 221 -- windows/test_webview2_runtime.py | 106 - windows/verify_sendinput_fix.py | 89 - windows/version_info.txt | 43 - windows/watchdog.ps1 | 441 ---- windows/webview2_browser.py | 718 ----- windows/webview2_runtime.py | 446 ---- .../MicrosoftEdgeWebview2Setup.exe | Bin 1783000 -> 0 bytes .../download_runtime_installers.ps1 | 65 - .../Microsoft.Web.WebView2.Core.dll | Bin 698208 -> 0 bytes windows/webview2_sdk/WebView2Loader.dll | Bin 163680 -> 0 bytes windows/win_card_reader.py | 720 ----- 68 files changed, 5604 insertions(+), 9326 deletions(-) delete mode 100644 .github/instructions/kiwy-build-and-development.instructions.md create mode 100644 .github/instructions/kiwy-linux-rpi.instructions.md delete mode 100644 documentation/CODE_SIGNING_SMART_APP_CONTROL.md create mode 100644 linux/RPI_TRIXIE_PORT_PLAN.md create mode 100644 linux/_probe_chromium_footprint.py create mode 100644 linux/_probe_video.py create mode 100644 linux/_probe_weblink.py create mode 100644 linux/development-track.md create mode 100755 linux/fix_kivy_sdl2.sh create mode 100644 linux/linux_browser.py create mode 100644 linux/linux_display.py create mode 100644 linux/monitor_player.py create mode 100644 linux/run_linux.py create mode 100644 linux/test_linux_browser_flags.py create mode 100644 linux/test_linux_patches.py create mode 100644 linux/test_media_state.py create mode 100644 linux/video_normalizer.py delete mode 100644 media/edited_media/5/eye_e_v2.jpg create mode 100644 src/media_state.py delete mode 100644 windows/2.3.0 delete mode 100644 windows/README_WINDOWS_BUILD.md delete mode 100644 windows/_check_syntax.py delete mode 100644 windows/app_icon.ico delete mode 100644 windows/archive_list.txt delete mode 100644 windows/build.spec delete mode 100644 windows/build_last.txt delete mode 100644 windows/build_win.bat delete mode 100644 windows/cef_browser.py delete mode 100644 windows/create_self_signed_cert.ps1 delete mode 100644 windows/development-track.md delete mode 100644 windows/launch_player.bat delete mode 100644 windows/pyi_runtime_hook.py delete mode 100644 windows/requirements_win.txt delete mode 100644 windows/run_win.py delete mode 100644 windows/sign_exe.ps1 delete mode 100644 windows/start_player_watchdog.bat delete mode 100644 windows/test_first_run_setup.py delete mode 100644 windows/test_import_fix.py delete mode 100644 windows/test_video_hang.py delete mode 100644 windows/test_watchdog.py delete mode 100644 windows/test_webview2_embed.py delete mode 100644 windows/test_webview2_navigation.py delete mode 100644 windows/test_webview2_offline.py delete mode 100644 windows/test_webview2_runtime.py delete mode 100644 windows/verify_sendinput_fix.py delete mode 100644 windows/version_info.txt delete mode 100644 windows/watchdog.ps1 delete mode 100644 windows/webview2_browser.py delete mode 100644 windows/webview2_runtime.py delete mode 100644 windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe delete mode 100644 windows/webview2_runtime/download_runtime_installers.ps1 delete mode 100644 windows/webview2_sdk/Microsoft.Web.WebView2.Core.dll delete mode 100644 windows/webview2_sdk/WebView2Loader.dll delete mode 100644 windows/win_card_reader.py diff --git a/.github/instructions/kiwy-build-and-development.instructions.md b/.github/instructions/kiwy-build-and-development.instructions.md deleted file mode 100644 index 4fb8a03..0000000 --- a/.github/instructions/kiwy-build-and-development.instructions.md +++ /dev/null @@ -1,273 +0,0 @@ ---- -description: "Use when building, compiling, packaging or releasing the Kiwy Signage Player: PyInstaller build, .exe generation, build.spec, hiddenimports, code signing, Smart App Control, Windows development, Raspberry Pi deployment, or adding new modules under src/. Covers the exact build commands, environment constraints, bundling rules and verification steps." -name: "Kiwy Build & Development" ---- - -# Kiwy Signage Player — Build & Development - -Cross-platform Kivy digital signage player. - -- **Raspberry Pi / Linux** — `src/main.py` is the entry point (root `install.sh`, `start.sh`). -- **Windows** — `windows/run_win.py` is the entry point; it sets Windows env vars, imports - `main.py`, then monkey-patches platform differences. Packaged to `.exe` with PyInstaller. - -## Ground Rules - -- **Keep `src/main.py` cross-platform.** Windows-specific behaviour belongs in - `windows/run_win.py` (see `_patch_main()`), Pi-specific behaviour in `main.py` guarded - by capability checks. Do not add Windows-only imports to `main.py`. -- **Rebuild is mandatory.** The `.exe` bundles `src/`, so Python edits are invisible until - you rebuild. There is no hot reload in the packaged app. -- **`dist/` is a runtime folder, not just build output.** It holds `config/`, `media/`, - `playlists/`, `logs/`, `.kiosk-profile/` and `.player_heartbeat`. Never wipe `dist/` - blindly; a `--clean` rebuild preserves it, but a manual delete destroys player state. - -## Environment - -| Item | Value | -|------|-------| -| Interpreter | **Python 3.12.9** (64-bit) — project venv at `windows\venv` | -| Kivy | 2.3.1 | -| PyInstaller | 6.21.0 | -| Linux target | Python 3.13 (`repo/python-wheels/`, `repo/system-packages/`) | - -- **Python 3.13+/3.14 is NOT supported for the Windows build** — Kivy 2.3.1 has no wheels - for them. -- `cefpython3` is **not installed** in the project venv (its wheels stop at Python 3.9). - The embedded-CEF engine in `windows/cef_browser.py` is therefore dormant on this - build — do not rely on it. -- **Web links use embedded WebView2** (`windows/webview2_browser.py`), driven through - `pythonnet` (installed). Two extra things ship with the exe: - - `windows/webview2_sdk/` — ~860 KB of SDK DLLs (`Microsoft.Web.WebView2.Core.dll`, - `WebView2Loader.dll`). **Tracked in git on purpose**: without them WebView2 - silently downgrades to the Chrome/Edge subprocess engine. - - `windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe` — ~1.7 MB bootstrapper, - used to install the Runtime on a machine that lacks it. The ~203 MB offline - standalone installer is **git-ignored**; fetch it with - `windows/webview2_runtime/download_runtime_installers.ps1 -Offline` when you need - to deploy to machines with no internet (it makes the exe ~305 MB). - -## Build - -From `windows\`: - -``` -venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm -``` - -Or the full one-click path (creates venv, installs deps, builds, then signs): - -``` -build_win.bat -``` - -Output (folder mode, via `COLLECT`): - -``` -windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe <- the real build output -``` - -**Deployment hazard:** `windows\dist\KiwySignagePlayer.exe` (one level up) is a stale -leftover from an older single-file build of the same name. It is not refreshed by the -current spec, so deploying it ships old code. Always take the executable from the -`KiwySignagePlayer\` subfolder, and delete the stray copy if it reappears. - -### `build.spec` facts - -- Entry point `run_win.py`; `pathex=[BUILD_DIR, SRC_DIR]` so `src/` modules resolve. -- `runtime_hooks=[pyi_runtime_hook.py]` — sets per-monitor DPI awareness **before** SDL/Kivy - initialise. Keep DPI work here; it must run before any window is created. -- `datas += Tree(src)` bundles all of `src/`. `console=True` so startup errors and the Kivy - log remain visible — do not flip this to `False` without a reason. -- `hiddenimports` must list modules that PyInstaller's static analysis **cannot** see: - lazily imported ones (`cef_browser` is imported inside a function) or dynamic imports - (`getattr`, `importlib`). Top-level imports such as `weblink_session` are found - automatically via `pathex`, but listing them is harmless insurance. - **When you add a module under `src/` that is imported lazily or by string, add it to - `hiddenimports` or the packaged exe will fail at runtime with `ModuleNotFoundError`.** -- `excluded_imports` drops Linux-only packages (`evdev`, `gi`, GStreamer) and the - non-matching `cefpython3` `.pyd` variants. - -## First-run setup (no bundled credentials) - -`config/app_config.json` is **deliberately NOT bundled** into the exe, and -`player_auth.json` is excluded from `Tree(src)`. Shipping either one planted the -build machine's `server_ip` / `screen_name` / `auth_code` into every install, so -a fresh machine silently connected to the wrong server (or played a stale -playlist) instead of asking. - -What happens on a machine with no usable config: - -1. The splash video (`config/resources/intro1.mp4`) plays. -2. A notice appears: *"Player is not configured"*. -3. After `SETUP_NOTICE_SECONDS` (**5 s**, `src/main.py`) the Settings screen - opens automatically. -4. Saving valid values writes `config/app_config.json` next to the **.exe** and - playback starts immediately — no restart needed. - -On a machine that **is** configured, the notice and Settings are skipped and the -cached playlist plays straight away. - -Key functions in `src/main.py`: - -| Name | Role | -|------|------| -| `config_is_configured(config)` | Single source of truth. Missing file, empty/unparseable JSON, missing keys and leftover placeholders (`localhost`, `kivy-player`, `1234567`, `127.0.0.1`) all count as **unconfigured**. | -| `DEFAULT_CONFIG` | Blank-credential starting point, so a fresh install can never look configured. | -| `SignagePlayer.on_intro_finished()` | The one decision point after the splash: setup vs. playback. Both intro paths (video end and "no intro file") go through it. | -| `show_setup_required_notice()` | Shows the notice, then opens Settings after 5 s. | -| `on_first_run_config_saved()` | Re-syncs and starts playback. | - -`config_is_configured()` is covered by `windows/test_first_run_setup.py` — run it -after touching that logic. - -> When adding a new **required** config key, add it to `CONFIG_REQUIRED_KEYS` -> (and to `CONFIG_PLACEHOLDER_VALUES` if it has a placeholder default), or the -> first-run flow will not ask for it. - -## 24/7 Supervision (Windows) - -A signage player must survive crashes and hangs unattended. On **Linux/Pi** this is -`start.sh` (systemd or `./start.sh`). On **Windows** it is `windows/watchdog.ps1`, -launched by `windows/start_player_watchdog.bat`. - -It restarts the player when it: - -- **crashed** — the process disappeared; or -- **hung** — the process is alive but `.player_heartbeat` has gone stale - (the player rewrites it every 10 s; the watchdog treats >60 s as hung). A frozen - player is otherwise indistinguishable from a working one. - -It also backs off if the player cannot stay up (crash-loop breaker: -`CrashLoopMaxFailures` failures in `CrashLoopWindowMin` minutes without ever -becoming healthy → `CrashLoopBackoffMin` minutes of quiet). - -### The stop flag is SESSION-SCOPED - -The only supported way to stop the player is the **exit-screen password**. On -success the player writes `.player_stop_requested` next to its `.exe`, and the -watchdog then **stands down instead of restarting**. - -The flag is cleared **every time the watchdog starts**, which is what makes it -session-scoped: the password exit ends the *current* session, and the next launch -begins a new one. There is no file to delete by hand. - -> **Ordering matters:** the flag is the operationally-correct choice over a -> "remember the last exit" setting, because a reboot must come back up playing. -> Clearing on start also means a power cut cannot leave the player permanently off. - -### Two failure modes that must not be confused - -| Situation | Correct reaction | -|---|---| -| Was healthy, then heartbeat went stale | **Restart promptly** (this is a hang) | -| Running but never became healthy (slow start, bad install, the **first-run setup screen**) | **Be patient**, then restart after `2 × StartupGraceSec` | - -Never kill a player that has not yet been healthy — it may be an operator entering -server settings on the first-run setup screen. - -### Important Windows-specific facts - -- The packaged player is **TWO processes** (PyInstaller bootloader parent + the - child that owns the SDL window). Any kill must use `taskkill /T` or the visible - window survives and the next launch collides with it. -- The watchdog can only *restart* an install that already has a working config. - It cannot help before the player has first run. -- Hiding the console window (`console=False`) is **not** done: the console is the - only way to see a start-up failure, and the build instructions warn against - flipping it without reason. -- **No Windows service.** A service runs in session 0 with no desktop, so the - player could not render to the screen. A login-triggered task is the correct - Windows counterpart to the Pi's systemd unit — that is what - `start_player_watchdog.bat` is for (add it to the Startup folder for autostart). - -### Verify the watchdog - -``` -cd windows -venv\Scripts\python.exe test_watchdog.py # crash + hang + stop-flag + new-session -``` - -Check state at any time (read-only): - -``` -powershell -NoProfile -ExecutionPolicy Bypass -File windows\watchdog.ps1 -Status -``` - -> When testing a hang, do **not** just backdate the heartbeat file's mtime — the -> healthy player rewrites it immediately, so nothing is actually stale and the -> test passes for the wrong reason. Hold the file open with an exclusive Windows -> lock (share mode 0) so the player's own write fails; that reproduces a genuine -> stale heartbeat while the process stays alive. - -## Code Signing (production constraint) - -Production PCs run with **Smart App Control enforced** (`VerifiedAndReputablePolicyState=1`, -UMCI enforced) with **no "Run anyway" bypass** — an unsigned exe is blocked at kernel level. - -- A **self-signed certificate does NOT satisfy Smart App Control.** A cert from a reputable - public CA is required. `create_self_signed_cert.ps1` is dev-only. -- Provide a cert as `KIWY_SIGN_PFX` (+ optional `KIWY_SIGN_PFX_PASSWORD`) or drop - `kiwy_signing.pfx` in `windows\`; `build_win.bat` then signs via `sign_exe.ps1` - (signtool with RFC3161 timestamp when available). -- See `documentation/CODE_SIGNING_SMART_APP_CONTROL.md`. - -## Verify Before Committing - -Fast syntax gate (no build, seconds): - -``` -python -m py_compile src/main.py src/weblink_session.py windows/run_win.py windows/webview2_browser.py windows/webview2_runtime.py -``` - -Web-link engine checks (no build, no player): - -``` -cd windows -venv\Scripts\python.exe test_webview2_embed.py # embeds WebView2 in a bare Win32 window -venv\Scripts\python.exe test_webview2_runtime.py # Runtime detection + installer discovery -``` - -Rebuild warning: the bundled offline installer makes the exe ~305 MB. If you do not -need offline machines, delete -`windows/webview2_runtime/MicrosoftEdgeWebView2RuntimeInstallerX64.exe` before building. - -**Close the running player before rebuilding.** A running -`dist\KiwySignagePlayer\KiwySignagePlayer.exe` locks the output and the build fails with -"Access is denied". Chrome/Edge kiosk processes left over from web-link playback can also -hold locks; if the build fails, check for stray `msedge.exe` / `chrome.exe` before retrying. - -Because `.exe` is a **reserved Windows device name** as well as a real file, use -`Test-Path -LiteralPath` when probing for the executable, otherwise the path silently -resolves to the console device. - -Development run — **use this on a SAC-locked host**, since the unsigned exe cannot launch: - -``` -cd windows -venv\Scripts\activate -python run_win.py -``` - -Diagnose playback transitions via the trace log (`logs\playback_trace.log`), written by -`src/playback_trace.py`. - -## Release Checklist - -1. Bump `PLAYER_VERSION` in `src/main.py` **and** `filevers`/`prodvers`/`FileVersion`/ - `ProductVersion` in `windows\version_info.txt`; keep them in sync. -2. Rebuild (`build_win.bat`). -3. Confirm the exe is signed (`Get-AuthenticodeSignature`); unsigned builds are blocked on - production hosts. -4. Smoke-test a mixed playlist: image → video → weblink → image, verifying durations, audio - (`audio: off` / `muted`), and a clean exit/restart. - -## Commit Hygiene - -- Never commit `windows\build\` or `windows\dist\` (git-ignored). Tracked build output such - as `windows\archive_list.txt` and `windows\build_last.txt` is the exception, not the rule. -- **Do not commit `player_auth.json` or `src/player_auth.json`** — they contain live - credentials (`auth_code`, `player_id`, `server_url`). These are currently tracked; treat any - future change to them as a deliberate, reviewed decision. -- Do not commit `config\app_config.json` values that are host-specific without checking - whether they belong in the repo default. diff --git a/.github/instructions/kiwy-linux-rpi.instructions.md b/.github/instructions/kiwy-linux-rpi.instructions.md new file mode 100644 index 0000000..b70ba7f --- /dev/null +++ b/.github/instructions/kiwy-linux-rpi.instructions.md @@ -0,0 +1,253 @@ +--- +description: "Use when installing, deploying, running or debugging the Kiwy Signage Player on Raspberry Pi OS Trixie 64-bit: venv setup, Kivy/SDL2 Wayland requirements, the linux/ entry point, autostart, systemd, watchdog supervision, or adding new modules under src/. Covers the exact commands, environment constraints and verification steps." +name: "Kiwy Linux / Raspberry Pi Build & Development" +--- + +# Kiwy Signage Player — Linux / Raspberry Pi Build & Development + +Kivy digital signage player targeting **Raspberry Pi OS "Trixie"** (Debian 13, +aarch64, Wayland/labwc). + +- **Entry point** — `linux/run_linux.py`. Not `src/main.py`. +- **Target hardware** — Raspberry Pi 4 / 5. Verified on a Pi 4 Model B Rev 1.4, + kernel `6.18.39+rpt-rpi-v8`. + +## Ground Rules + +- **Never launch `src/main.py` directly.** It is a shared module, not the Pi + entry point. Running it skips every platform patch (session environment, + display keep-awake, Chromium adapter) and resolves the data directory one + level too high. Always go through `linux/run_linux.py` + (`bash run_player.sh` / `bash start.sh`). +- **Keep `src/main.py` platform-neutral.** Pi-specific behaviour belongs in + `linux/`. `src/main.py` may only contain *guarded capability checks*, never a + platform-specific import. +- **A patched method must be resolvable by its own `__name__`.** Kivy's `Clock` + wraps callbacks in a `WeakMethod` keyed on `func.__name__` and re-resolves it + with `getattr(instance, name)`. Assigning a replacement under a different name + than it was defined with raises `AttributeError` **the first time the Clock + fires** — minutes after a successful start, with a traceback that points + nowhere near the patch. Use `_bind_name()`, and cover new patches in + `linux/test_linux_patches.py`. This has already caused two outages. +- **No `sudo` at runtime.** The player runs as an unprivileged user. Anything + needing root must be set up at install time (sudoers entry, udev rule, + systemd unit). + +## Environment + +| Item | Value | +|------|-------| +| OS | Raspberry Pi OS Trixie / Debian 13, aarch64 | +| Session | **Wayland** via `rpd-labwc` (`labwc`). No X server. | +| Python | **3.13.5** — project venv at `.venv` (created `--system-site-packages`) | +| Kivy | 2.3.1 (PyPI wheel) | +| ffpyplayer | 4.5.3 (video/audio backend) | +| evdev | 2.0.0 — **sdist only**, built locally (needs `python3-dev`, `build-essential`) or apt `python3-dev` | +| Browser | `/usr/bin/chromium` (there is no `chromium-browser` on Trixie) | + +### The SDL2 trap (the #1 way to break the install) + +Kivy's PyPI wheel bundles a **private SDL2 without the Wayland driver**: + +``` +Kivy.libs/libSDL2-2-*.so -> x11, KMSDRM, offscreen, dummy, evdev # no wayland! +/usr/lib/.../libSDL2-2.0.so.0 -> x11, wayland, KMSDRM, offscreen, dummy, evdev +``` + +On Trixie there is no X server, so the bundled build cannot create a window: + +``` +[CRITICAL] Unable to find any valuable Window provider. +sdl2 - RuntimeError: b'wayland,x11,dummy not available' +``` + +`linux/fix_kivy_sdl2.sh` symlinks the **system** SDL2 over the bundled +filename. **Re-run it after every `pip install --upgrade kivy`.** +`linux/test_linux_patches.py` asserts the driver is present, so a regression +fails the test suite rather than the player. + +### SDL2 requires `WAYLAND_DISPLAY` to be set + +The socket existing is **not** enough — unlike `wlopm`, SDL2 does not scan +`XDG_RUNTIME_DIR`. A launch from systemd, cron, an autostart entry or SSH has +the variable **empty**, and Kivy then fails with `wayland not available`. +`linux_display.ensure_session_environment()` detects the socket and exports the +name; `run_linux.py` calls it before importing Kivy. + +## Install + +Required system packages: + +```bash +sudo apt install -y \ + python3-venv python3-dev build-essential \ + libsdl2-2.0-0 libsdl2-image-2.0-0 libsdl2-mixer-2.0-0 libsdl2-ttf-2.0-0 \ + libgl1-mesa-dri libgles2 \ + chromium wlopm wlr-randr ffmpeg +``` + +Python dependencies (all have cp313 aarch64 wheels **except** evdev): + +```bash +python3 -m venv --system-site-packages .venv +.venv/bin/pip install kivy ffpyplayer requests bcrypt aiohttp +.venv/bin/pip install evdev # builds from source +bash linux/fix_kivy_sdl2.sh # REQUIRED: system SDL2 with Wayland +``` + +## Run + +```bash +bash run_player.sh # single run, no supervision +bash start.sh # watchdog: auto-restart on crash/hang (24/7) +``` + +`start.sh` supervises through a heartbeat file (`.player_heartbeat`, rewritten +every 10 s; stale after 60 s ⇒ hung ⇒ restart) and honours +`.player_stop_requested` so a password exit is not resurrected. + +## Verify Before Committing + +```bash +.venv/bin/python -m py_compile src/*.py linux/*.py # syntax, seconds +bash -n start.sh run_player.sh stop_player.sh check_player_status.sh + +.venv/bin/python linux/test_linux_patches.py # expect: 21/21 passed +.venv/bin/python linux/test_linux_browser_flags.py # expect: 27/27 passed +.venv/bin/python linux/test_media_state.py # expect: 18/18 passed +bash linux/fix_kivy_sdl2.sh --check # expect: STATUS: fixed +.venv/bin/python linux/_probe_video.py # video decodes + advances +.venv/bin/python linux/_probe_chromium_footprint.py https://example.com/ # PSS per profile +``` + +**Close the running player before editing `src/`** if you intend to test +immediately — the watchdog will restart it and you will test stale code. + +## 24/7 Supervision + +`start.sh` restarts the player when it: + +- **crashed** — the process disappeared; or +- **hung** — the process is alive but the heartbeat is stale (>60 s). + +Two failure modes must not be confused: + +| Situation | Correct reaction | +|---|---| +| Was healthy, then heartbeat went stale | **Restart promptly** (a hang) | +| Running but never became healthy (slow start, the first-run setup screen) | **Be patient** — do not kill a player an operator is configuring | + +Diagnostics for playback transitions live in `logs/playback_trace.log`, +written by `src/playback_trace.py` independently of Kivy's log level. + +## Runtime pitfalls + +- **`~/dev/...` cwd matters.** The player's data directory is the repo root. + Launch from there (all provided scripts do). +- **The desktop blanker fights the player.** `~/.config/labwc/autostart` ships + `swayidle -w timeout 600 'wlopm --off *'`, which powers the panel off after + 10 minutes. `linux_display.neutralise_idle_blanker()` stops it at start-up. + Do not remove that call. +- **`tvservice`, `xdotool`, `ydotool` do not exist on Trixie.** Any code using + them silently does nothing. +- **Web links need a dedicated `--user-data-dir`.** Without it Chromium hands + the URL to a running instance, our process exits in ~2 s and the item is + skipped as a failed launch. +- **The keyring prompt is suppressed via the ENVIRONMENT, not the flags.** + `--password-store=basic` alone does not stop Chromium asking for the login + keyring password: it inherits `DBUS_SESSION_BUS_ADDRESS`, reaches the running + `gnome-keyring-daemon` and prompts. `LinuxChromiumAdapter.launch_env()` strips + the bus for the child process, which is what actually fixes it. Do not remove + `launch_env()`, and do not add a flag list that nothing references — + `test_linux_browser_flags.py` fails if a list becomes dead code, because that + is exactly how this bug hid before. +- **`--ozone-platform-hint=auto` does not work on Chromium 152.** It aborts with + "Missing X server" instead of falling back to Wayland; `--ozone-platform=wayland` + must be stated explicitly. +- **Kill the whole process group on teardown.** `proc.terminate()` leaves + Chromium's GPU/zygote/renderer children behind; they accumulate until the Pi + runs out of memory. This needs `start_new_session=True` at launch. +- **Measure memory with PSS, never RSS.** Chromium shares pages across its + processes, so summing RSS roughly doubles the figure and even ranks smaller + configurations as larger. `linux/_probe_chromium_footprint.py` does it correctly. +- **`--disable-gpu` increases memory** for a real page (1038 MB vs 513 MB). It is + intentionally not used anywhere. + +## Video: 4K cannot play, and is converted automatically + +**A 4K video will not play on a Pi 4.** ffpyplayer decodes in software and there +is no hardware H.264 decode in its pipeline. Measured: 1080p decodes at 3.03× +realtime, 3840×2160 at **0.90×** — below 1× the picture simply stops. + +The player therefore normalises oversized media to at most 1920×1080: + +* `src/media_state.py` — the shared on-disk contract (markers, path resolution, + and a dependency-free MP4 header parser so the playback path spawns nothing). +* `linux/video_normalizer.py` — the converter (hardware `h264_v4l2m2m`, ~31 s + for an 18 s 4K clip; `libx264` fallback). +* Triggered automatically from `get_playlists_v2.normalize_oversized_media()` + after every sync, in both the "updated" and "up-to-date" branches. + +| Marker next to the media | Meaning | +|---|---| +| `.kiwy-converting` | in flight → the player **skips** the item | +| `_kiwy1080p.mp4` + `.kiwy-normalized.json` | done → the player plays **this** file | + +Do not remove these markers, and do not let `delete_unused_media()` prune them — +the converted output is deliberately absent from the playlist and would +otherwise be deleted while the player is using it. + +The conversion is only triggered by **resolution**. A file within 1920×1080 is +left byte-identical. When nothing is playable yet (a single-item playlist whose +only video is still converting), the player loops `config/resources/intro1.mp4` +rather than showing a blank screen. + +Run it manually: + +```bash +.venv/bin/python linux/video_normalizer.py --dry-run media/ # report only +.venv/bin/python linux/video_normalizer.py media/ # convert +.venv/bin/python linux/video_normalizer.py --max-height 720 media/ +``` + +> Prefer normalising on the **server** before upload: it avoids both the 4K +download and the 31 s conversion. The player-side path is a safety net, not the +preferred workflow. + +## Diagnostics + +| Command | Purpose | +|---------|---------| +| `.venv/bin/python linux/linux_display.py` | Wayland/X11 status, outputs, available tools | +| `bash linux/fix_kivy_sdl2.sh --check` | Which SDL2 Kivy loads and its drivers | +| `bash check_player_status.sh` | Is the player running | +| `bash stop_player.sh` | Stop player + watchdog | + +Escape hatches: + +| Variable | Effect | +|----------|--------| +| `KIWY_DISPLAY_TOOLS_DISABLED=1` | Disable `wlopm`/`vcgencmd`/`swayidle` work | +| `KIWY_CHROMIUM_MODE=light` | Default footprint profile (safe reductions) | +| `KIWY_CHROMIUM_MODE=minimal` | Adds `--single-process`; ~15% less memory, less stable | +| `KIWY_CHROMIUM_MODE=safe` | No footprint flags at all (when debugging) | +| `KIWY_VENV=/path` | Point the SDL2 fix script at another virtualenv | + +## Commit Hygiene + +- Never commit `.venv/`, `.kivy/`, `logs/`, `.kiosk-profile/` (git-ignored). +- **Do not commit `player_auth.json`** — it holds live credentials + (`auth_code`, `player_id`, `server_url`). +- Do not commit `config/app_config.json` values that are host-specific + (`server_ip`, `screen_name`, `quickconnect_key`). The repo default is blank on + purpose so a fresh install runs the first-run setup flow. + +## Release Checklist + +1. Bump `PLAYER_VERSION` in `src/main.py`. +2. Run the verification suite above on the target Pi. +3. Confirm `bash linux/fix_kivy_sdl2.sh --check` reports `fixed`. +4. Smoke-test a mixed playlist: image → video → weblink → image, verifying + durations, audio (`audio: off` / `muted`) and a clean exit/restart. +5. 24/7 soak (≥12 h): check the heartbeat, `pgrep -c chromium` for leaks and + memory growth. diff --git a/.gitignore b/.gitignore index acbf53f..871d0c1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,12 +25,16 @@ wheels/ venv/ ENV/ env/ -windows/venv312/ # Kivy *.pyc # Media files (optional - remove if you want to track media) +# +# Coverage matters here: `media/*.mp4` matches files directly in media/, but NOT +# media/edited_media/1/foo.jpg. Playlist content is downloaded per-site and must +# never be committed, so the whole tree is ignored and only the directory +# structure (via .gitkeep) is tracked. media/*.jpg media/*.jpeg media/*.png @@ -41,9 +45,20 @@ media/*.avi media/*.mkv media/*.mov media/*.webm +media/**/*.jpg +media/**/*.jpeg +media/**/*.png +media/**/*.gif +media/**/*.bmp +media/**/*.mp4 +media/**/*.avi +media/**/*.mkv +media/**/*.mov +media/**/*.webm -# Playlists cache (auto-generated) +# Playlist cache (auto-generated from the server per device) playlists/server_playlist_*.json +playlists/server_playlist.json # Logs *.log @@ -72,9 +87,6 @@ logs/startup_crash.log logs/console_out.txt logs/console_err.txt logs/watchdog_test_*.txt -logs/.webview2_install_attempt - -windows/venv_build/ # Player credentials — live auth_code/player_id/server_url. Never commit these: # a bundled copy made fresh builds boot "already authenticated" against an old @@ -82,20 +94,15 @@ windows/venv_build/ player_auth.json src/player_auth.json working_files/player_auth.json -windows/dist/*/player_auth.json -# Runtime web-engine profiles (browser cache, not source) +# Runtime browser profile (cache, not source). The player launches Chromium with +# a dedicated --user-data-dir so it never touches the operator's own profile. +# The wildcard covers probe/test profiles (e.g. .kiosk-profile-probe), which +# otherwise get picked up as untracked files and are several MB of cache each. .kiosk-profile/ +.kiosk-profile-*/ .webview2-profile/ -# NOTE: windows/webview2_sdk/ IS tracked on purpose — build.spec bundles those -# DLLs, so a fresh clone must have them or web links silently fall back to the -# Chrome/Edge subprocess engine. Only ~860 KB (2 files). - -# The small WebView2 Runtime bootstrapper (~1.7 MB) IS tracked: build.spec -# bundles it so a machine without the Runtime can install it on first start. -# The ~203 MB offline standalone installer is NOT tracked — fetch it with -# windows/webview2_runtime/download_runtime_installers.ps1 when you need to -# build for machines that have no internet. -windows/webview2_runtime/MicrosoftEdgeWebView2RuntimeInstaller*.exe -!windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe +# Monitoring output (generated per run, not source) +logs/monitor*.csv +logs/monitor*.log diff --git a/PLAYER_WEBLINK_INTEGRATION.md b/PLAYER_WEBLINK_INTEGRATION.md index 9ba7b53..56b4490 100644 --- a/PLAYER_WEBLINK_INTEGRATION.md +++ b/PLAYER_WEBLINK_INTEGRATION.md @@ -5,11 +5,10 @@ This document describes how the **Kiwy-Signage player** playlist item type (display a live web page / URL instead of an uploaded media file). -> **Status: implemented.** The player supports `weblink` items on both -> Raspberry Pi (`chromium` subprocess) and Windows (embedded **WebView2**, with -> the CEF and Chrome/Edge subprocess engines as fallbacks). Sections 1–4 -> describe the original design plan; section 6 documents the shipped -> architecture and the interaction model. +> **Status: implemented.** The player supports `weblink` items on +> Raspberry Pi / Linux via a Chromium kiosk subprocess. +> Sections 1–4 describe the original design plan; section 6 documents the +> shipped architecture and the interaction model. --- @@ -207,7 +206,8 @@ Recommended options, in order of robustness: - On Wayland/X11 the player already sets `SDL_VIDEODRIVER`; verify Chromium launches on the same display/session. -2. **Embedded web view widget** (`kivy_garden.webview`, WebKit/GTK, or WebView2). +2. **Embedded web view widget** (`kivy_garden.webview`, WebKit/GTK, or a + platform WebView) — renders inside the Kivy window. Cleaner UX (stays inside the Kivy widget tree) but fragile and poorly supported on Pi/Wayland — only pursue if option 1 is unacceptable. @@ -258,55 +258,59 @@ platform: |--------------------------|----------------| | `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. `extra_launch_args()` lets a subclass add browser flags without copying `launch()`. | -| `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`; on Windows the Chrome/Edge fallback). | +| `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`). | | `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 engines). | -| `webview2_browser.py` | **Windows, preferred**: embeds WebView2 as a child HWND of the Kivy window. | -| `webview2_runtime.py` | **Windows**: detects the WebView2 Runtime and installs it silently when missing. | +| `WebInputSources` | Reads `/dev/input/event*` to detect viewer interaction. | +| `linux_browser.py` | **Raspberry Pi / Linux**: `LinuxChromiumAdapter` — Chromium kiosk on Wayland (labwc). | -Platform wrappers inject their engines through +Platform entry points inject their engine through `SignagePlayer.weblink_adapter_factory`: -* **Raspberry Pi / Linux** — built-in Chromium subprocess adapter. -* **Windows** (`windows/run_win.py`) — engines are tried in this order: +* **Raspberry Pi / Linux** (`linux/run_linux.py`) — injects + `LinuxChromiumAdapter`, which adds the flags Trixie needs: - | Order | Engine | Renders | Notes | - |-------|--------|---------|-------| - | 1 | **WebView2** (`webview2_browser.py`) | child window **inside** Kivy | Preferred. No subprocess, so no background/z-order/hand-off/leak problems. | - | 2 | CEF (`cef_browser.py`) | child window inside Kivy | Dormant: `cefpython3` has no wheels past Python 3.9. | - | 3 | Chrome/Edge subprocess | separate window | Last resort only; retains the old drawbacks. | + | Flag | Why | + |------|-----| + | `--kiosk` | What actually makes labwc give the window exclusive fullscreen. Windows needed `--start-maximized` instead; that is not sufficient here. | + | `--user-data-dir=<.kiosk-profile>` | **Mandatory.** Without it Chromium hands the URL to an already-running instance, the process we launched exits in ~2 s and the item is skipped as a failed launch. Also guarantees we never touch the operator's own browser profile. | + | `--ozone-platform-hint=auto` | Lets Chromium pick Wayland when available and fall back to X11/XWayland. | + | `--autoplay-policy=no-user-gesture-required` | Signage pages play media without a click. | + + Teardown kills the **whole process group** (`os.killpg`). `proc.terminate()` + only reaps the parent, leaving Chromium's GPU/zygote/renderer children behind; + across a 24/7 playlist those accumulate until the Pi runs out of memory. `WeblinkAdapter.extra_launch_args()` is the hook subclasses use to add flags -without duplicating `launch()` — the Chrome adapter uses it for +without duplicating `launch()` — the Linux adapter uses it for `--user-data-dir` + `--kiosk`. > **Do not give the factory a class-level `None` default combined with an > unconditional instance assignment.** `SignagePlayer.__init__` originally set > `self.weblink_adapter_factory = None`, which shadowed the class attribute the -> Windows wrapper installs — so the platform adapters were silently ignored and -> every weblink fell back to the generic adapter and failed. It now only sets +> platform entry point installs — so the platform adapters were silently ignored +> and every weblink fell back to the generic adapter and failed. It now only sets > the instance attribute when the class attribute is absent. -### 6.2 Windows: WebView2 Runtime +### 6.2 Linux: Chromium kiosk on Wayland -WebView2 is two separate things, and they ship differently: +Web links on Raspberry Pi OS Trixie use a single dedicated `chromium` process +launched in kiosk mode with its own profile directory: -* the **SDK** (`Microsoft.Web.WebView2.Core.dll`, `WebView2Loader.dll`) — the - API surface, bundled in the exe from `windows\webview2_sdk\` (~860 KB); -* the **Runtime** (`msedgewebview2.exe`) — the actual Chromium engine, shipped - by Microsoft and **verified/installed at start-up** by - `windows\webview2_runtime.py`. +* `--kiosk` gives an exclusive-fullscreen window under the **labwc** compositor; +* `--user-data-dir=/.kiosk-profile` guarantees a *fresh, trackable* browser + instead of a hand-off to a running instance; +* `--ozone-platform-hint=auto` selects Wayland natively and falls back to X11. -If the Runtime is absent the player runs an installer silently -(`/silent /install`) and **unelevated**, which produces a *per-user* install and -therefore never raises a UAC prompt on the signage display. A ~1.7 MB online -bootstrapper is bundled by default; a ~203 MB offline standalone installer can -be bundled instead (see `windows\webview2_runtime\download_runtime_installers.ps1`) -for machines with no internet. +Because a separate process cannot be introspected portably, visibility is +verified by combining "the process survived the health grace period" with a +best-effort check that the PID actually owns a Wayland/X11 socket. A launch that +dies sooner than `min_healthy_alive` is treated as a **failed launch** (hand-off, +missing binary, instant crash) rather than a finished item, so the playlist +never skips a weblink silently. -Install success is decided by **re-reading the installed version**, not by the -installer exit code — Edge Update returns a non-zero HRESULT (e.g. -`-2147219416`) when the Runtime is already current, which is not a failure. +Stale `SingletonLock`/`SingletonSocket` files left by a crashed Chromium are +cleared before each launch: our profile is private to the player, so removing +the lock is always safe and prevents Chromium refusing to start. ### 6.1 Interaction model — web links are not passive media diff --git a/check_player_status.sh b/check_player_status.sh index 3ef6b69..e8d05f3 100755 --- a/check_player_status.sh +++ b/check_player_status.sh @@ -22,7 +22,8 @@ if [ -f "$STOP_FLAG_FILE" ]; then fi # Check if player process is running -PLAYER_PID=$(pgrep -f "python3 main.py" | head -1) +# Matches the linux/run_linux.py entry point (src/main.py is only a module). +PLAYER_PID=$(pgrep -f "run_linux.py" | head -1) if [ -z "$PLAYER_PID" ]; then echo "Status: ❌ NOT RUNNING" diff --git a/config/app_config.json b/config/app_config.json index a9e7832..9addf0d 100644 --- a/config/app_config.json +++ b/config/app_config.json @@ -1,8 +1,8 @@ { - "server_ip": "192.168.0.110", - "port": "80", - "screen_name": "DESKTOP-NJLBQKH", - "quickconnect_key": "8887779", + "server_ip": "", + "port": "8080", + "screen_name": "", + "quickconnect_key": "", "orientation": "Landscape", "touch": "True", "max_resolution": "1920x1080", @@ -20,6 +20,6 @@ "max_dwell_factor": 6.0, "min_max_dwell": 300, "launch_timeout": 15, - "prewarm": true + "prewarm": false } } \ No newline at end of file diff --git a/documentation/CODE_SIGNING_SMART_APP_CONTROL.md b/documentation/CODE_SIGNING_SMART_APP_CONTROL.md deleted file mode 100644 index 4920a9a..0000000 --- a/documentation/CODE_SIGNING_SMART_APP_CONTROL.md +++ /dev/null @@ -1,108 +0,0 @@ -# Kiwy Signage Player — Code Signing & Smart App Control (Production) - -> **TL;DR:** If production PCs have **Smart App Control (SAC) ON** and you -> cannot disable it, the player `.exe` **must be signed by a certificate from a -> reputable public CA**. There is no other way — SAC blocks unsigned binaries at -> the kernel level (no "Run anyway" button). Self-signed certs and Defender -> exclusions do **not** satisfy SAC. - ---- - -## 1. Why Smart App Control blocks the app - -- SAC (Windows 11 22H2+, "Smart App Control" in **Windows Security → App & - browser control**) only runs apps that are **signed by a reputable publisher**. -- Your locally-built `KiwySignagePlayer.exe` is **unsigned** - (`Get-AuthenticodeSignature` → `NotSigned`), so SAC refuses to launch it and - shows "An Application Control policy has blocked this file." -- Unlike classic SmartScreen, SAC has **no "Run anyway" button** and cannot be - bypassed per-file. Disabling SAC is **permanent** and only possible with admin - rights — so it is not viable for locked-down production PCs. - ---- - -## 2. The solution for production: a real code-signing certificate - -1. **Buy an OV code-signing certificate** from a reputable CA, e.g.: - - Sectigo Code Signing - - SSL.com Code Signing - - DigiCert Code Signing - - GlobalSign Code Signing - OV is sufficient for SAC; EV gives the highest trust level. Cost is roughly - USD 100–300/yr. The CA will issue a `.pfx`/`.p12` (or `.cer`+key). - -2. **Sign the exe** after each build. Place your pfx at - `windows\kiwy_signing.pfx` (or set `KIWY_SIGN_PFX` env var) — `build_win.bat` - will then auto-sign via `sign_exe.ps1`: - - ```powershell - # One-off, from the windows\ folder: - .\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -CertPassword "yourpwd" - ``` - - The script: - - locates `signtool.exe` (Windows SDK) — install with - `winget install Microsoft.WindowsSDK.10.0.26100` if missing, - - signs with **SHA256** + **RFC3161 timestamp** (required for SAC and to - keep the signature valid after the cert expires), - - verifies the result with `Get-AuthenticodeSignature`. - -3. **Test** — confirm on one production PC: - ```powershell - Get-AuthenticodeSignature "dist\KiwySignagePlayer\KiwySignagePlayer.exe" - # Status must be: Valid - ``` - ---- - -## 3. Dev / test machines (where you have admin rights) - -If a test PC has SAC **off**, you can make the app trusted locally without -buying a cert: - -```powershell -# Run as Administrator -.\create_self_signed_cert.ps1 -``` - -This creates a self-signed code-signing cert, exports `kiwy_dev_signing.pfx`, -and installs it into **Trusted Root + Trusted Publisher + Trusted People** for -the current user, so the player runs without SmartScreen/Defender prompts on -that dev PC. - -⚠️ **This does NOT satisfy SAC.** It is only for machines where SAC is off or -where you have admin rights. - ---- - -## 4. Build → sign → verify workflow - -```bat -:: 1. Build (produces dist\KiwySignagePlayer\KiwySignagePlayer.exe) -cd windows -venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm - -:: 2. Sign (auto if kiwy_signing.pfx present, else manual) -.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -CertPassword "..." - -:: 3. Verify -Get-AuthenticodeSignature "dist\KiwySignagePlayer\KiwySignagePlayer.exe" -``` - -`build_win.bat` now does step 1 + step 2 automatically when a pfx is present. - ---- - -## 5. Important caveats - -- **Timestamping is mandatory.** The sign script timestamps by default - (`http://timestamp.digicert.com`). Without a timestamp, the signature becomes - invalid once the certificate expires and SAC will block the app. -- **SAC reputation takes time.** Even a validly signed exe from a brand-new - certificate may be blocked until the CA's reputation builds. EV certificates - and well-known CAs (DigiCert, Sectigo, SSL.com) pass immediately. -- **Re-sign after every build.** PyInstaller creates a new exe each time; the - old signature is lost. The auto-sign step in `build_win.bat` handles this. -- **Do not use UPX** on the signed exe — it invalidates the signature and can - trigger false positives. (`upx=True` in the spec currently does nothing - because UPX is not installed; if you ever install UPX, set it to False.) diff --git a/linux/RPI_TRIXIE_PORT_PLAN.md b/linux/RPI_TRIXIE_PORT_PLAN.md new file mode 100644 index 0000000..682a2d7 --- /dev/null +++ b/linux/RPI_TRIXIE_PORT_PLAN.md @@ -0,0 +1,265 @@ +# Kiwy Signage Player — Raspberry Pi OS "Trixie" 64-bit Port Plan + +> Branch: `Linux-RPI-Player` (created from `Windows-Player` HEAD `f437aba`) +> Status: **Phase 1 COMPLETE — player runs on the Pi.** See `development-track.md` +> for measured results, the five blocking bugs and how they were fixed. +> Date: 2026-09-13 + +## Progress snapshot + +| Phase | State | +|-------|-------| +| 0 — Baseline | ✅ folded into Phase 1 | +| 1 — Runtime bring-up | ✅ **done** — window, video, heartbeat, clean 60 s run | +| 1b — Windows removal | ✅ **done** — branch is now Linux-only (see `development-track.md`) | +| 2 — Web links | 🟡 adapter written, needs end-to-end verification on labwc | +| 3 — Networking & card reader | 🟡 `nmcli` restart implemented; card reader pending | +| 4 — Install & autostart | ⬜ | +| 5 — 24/7 validation | ⬜ | + +**Three findings changed the plan materially** (details in `development-track.md`): + +1. Kivy's PyPI wheel bundles an SDL2 **without** the wayland driver → no window + on Trixie. Fixed by `linux/fix_kivy_sdl2.sh`. (Open question: apt + `python3-kivy` may avoid this entirely.) +2. SDL2 needs `WAYLAND_DISPLAY` **set**; the socket alone is not enough, which + breaks every systemd/cron/autostart launch. +3. Kivy's `Clock` re-resolves callbacks by `func.__name__`; a mismatched patch + crashes the app ~20 s after a *successful* start. + +--- + +## 1. Executive summary + +The Windows port works because `windows/run_win.py` (2306 lines) monkey-patches +`src/main.py` at runtime and injects Windows-specific web-link adapters into the +existing `WeblinkSession` abstraction. The **`src/` core is already +cross-platform by design** — that is the single most important finding: + +* `src/weblink_session.py` already ships a working Linux path + (`ChromiumSubprocessAdapter` is the default adapter). +* `src/network_monitor.py` already has `IS_WINDOWS` branching. +* `src/video_safety.py` patches Kivy's `VideoFFPy` provider — the same provider + used on the Pi. + +So this is **not a rewrite**. It is: + +1. **A new `linux/` companion to `windows/`** — an entry point that injects the + Linux adapter + platform behaviour (mirrors `run_win.py`, keeps `main.py` clean). +2. **Repairing the existing Linux code paths** that were never validated against + Trixie (Wayland/labwc, NetworkManager, no `tvservice`, no `xdotool`). +3. **Fixing the install/deployment layer**, which is currently broken for + Trixie/aarch64 (empty offline package repo, wrong wheel platform, broken + shebangs, boot-time `sudo` calls). + +### Verified environment (measured on the target device) + +| Item | Value | +|------|-------| +| OS | Debian GNU/Linux 13 (trixie) | +| Model | Raspberry Pi 4 Model B Rev 1.4 | +| Kernel | `6.18.39+rpt-rpi-v8` aarch64 | +| Python | **3.13.5** (system) | +| Session | `Type=wayland`, `Desktop=rpd-labwc` (**labwc**, not Wayfire/X11) | +| Compositor | `/usr/bin/labwc`; user autostart file exists at `~/.config/labwc/autostart` | +| Browser | `/usr/bin/chromium` — **Chromium 152.0.7977.82** | +| Kivy | not installed; apt has `python3-kivy 2.3.1-1+b1`; PyPI has a + `cp313 manylinux_2_17_aarch64` wheel (verified downloadable) | +| ffpyplayer / evdev | not installed; apt has `python3-evdev 1.9.1-1` | +| Display power | `wlopm` present; **`tvservice`, `xdotool`, `ydotool` MISSING** | +| Networking | **NetworkManager active**; `dhcpcd`/`ifupdown` inactive | +| `sudo` | **requires a password** (`sudo -n` fails) | +| `repo/python-wheels/` | **EMPTY (0 files)** — offline install cannot work today | + +--- + +## 2. Compatibility matrix + +Legend: 🔴 breaks on Pi · 🟡 works but wrong/poor · 🟢 already fine + +| # | Component | Windows assumption | Trixie reality | Action | +|---|-----------|--------------------|----------------|--------| +| 1 | Entry point | `run_win.py` patches everything | no Linux equivalent — `main.py` used bare | 🟡 create `linux/run_linux.py` | +| 2 | Web-link engine | Chrome/Edge subprocess + WebView2/CEF | Chromium subprocess (default adapter) | 🟡 dedicated Linux adapter with `--kiosk`/Ozone flags | +| 3 | Web-link launch flags | `--start-fullscreen --start-maximized`, DPI-aware sizing | labwc honours `--kiosk`; needs `--ozone-platform=wayland` | 🔴 Linux-specific flag set | +| 4 | Browser hand-off leak | fixed by `--user-data-dir` | same failure mode exists | 🔴 add `--user-data-dir` on Linux too | +| 5 | Desktop flash on transition | `_Win32Overlay` + `_Win32Backdrop` | no Win32; wayland compositor owns stacking | 🔴 needs Kivy-side black cover + labwc rule | +| 6 | Window raise/focus | `SetForegroundWindow`, `AttachThreadInput` | `Window.raise_window()` is ~no-op on Wayland | 🟡 focus guardian is harmless no-op; disable on Linux | +| 7 | Keep display awake | `SetThreadExecutionState`, screensaver API | `signal_screen_activity()` shells out to X11 tools | 🔴 rewrite for `wlopm` (see #8) | +| 8 | `signal_screen_activity()` | — | runs `tvservice`(missing), `xdotool`(missing), `xset`/`xrandr`(no X DPMS), `ydotool`(missing), `wlopm` with a **buggy `\*` escape** | 🔴 rewrite, keep-awake via `wlopm --on '*'` | +| 9 | Idle/screensaver conflict | n/a | `~/.config/labwc/autostart` runs `swayidle -w timeout 600 'wlopm --off *'` → fights the player | 🔴 disable/neutralise for kiosk | +| 10 | GL backend | `angle_sdl2` | Mesa/V3D; `gl`/`gles`/`sdl2` | 🟡 set correct backend in `run_linux.py` | +| 11 | Audio driver | `directsound` | PipeWire (ALSA-compat / Pulse) | 🟡 `alsa,pulse,dummy`; a **duplicate `SDL_AUDIODRIVER` line** makes the 2nd a no-op | +| 12 | Card reader | Raw Input + `win_card_reader.py` | `evdev` (native path already in `main.py`) | 🟡 install `python3-evdev`; udev/permissions rule | +| 13 | Interaction watcher | `GetCursorPos` pointer tap | `/dev/input/event*` raw fds (already implemented) | 🟡 needs `input` group perms (user `pi` already in `input`) | +| 14 | Offline install | n/a | `repo/python-wheels/` empty; `download_offline_packages.sh` uses `--platform linux_armv7l` (**32-bit**) | 🔴 regenerate for `aarch64` / cp313 | +| 15 | `install.sh` shebang | n/a | literal `#\!/bin/bash` (escaped `!`) → not directly executable | 🔴 fix (3 places incl. heredocs) | +| 16 | `install.sh` deps | n/a | installs `libsdl2-dev`/`libav*-dev` build toolchain; apt `python3-kivy` exists | 🟡 prefer apt Kivy + wheels | +| 17 | `install.sh` autostart | n/a | writes *only* XDG `.desktop`; labwc uses `~/.config/labwc/autostart` | 🔴 add labwc hook + systemd unit | +| 18 | `start.sh` display setup | n/a | `configure_display_resolution()` writes `/boot/config.txt` (**wrong path** on Trixie → `/boot/firmware/config.txt`) and calls `xrandr`/`tvservice`; `sudo` will **prompt at boot** | 🔴 remove/repair — boot-hang risk | +| 19 | WiFi restart | `netsh wlan` | `rfkill`/`ifconfig`/`dhclient` — `ifconfig`+`dhclient` **not installed**, all `sudo` → password prompt | 🔴 move to `nmcli`, passwordless sudoers | +| 20 | `setup_wifi_control.sh` | n/a | allows `/sbin/ifconfig`, `/sbin/dhclient` — **paths don't exist** | 🔴 rewrite for Trixie | +| 21 | Orientation / `max_resolution` | `Window.size` constraint | fullscreen ignores `Window.size`; rotation is a compositor concern | 🔴 implement via `wlr-randr`/cmdline | +| 22 | `src/signageplayer.ini` | — | **never loaded by any code** (dead file) | 🟡 delete or wire up | +| 23 | `prewarm` weblink browser | desktop CPU budget | 2 extra Chromium processes on a Pi 4 | 🟡 default `prewarm:false` on Pi | +| 24 | Watchdog / 24-7 | `watchdog.ps1` | `start.sh` exists and is sound (heartbeat + stop flag) | 🟢 keep, with fixes from #18 | + +--- + +## 3. Proposed architecture + +Mirror the existing, proven Windows pattern. Keep `src/main.py` cross-platform +per the repo ground rules — **no Linux-only imports added to `main.py`**. + +``` +src/ # shared, cross-platform core (unchanged philosophy) + main.py # shared application; PI patches live in linux/ + weblink_session.py # WeblinkSession + ChromiumSubprocessAdapter (default) +linux/ # NEW — the Linux counterpart of windows/ + run_linux.py # ✅ entry point: env, session, adapter injection, patches + linux_display.py # ✅ wlopm keep-awake, swayidle neutralisation, rotation + linux_browser.py # ✅ LinuxChromiumAdapter (kiosk + Ozone + user-data-dir) + fix_kivy_sdl2.sh # ✅ system SDL2 (wayland-capable) over the bundled one + test_linux_patches.py # ✅ regression tests (29 checks) + _probe_video.py # ✅ ad-hoc video decode/playback probe + development-track.md # ✅ measured findings and bug log + RPI_TRIXIE_PORT_PLAN.md # ✅ this file + install_linux.sh # ⬜ upcoming — Trixie-correct installer + build_offline_aarch64.sh # ⬜ upcoming — cp313/aarch64 wheels + start_player.sh # ⬜ upcoming — kiosk launcher (fixed start.sh) + watchdog.sh # ⬜ upcoming — crash+heartbeat supervisor + kiwy-player.service # ⬜ upcoming — systemd unit + kiwy-signage-wifi.sudoers # ⬜ upcoming — nmcli-only passwordless rules +.github/instructions/ + kiwy-linux-rpi.instructions.md # ⬜ upcoming — build/deploy rules for the Pi +``` + +`run_linux.py` responsibilities (deliberately the *same shape* as `run_win.py`): + +1. Set Linux env vars **before** importing `main` (`SDL_VIDEODRIVER=wayland,x11,dummy`, + `KIVY_GL_BACKEND`, `SDL_AUDIODRIVER`, `KIVY_VIDEO/KIVY_AUDIO=ffpyplayer`). +2. Import `main`, then assign `SignagePlayer.weblink_adapter_factory` + → `[LinuxChromiumAdapter]`. +3. Patch `signal_screen_activity` → `linux_display` implementation. +4. Skip the Win32-only focus guardian / bring-to-front paths. +5. Provide a fatal-error surface that works without a console + (log file + optional on-screen error label). + +**Why an adapter instead of patching `play_weblink()`:** the `WeblinkSession` +abstraction already exists precisely for this, and it is what removed the +z-order/leak bugs on Windows. Reusing it means the Pi inherits the verified +launch → visibility → interaction → teardown state machine for free. + +--- + +## 4. Phased implementation plan + +### Phase 0 — Baseline (no product change) +- [ ] Fix the `install.sh`/`start.sh` shebangs (`#\!` → `#!`) so scripts are executable. +- [ ] Boot the player on this Pi **manually** (`python3 src/main.py` from a venv) and + capture a baseline: does Kivy start on labwc/Wayland? GL backend? audio? video? +- [ ] Record findings in `linux/development-track.md`. + +**Exit criteria:** a screenshot/log showing the player window on the Pi, or a +precise first-failure diagnosis. + +### Phase 1 — Runtime bring-up (`linux/run_linux.py`, `linux/linux_display.py`) +- [x] Create the venv and install deps (`kivy`, `ffpyplayer`, `evdev`, `requests`, `bcrypt`, `aiohttp`). +- [x] `run_linux.py`: env setup + adapter injection + `signal_screen_activity` replacement. +- [x] Fix the SDL2/Wayland blocker (`linux/fix_kivy_sdl2.sh`). +- [x] `signal_screen_activity()`: `wlopm --on '*'` via `subprocess` (no `os.system`), + `vcgencmd display_power` backstop, X11 fallbacks; dropped `tvservice`/`xdotool`/`ydotool`. +- [x] Neutralise the `swayidle` idle blanker. +- [x] Gate the Win32 focus guardian/keeper on Linux. +- [x] Fix the duplicate `SDL_AUDIODRIVER` line. +- [x] Point the launchers at `linux/run_linux.py` instead of `src/main.py`. +- [x] Regression test `linux/test_linux_patches.py` (29 checks). + +**Exit criteria met:** player runs fullscreen on labwc, video decodes and plays +(`duration=6.0`, position advancing, texture rendered), heartbeat written, +60 s run with zero errors and a clean shutdown. 24/7 soak still pending (Phase 5). + +### Phase 2 — Web links on Chromium/Wayland (`linux/linux_browser.py`) +- [ ] `LinuxChromiumAdapter(ChromiumSubprocessAdapter)`: `--kiosk`, `--ozone-platform=wayland` + (with auto fallback), dedicated `--user-data-dir`, `--autoplay-policy=no-user-gesture-required`, + `--hide-scrollbars`, `--disable-pinch`, `--start-fullscreen` only where `--kiosk` misbehaves. +- [ ] Implement `wait_visible()` for Linux — verify the Chromium window exists + (`/proc//` + Wayland toplevel check, or a `--remote-debugging-port` probe) + instead of only "process still alive". +- [ ] Transition masking without Win32: Kivy-side black cover + verify no desktop flash + under labwc; if needed, an `rc.xml` window rule pinning the Kivy window. +- [ ] Teardown: kill the full process tree (`start_new_session=True` + `os.killpg`) + so GPU/renderer children don't leak (the Linux twin of `taskkill /T`). +- [ ] Default `weblink.prewarm=false` on Pi to save CPU/RAM. + +**Exit criteria:** image → video → weblink → image cycle with no flash, no leaked +`chromium` processes, correct duration + interaction postponement. + +### Phase 3 — Networking & card reader +- [ ] `src/network_monitor.py`: replace `rfkill`/`ifconfig`/`dhclient` with `nmcli` + (`nmcli radio wifi off/on`, `nmcli device disconnect/connect`) — Trixie uses NetworkManager. +- [ ] `kiwy-signage-wifi.sudoers`: passwordless rules for exactly the `nmcli`/`rfkill` binaries used. +- [ ] Install `python3-evdev` (apt) + udev rule so the card reader works without `input` group hacks. + +### Phase 4 — Install & autostart (Trixie-native) +- [ ] `build_offline_aarch64.sh`: populate `repo/python-wheels/` with **cp313 aarch64** + wheels (`kivy`, `ffpyplayer`, `evdev`, `requests`, `bcrypt`, `aiohttp`, …). + Replace the `--platform linux_armv7l` logic. +- [ ] `install_linux.sh`: prefer apt `python3-kivy`/`python3-evdev` (fast, no build toolchain); + venv with `--system-site-packages`; drop the `--break-system-packages` fallback. +- [ ] Autostart: append to `~/.config/labwc/autostart` **and** ship a systemd unit + (verify which one RPi OS Trixie `rpd-labwc` actually honours — validation item). +- [ ] `watchdog.sh`: repair `configure_display_resolution()` (wrong `/boot/config.txt` path, + `sudo` at boot = hang risk); make all boot-time steps non-interactive. +- [ ] Orientation / `max_resolution`: implement rotation via `wlr-randr` (Wayland) or + `cmdline.txt` `video=...,rotate=`; document that `Window.size` cannot change a + fullscreen mode. + +**Exit criteria:** clean install on a wiped Pi → reboots into playback unattended. + +### Phase 5 — 24/7 validation & docs +- [ ] Overnight soak (≥12 h): mixed playlist, verify heartbeat, no leak growth + (`ps` chromium count), no memory growth. +- [ ] Crash/hang drills: kill the player, freeze it, power-cut it → watchdog recovery. +- [ ] `linux/development-track.md` + `.github/instructions/kiwy-linux-rpi.instructions.md`. +- [ ] Update `PLAYER_VERSION` and the release checklist. + +--- + +## 5. Risks & mitigations + +| Risk | Mitigation | +|------|-----------| +| Wayland client stacking — Chromium kiosk may not reliably return focus to Kivy | validate early (Phase 2); fallback = labwc `rc.xml` rule or run the session under XWayland | +| Chromium `--kiosk` + labwc fullscreen semantics | empirical flag matrix recorded in the dev-track (like the Windows "tested & rejected" log) | +| `sudo` password prompt at boot hangs the watchdog | all boot-time steps must be non-interactive; sudoers written at install time | +| Pi 4 software H.265 decode is heavy | document supported codecs; prefer H.264; keep `video_safety` bounded join | +| Offline wheels for cp313/aarch64 may be incomplete | Phase 0 verifies `pip download` for every requirement before Phase 4 | +| `src/main.py` drift — Linux fixes leaking into shared code | follow the repo rule: platform code lives in `linux/`, `main.py` only gains *guarded* capability checks | + +## 6. Validation checklist (per phase, on the real device) + +- [ ] `python -m py_compile` on every touched file +- [ ] Player starts fullscreen on labwc without a desktop flash +- [ ] Display never blanks (24 h) +- [ ] Playlist: image → video → weblink → image, correct durations +- [ ] Weblink: kiosk fullscreen, no leaked `chromium` processes after the item +- [ ] Touch interaction postpones the weblink advance +- [ ] Card reader authenticates +- [ ] WiFi restart recovers from an unplugged AP without a password prompt +- [ ] Reboot → playback resumes unattended +- [ ] `kill -9` the player → watchdog restarts within 60 s + +--- + +## 7. Open decisions (need input before Phase 1) + +1. **Kivy source**: apt `python3-kivy` (fast, offline-friendly, distro-managed) vs + PyPI wheel in a venv (matches the Windows Kivy 2.3.1 + full control)? +2. **Session target**: stay on the stock `rpd-labwc` kiosk session, or ship a + dedicated minimal labwc session (no panel, no `swayidle`) for the player? +3. **Autostart mechanism**: systemd (system or user) vs `~/.config/labwc/autostart`? +4. **Orientation**: is Portrait support actually required for this deployment? +5. **Offline install**: must the Pi install work with no internet (i.e. vendor the + aarch64 wheels into `repo/`), or is online install acceptable? +6. **Card reader**: is it in scope for the Pi, or is authentication keypad/quickconnect only? diff --git a/linux/_probe_chromium_footprint.py b/linux/_probe_chromium_footprint.py new file mode 100644 index 0000000..767b7a4 --- /dev/null +++ b/linux/_probe_chromium_footprint.py @@ -0,0 +1,126 @@ +"""_probe_chromium_footprint.py — measure Chromium's footprint for one page. + +Compares the flag profiles so the memory trade-off is measured rather than +guessed. Run: + + .venv/bin/python linux/_probe_chromium_footprint.py [url] + +Reports process count and total resident memory for the browser tree, using the +same flags the player uses (so the numbers match production). +""" + +import os +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from linux_display import ensure_session_environment # noqa: E402 + +ensure_session_environment() + +import linux_browser # noqa: E402 + +URL = sys.argv[1] if len(sys.argv) > 1 else 'about:blank' + + +def tree_memory(root_pid): + """(PSS kB, process count) for root_pid and its descendants. + + PSS (proportional set size) from ``/proc//smaps_rollup`` is the right + measure here, **not** RSS. Chromium forks many processes that share the same + libraries and file-backed pages; summing RSS counts every shared page once + per process, which inflated earlier measurements by roughly 2x and even made + a smaller configuration look larger. + """ + try: + out = subprocess.run( + ['ps', '-eo', 'pid,ppid'], capture_output=True, text=True, + timeout=10, check=False, + ).stdout + except Exception: + return 0, 0 + + parents = {} + for line in out.splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[0].isdigit(): + parents[int(parts[0])] = int(parts[1]) + + family = [root_pid] + changed = True + while changed: + changed = False + for pid, parent in parents.items(): + if parent in family and pid not in family: + family.append(pid) + changed = True + + total_kb = 0 + for pid in family: + try: + with open(f'/proc/{pid}/smaps_rollup') as fh: + for line in fh: + if line.startswith('Pss:'): + total_kb += int(line.split()[1]) + break + except OSError: + # Process exited between listing and reading. + continue + return total_kb, len(family) + + +def measure(mode): + os.environ['KIWY_CHROMIUM_MODE'] = mode + profile = f'/tmp/kiwy-footprint-{mode}' + subprocess.run(['rm', '-rf', profile], check=False) + + adapter = linux_browser.LinuxChromiumAdapter( + browser_path=linux_browser.find_linux_browser(), kiosk=True, + ) + adapter._profile_dir = profile + + ok = adapter.launch(URL, 1280, 720) + if not ok or adapter._proc is None: + return None + + try: + # Let Chromium finish spawning helpers before sampling. + time.sleep(8) + if adapter._proc.poll() is not None: + return {'crashed': True, 'rc': adapter._proc.returncode} + pss_kb, procs = tree_memory(adapter._proc.pid) + return {"pss_mb": pss_kb / 1024.0, "procs": procs} + finally: + adapter.teardown() + time.sleep(1.5) + + +print(f'url = {URL}\n') +print(f'{"mode":<10} {"procs":>6} {"PSS (MB)":>10}') +print('-' * 30) + +results = {} +for mode in ('safe', 'light', 'minimal'): + result = measure(mode) + results[mode] = result + if result is None: + note = 'launch failed' + elif result.get('crashed'): + note = f'CRASHED rc={result["rc"]}' + else: + note = '' + if note: + print(f'{mode:<10} {"-":>6} {note:>10}') + else: + print(f'{mode:<10} {result["procs"]:>6} {result["pss_mb"]:>10.0f}') + +print() +base = results.get('safe', {}) or {} +light = results.get('light', {}) or {} +if base.get('pss_mb') and light.get('pss_mb'): + saved = base['pss_mb'] - light['pss_mb'] + pct = 100.0 * saved / base['pss_mb'] + print(f'light mode saves {saved:.0f} MB ({pct:.0f}%) vs the default flag set') + print(f' processes: {base["procs"]} -> {light["procs"]}') diff --git a/linux/_probe_video.py b/linux/_probe_video.py new file mode 100644 index 0000000..195353b --- /dev/null +++ b/linux/_probe_video.py @@ -0,0 +1,70 @@ +"""Ad-hoc video playback probe (not part of the test suite). + +Plays the intro video in a Kivy window and reports whether it decodes and +advances. Used to verify the ffpyplayer path on Raspberry Pi OS Trixie. + + .venv/bin/python linux/_probe_video.py [path/to/video.mp4] +""" + +import os +import sys +import time + +# Mirror the real entry point: SDL2 does NOT discover the Wayland socket on its +# own, so WAYLAND_DISPLAY must be filled in first. Without this the probe falls +# back to x11 and dies with "Couldn't connect to X server". +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from linux_display import ensure_session_environment # noqa: E402 + +ensure_session_environment() + +os.environ.setdefault('SDL_VIDEODRIVER', 'wayland,x11,dummy') +os.environ.setdefault('KIVY_GL_BACKEND', 'gl') +os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer') +os.environ.setdefault('KIVY_AUDIO', 'ffpyplayer') + +from kivy.config import Config # noqa: E402 + +Config.set('graphics', 'window_state', 'hidden') +Config.set('graphics', 'fullscreen', '0') + +from kivy.app import App # noqa: E402 +from kivy.clock import Clock # noqa: E402 +from kivy.uix.video import Video # noqa: E402 + +SOURCE = sys.argv[1] if len(sys.argv) > 1 else 'config/resources/intro1.mp4' +DURATION = 12.0 +results = {} + + +class Probe(App): + def build(self): + self.video = Video( + source=SOURCE, state='play', options={'eos': 'stop'}, + allow_stretch=True, keep_ratio=True, + ) + self.video.bind(on_eos=lambda *a: results.setdefault('eos', True)) + return self.video + + def on_start(self): + self.t0 = time.monotonic() + Clock.schedule_interval(self.tick, 1.5) + Clock.schedule_once(lambda dt: self.stop(), DURATION) + + def tick(self, dt): + elapsed = time.monotonic() - self.t0 + core = self.video._video + position = getattr(core, 'position', None) if core else None + if position is not None: + results['last_position'] = position + print( + f' t={elapsed:5.1f}s state={self.video.state} ' + f'duration={self.video.duration:.1f} position={position} ' + f'texture={"yes" if self.video.texture else "no"}', + flush=True, + ) + + +Probe().run() +print(f'RESULT duration={results.get("duration")} ' + f'last_position={results.get("last_position")} eos={results.get("eos")}') diff --git a/linux/_probe_weblink.py b/linux/_probe_weblink.py new file mode 100644 index 0000000..c47ec48 --- /dev/null +++ b/linux/_probe_weblink.py @@ -0,0 +1,92 @@ +"""Ad-hoc weblink engine probe — drives the real LinuxChromiumAdapter. + +Verifies the full launch → visible → teardown cycle outside the player, so +weblink behaviour can be tested without a server-provided playlist. + + .venv/bin/python linux/_probe_weblink.py [url] +""" + +import os +import sys +import time + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, _HERE) +sys.path.insert(0, os.path.join(os.path.dirname(_HERE), 'src')) + +from linux_display import ensure_session_environment # noqa: E402 + +ensure_session_environment() + +URL = sys.argv[1] if len(sys.argv) > 1 else 'https://example.com' +REQUIRED = os.environ.get('KIWY_REQUIRE_SUBSTRING', '') + +from linux_browser import LinuxChromiumAdapter, find_linux_browser # noqa: E402 + + +def chromium_count(): + import subprocess + out = subprocess.run(['pgrep', '-c', 'chromium'], capture_output=True, text=True) + try: + return int(out.stdout.strip()) + except ValueError: + return 0 + + +def rss_mb(): + import subprocess + out = subprocess.run( + ['ps', '-o', 'rss=', '-C', 'chromium'], capture_output=True, text=True + ) + total = sum(int(x) for x in out.stdout.split() if x.isdigit()) + return total / 1024.0 + + +def main(): + print(f'URL: {URL}') + print(f'baseline chromium procs: {chromium_count()}') + + adapter = LinuxChromiumAdapter( + browser_path=find_linux_browser(), + kiosk=True, + profile_dir=os.path.join(os.getcwd(), '.kiosk-profile-probe'), + ) + + print(f'flags: {adapter.extra_launch_args()}') + + launched = adapter.launch(URL, 1920, 1080) + print(f'launch() -> {launched}') + if not launched: + print('FAIL: launch returned False') + return 1 + + visible, reason = adapter.wait_visible(15.0) + print(f'wait_visible() -> {visible} ({reason})') + + time.sleep(6) + print(f'during: {chromium_count()} procs, {rss_mb():.0f} MB') + + if REQUIRED: + # Fetch the same URL over HTTP and confirm it resolves, so a blank page + # can be attributed to rendering rather than to the network. + import urllib.request + try: + body = urllib.request.urlopen(URL, timeout=10).read(400_000).decode( + 'utf-8', 'replace' + ) + print(f'server reachable; page contains {REQUIRED!r}: {REQUIRED in body}') + except Exception as exc: + print(f'server fetch failed (network/host issue, not the engine): {exc}') + + adapter.teardown() + time.sleep(3) + + left = chromium_count() + print(f'after teardown: {left} chromium procs (0 = no leak)') + ok = visible and left == 0 + print('RESULT:', 'PASS' if ok else 'FAIL') + return 0 if ok else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/linux/development-track.md b/linux/development-track.md new file mode 100644 index 0000000..500d95e --- /dev/null +++ b/linux/development-track.md @@ -0,0 +1,509 @@ +# 🧪 Development Track — Kiwy Signage Player (Raspberry Pi / Linux Edition) + +> Read this FIRST before debugging or coding on the Pi port. It records what was +> measured on real hardware, not what was assumed. + +--- + +## 📅 Session — 2026-09-13 + +| Field | Value | +|-------|-------| +| **Branch** | `Linux-RPI-Player` (from `Windows-Player` @ `f437aba`) | +| **Hardware** | Raspberry Pi 4 Model B Rev 1.4, kernel `6.18.39+rpt-rpi-v8` | +| **Python** | 3.13.5 in `.venv` (created with `--system-site-packages`) | +| **Kivy** | 2.3.1 (PyPI wheel, cp313 aarch64) | +| **ffpyplayer** | 4.5.3 (cp313 aarch64 wheel) | +| **evdev** | 2.0.0 (built from sdist — no cp313 wheel published) | +| **Session** | Wayland, `rpd-labwc` (labwc — **not** Wayfire, not X11) | +| **Browser** | `/usr/bin/chromium` 152.0.7977.82 | +| **Entry point** | `linux/run_linux.py` (**not** `src/main.py`) | + +### ✅ Status: the player runs on the Pi + +Verified by real runs (not inference): + +* Kivy window created on **Wayland** (`wayland,x11,dummy` → wayland driver) +* 60 s continuous run, **0 errors, 0 tracebacks, clean shutdown** +* Heartbeat file written and refreshed every 10 s +* Playlist polling loop active (30 s interval) +* **Video playback works**: intro1.mp4 → `duration=6.0`, position advanced + `0.56 → 2.07 → 3.56 → 5.06`, texture rendered, EOS handled, state auto-reset +* Screensaver/blanker neutralised; `wlopm --on` confirmed working + +Blocked on: no server reachable from this network (`192.168.0.110` → *No route +to host*), so no playlist/media content could be downloaded. That is an +environment limitation, not a code defect. + +--- + +## �️ Windows code removal (2026-09-13, second pass) + +This branch is now **Linux-only**. All Windows code and assets were removed; +they remain available on the `Windows-Player` branch. + +**Deleted:** + +* `windows/` — the whole directory (32 files): `run_win.py`, `win_card_reader.py`, + `webview2_browser.py`, `webview2_runtime.py`, `cef_browser.py`, `build.spec`, + `watchdog.ps1`, the PyInstaller/PowerShell tooling and the bundled Windows + binaries (`webview2_sdk/` DLLs, `app_icon.ico`). +* `.github/instructions/kiwy-build-and-development.instructions.md` (the exe build guide) +* `documentation/CODE_SIGNING_SMART_APP_CONTROL.md` (Windows-only signing constraint) + +**Removed from shared `src/`** — these were live code paths, so this was a real +behavioural change, not just a comment cleanup: + +| File | Removed | +|------|---------| +| `main.py` | The whole Win32 focus subsystem: `_bring_window_to_front_nonblocking`, `_start/_stop_focus_keeper`, `_focus_keeper_tick`, `_start/_stop_focus_guardian`, `_focus_guardian_tick`, their `__init__` state, the `_start_focus_keeper()` call at video start, the guardian start in `__init__`, the two `_stop_focus_keeper()` calls, and the `_is_foreground_win` / `_bring_kivy_to_front_win` getattr hooks. Also the duplicate `Window.raise_window()` block and `_weblink_proc`. | +| `network_monitor.py` | `IS_WINDOWS`, the `ping -n/-w` branch, `_restart_wifi_windows()` (netsh), and the `sudo ifconfig`/`dhclient` calls (`ifconfig`/`dhclient` are **not installed** on Trixie). | +| `weblink_session.py` | `WebInputSources.pointer_moved()` (`GetCursorPos`), the `use_pointer` plumbing, and `msedge` from the browser search list. | +| `video_safety.py` | Windows framing in the docstring only — the fix itself is cross-platform and was kept. | + +**Replaced, not just deleted:** + +* `_restart_wifi_linux()` → `_restart_wifi_nmcli()`. Trixie uses **NetworkManager**, + so `nmcli radio wifi off/on` + `nmcli device connect` is the correct interface + and needs no `sudo`. The old path depended on packages that are absent. +* `ifconfig wlan0 down/up` → `ip link set wlan0 down/up`. +* New `.github/instructions/kiwy-linux-rpi.instructions.md` (the Pi counterpart + of the deleted build guide). +* `PLAYER_WEBLINK_INTEGRATION.md` §6.2 rewritten: "Windows: WebView2 Runtime" + → "Linux: Chromium kiosk on Wayland". +* `.gitignore`: dropped the Windows venv/dist/WebView2 entries, kept the + `.kiosk-profile/` ignore and clarified the credential rules. +* `config/app_config.json`: blanked the Windows host values + (`screen_name: DESKTOP-NJLBQKH`, the server IP and quick-connect key) and set + `weblink.prewarm: false` — pre-warming costs 2 extra Chromium processes, which + is not worth it on a Pi 4. + +**Also removed:** the `_patch_focus_handlers()` patch from `run_linux.py`. It had +become vestigial once the focus methods were deleted — it would have created +no-op attributes the app never calls. `test_linux_patches.py` now **asserts those +attributes do not exist**, so reintroducing Win32 focus code fails the suite. + +**Verified after the removal:** 21/21 patch checks pass, video still decodes and +plays (position advancing, texture rendered), 60 s player run with 0 errors and +no spurious crash log. + +--- + +## �🔴 The five bugs that actually prevented the port +### [PI-013] Chromium asked for the keyring password at every weblink + +* **Symptom:** "user and password" prompt appeared when a weblink launched + (reproducible also by starting Chromium manually). Impossible to answer on an + unattended signage screen, and it blocked the page. +* **Root cause (two separate bugs, neither sufficient alone):** + 1. **Flag list was dead code.** `APPLIANCE_FLAGS` contained + `--password-store=basic` and `--use-mock-keychain`, but the list was + **never referenced** from `extra_launch_args()` — so those flags never + reached the command line. The comment looked like a fix; nothing was applied. + 2. **The D-Bus session bus was inherited.** `ChromiumSubprocessAdapter.launch()` + used a bare `subprocess.Popen(args)` with **no `env=`**, and + `gnome-keyring-daemon --components=secrets` is running with + `DBUS_SESSION_BUS_ADDRESS` set. Chromium's password-store backend therefore + resolved to `gnome-libsecret` (Secret Service) and tried to unlock the login + keyring to hold its encryption key. Flags alone cannot fix this. +* **Fix:** + * New `launch_env()` hook on the base adapter (returns `None` = inherit); + `LinuxChromiumAdapter` returns `_browser_env()`, which strips + `DBUS_SESSION_BUS_ADDRESS`/`DBUS_SESSION_BUS_PID`, empties + `GNOME_KEYRING_CONTROL` and sets `CHROME_PASSWORD_STORE=basic`. Scoped to the + **child** — the player keeps its real session bus. + * Every flag list is now referenced from `extra_launch_args()`, and + `linux/test_linux_browser_flags.py` fails if any list becomes dead code. + * Added `--disable-save-password-bubble`. +* **Verified:** the live process's `/proc//environ` contains no + `DBUS_SESSION_BUS_ADDRESS`, and Chromium logs + `dbus/bus.cc:405] Failed to connect to the bus` — i.e. it structurally cannot + reach the keyring. No prompt appears. + +### [PI-014] `--ozone-platform-hint=auto` aborts on Chromium 152 + +* **Symptom:** no weblink ever displayed under labwc. +* **Root cause:** measured, the hint flag does **not** fall back to Wayland when + there is no X server — it simply fails: + ``` + (no flag) -> "Missing X server or $DISPLAY", aborts + --ozone-platform-hint=auto -> "Missing X server or $DISPLAY", aborts + --ozone-platform=wayland -> starts + ``` +* **Fix:** the platform is chosen explicitly from the detected session + (`--ozone-platform=wayland`), with `WAYLAND_FLAGS` omitted entirely when there + is no Wayland socket so Chromium uses its own X11 default. + +### [PI-015] Process-group teardown could never run + +* **Symptom:** every weblink risked leaving Chromium's GPU/zygote/renderer + children behind (the Linux twin of the Windows leak). +* **Root cause:** `linux_browser.teardown()` calls `os.killpg`, but it only did + so when `self._pgid` was a **different** group — and because the base + `Popen(args)` had no `start_new_session=True`, the browser shared the player's + own process group. Killing that group would have killed the player itself, so + the code correctly refused and fell back to `terminate()`, orphaning children. +* **Fix:** new `start_new_session()` hook; the Linux adapter returns `True`. + `test_linux_browser_flags.py` asserts the live process is its own group leader. + +--- + +## 🎬 Video normalisation — 4K cannot play on a Pi 4 + +### [PI-016] A 4K video shows one frozen frame instead of playing + +* **Symptom:** the new 4K sample (`16118765_3840_2160_30fps.mp4`, 3840×2160 + H.264 High@5.1) displayed a still frame while the playlist timer ticked on. + The 1080p sample played perfectly. +* **Root cause:** ffpyplayer decodes in **software** — there is no hardware + H.264 *decode* wired into its pipeline. Measured on this Pi 4: + + | File | Resolution | Software decode speed | + |------|-----------|----------------------| + | `sample-30s.mp4` | 1920×1080 | **3.03× realtime** ✅ | + | `16118765_3840_2160_30fps.mp4` | 3840×2160 | **0.90× realtime** ❌ | + + Below 1× realtime the decoder can never feed the display, so the picture + effectively stops. The player *did* advance correctly at the 19 s duration — + there was never a hang, just a video that cannot be rendered in time. +* **Important:** the file plays fine in an *isolated* Kivy probe (position + advances in realtime). The stall only appears under the real player, where + decode competes with rendering, the weblink browser and the GUI. Do not + conclude "the file is fine" from a standalone probe. + +### The fix: `linux/video_normalizer.py` + +Oversized media is downscaled to at most **1920×1080** once, at sync time. + +* **Triggered by resolution only** — `width > 1920 or height > 1080`. A file + already within the limit is left byte-identical, so nothing that already + plays is ever re-encoded. +* **Hardware encoding** via `h264_v4l2m2m` (verified working — the Pi 4's + H.264 *encoder* is a separate block from its decoder), with `libx264` as the + fallback. Measured: **31 s** for an 18 s 4K clip. +* **Audio preserved** (`-c:a copy`, AAC fallback). This matters: a *silent* + video hits the separate SDL2_mixer bug documented in `_video_has_audio`, so + the normaliser must not create one. +* Output lands next to the source as `_kiwy1080p.mp4` plus a + `.kiwy-normalized.json` metadata file. + +### Skip-while-converting, loop-the-intro-if-nothing-else + +The conversion is asynchronous, so the playlist can reach the item before it is +ready. `src/media_state.py` owns one on-disk contract shared by the player and +the normaliser: + +| Marker | Meaning | +|--------|---------| +| `.kiwy-converting` | conversion in flight → **skip this item** | +| `_kiwy1080p.mp4` + `.kiwy-normalized.json` | done → **play this file instead** | + +Player behaviour per item: + +* **ready** — play it (the converted file when one exists, else the original) +* **converting** — skip the lap immediately, no dwell delay +* **pending** — oversized and not converted: skip, and request conversion + +Skipped items are advanced with `_advance_without_wait()` rather than occupying +their configured duration, so a 19 s slot that cannot be shown does not add 19 s +of nothing to every lap. + +**When nothing at all is playable** (e.g. a single-item playlist that is one 4K +video still converting), the player loops `config/resources/intro1.mp4` +(`eos: loop`) and polls every 3 s. The moment a converted item appears it stops +the loop and restarts the playlist **from index 0** — so a one-item playlist +gets a clean full run rather than joining mid-clip. A blank screen is never left +on a signage display. + +### Traps found while building this + +1. **`resolve_playable()` initially skipped every normal video.** It checked for + a conversion output and a marker but never whether the file was oversized at + all, so a plain 1080p file (no output, no marker) fell through to + `pending`. The test caught it before it shipped: *skip-the-whole-playlist* is + a far worse failure than the freeze it was meant to fix. +2. **The media cleanup would have deleted the converted file.** + `delete_unused_media()` prunes anything not named in the playlist — and + `_kiwy1080p.mp4` is, by design, not named there. It is now explicitly + protected, along with both marker types. +3. **ffmpeg cannot infer the muxer from a `.part` temp name** ("Unable to choose + an output format"). The temp file keeps its real extension. +4. **Metadata is written last.** Its presence is what marks a conversion + complete, so it must never exist for a half-written output. +5. **A stale marker must not park an item forever** — markers older than 30 min + are ignored, so a crash mid-conversion cannot make a video unplayable. + +### Verified end to end (real server, real playlist) + +``` +20:33:05 sync detects 3840x2160 -> starts the background conversion +20:33:36 conversion completes (31s) -> _kiwy1080p.mp4 + metadata +20:36:01 video_using_normalized source=...3840_2160... normalized=..._kiwy1080p.mp4 +20:36:01 starting_video path=..._kiwy1080p.mp4 +20:36:20 next_media_called was_index=7 (played 18.7s, advanced on EOS) +``` + +Suites: `test_media_state.py` 18/18 · `test_linux_patches.py` 21/21 · +`test_linux_browser_flags.py` 27/27. + +**Recommendation for the server side:** normalising the source to 1920×1080 +before upload is still better — it avoids the 4K download *and* the 31 s +conversion. This player-side path exists so that an oversized upload degrades +gracefully instead of breaking the screen. + +--- + +## 🟡 Chromium footprint — measured, not assumed + +The Pi 4 has 3.8 GB total and the desktop already uses ~1.9 GB, so a weblink +needs to be lightweight. Numbers below are **PSS** summed over the browser tree +for the real page (`https://moto-adv.com/`), via +`linux/_probe_chromium_footprint.py`. + +| Profile | Processes | PSS | +|---------|-----------|-----| +| `safe` (no footprint flags) | 10 | 513 MB | +| **`light` (default)** | **9** | **507 MB** | +| `minimal` (`--single-process`) | 4 | 438 MB | + +**Honest conclusion: flag tuning buys very little.** Chromium's baseline is +simply ~500 MB and the remaining levers are single-digit percentages. +`minimal` saves ~15% but halves the process count, and upstream labels +`--single-process` unsupported, so it stays opt-in. + +**Two measurement traps worth remembering:** + +1. **RSS is the wrong metric.** Chromium shares libraries and file-backed pages + across processes; summing RSS double-counts and produced numbers ~2x too high + — it even ranked a *smaller* configuration as *larger* (1132 MB safe vs + 1529 MB light). Use `Pmi/smaps_rollup`. +2. **`--disable-gpu` makes it worse.** It looks like an obvious win for a static + page, but it moves rasterization out of the GPU process into the renderer: + **1038 MB vs 513 MB**. It is deliberately absent from every list. + +Use `KIWY_CHROMIUM_MODE=light|minimal|safe` to select a profile. If memory is +genuinely the constraint, the structural fix is an **embedded** engine rendering +inside the Kivy window (WebKitGTK, available on aarch64) — not more flags. + +> **Ultralight is not an option on this hardware.** Despite the vendor's site +> claiming "Linux (x64 / arm64)", enumerating the entire SDK bucket returns only +> `mac-x64, linux-x64, win-x64, win-uwp-x64, win-x86`; +> `ultralight-sdk-latest-linux-arm64.7z` is a **404**. There is no ARM64 build of +> any kind, and it is closed-source C++ with no Python binding. + +--- + +## 🔴 The five bugs that actually prevented the port + +### [PI-001] Kivy's bundled SDL2 has NO Wayland driver — **no window at all** + +* **Symptom:** + ``` + [CRITICAL] Unable to find any valuable Window provider. + sdl2 - RuntimeError: b'wayland,x11,dummy not available' + [CRITICAL] SignagePlayerApp: Window is None - display server not available + ``` +* **Root cause:** Kivy's PyPI wheel ships a **private SDL2** in `Kivy.libs/` + (name has a hash: `libSDL2-2-87637523.0.so.0.3000.7`). Driver enumeration + proved it is built **without Wayland**: + + | Library | Video drivers | + |---------|---------------| + | `Kivy.libs/libSDL2-2-*.so` (bundled) | `x11, KMSDRM, offscreen, dummy, evdev` | + | `/usr/lib/aarch64-linux-gnu/libSDL2-2.0.so.0` (system) | `x11, **wayland**, KMSDRM, offscreen, dummy, evdev` | + + Trixie runs a Wayland session and there is no X server, so the bundled build + cannot create a window. Note the driver *name* does not change: the bundled + SDL2 resolves `b'wayland'` as "driver unavailable", not "unknown driver". +* **Fix:** `linux/fix_kivy_sdl2.sh` symlinks the system SDL2 over the bundled + filename (idempotent, reversible, survives only until the next + `pip install --upgrade kivy`). +* **Verified:** `WINDOW OK size=(800, 600)` +* **Rejected alternatives:** + * ❌ Editing `~/.kivy/config.ini` — irrelevant, the provider never loads. + * ❌ `KIVY_WINDOW=...` variations — the library lacks the driver, full stop. + * *(Still untested)* apt `python3-kivy` likely bundles no SDL2 and would + sidestep this entirely — worth evaluating for the installer. + +### [PI-002] SDL2 requires `WAYLAND_DISPLAY` — the socket alone is NOT enough + +* **Symptom:** window creation fails when the player is launched from systemd, + cron, an autostart entry or SSH. +* **Root cause:** those contexts set `XDG_RUNTIME_DIR` but leave + `WAYLAND_DISPLAY` **empty** (the compositor only exports it inside the desktop + session). Measured: + + | `WAYLAND_DISPLAY` | result | + |-------------------|--------| + | unset | `sdl2 - RuntimeError: b'wayland not available'` | + | `wayland-0` | `WINDOW OK (800, 600)` | + + Notably `wlopm` **does** fall back to scanning `XDG_RUNTIME_DIR`, but SDL2 + does not — so this cannot be inferred from the display tools working. +* **Fix:** `linux_display.ensure_session_environment()` detects the socket, + derives the name from its filename (so `wayland-1` works) and exports it. + Called at the very top of `run_linux.py`, before Kivy is imported. +* **Verified:** `session environment filled in: {'WAYLAND_DISPLAY': 'wayland-0'}` + +### [PI-003] Kivy `WeakMethod` name trap — crash ~20 s AFTER startup + +* **Symptom:** the app starts fine, then dies on the first Clock tick: + ``` + [CRITICAL] Fatal error: 'SignagePlayer' object has no attribute 'linux_screen_activity' + [CRITICAL] Fatal error: 'SignagePlayer' object has no attribute '_noop' + ``` +* **Root cause:** Kivy's `Clock` stores a callback's `__func__.__name__` in a + `WeakMethod` and later resolves it with `getattr(instance, that_name)`. A + replacement assigned under a *different* name than the function was defined + with raises `AttributeError` — but only when the Clock next fires, so the + traceback points nowhere near the patch. + Two variants bit us: a name mismatch (`linux_screen_activity` vs + `signal_screen_activity`) and a shared helper (`_noop` used for three + different methods). +* **Fix:** `_bind_name()` sets `__name__`/`__qualname__` on each replacement, + and the focus no-ops are generated per-attribute rather than shared. +* **Verified:** `linux/test_linux_patches.py` asserts that + `getattr(SignagePlayer, func.__name__)` resolves *and* returns the patched + function — for every replaced method. +* This is the same trap that used to be documented in the Windows port; the + lesson is now enforced by a test instead of by a comment. + +### [PI-004] The inherited `signal_screen_activity()` never kept the screen on + +* **Symptom:** the panel blanks after the 10-minute idle timeout. +* **Root causes (three compounding):** + 1. `swayidle -w timeout 600 'wlopm --off *'` runs from + `~/.config/labwc/autostart` — measured: PID 1431, confirmed running. + 2. The handler shells out to `tvservice`, `xdotool`, `ydotool` — **all three + are absent on Trixie** (`tvservice` is gone with the legacy firmware + stack; `xdotool`/`ydotool` are not installed). + 3. It passed a shell-escaped `wlopm --on \*`, so the compositor matched an + output literally named `*` and did nothing. +* **Fix:** `linux/linux_display.py` — real `wlopm --on '*'` (list argv, no + shell), `vcgencmd display_power 1` as a firmware backstop, X11 fallbacks for + non-Wayland sessions, and `neutralise_idle_blanker()` to stop `swayidle`. +* **Verified:** `keep_display_awake` → `True`; `swayidle` killed; + `wlopm` reports `HDMI-A-1 on`. + +### [PI-005] Launcher scripts bypassed the platform layer entirely + +* **Symptom:** the player runs but blanks, shows no web links, and any fix to + the platform layer has no effect. +* **Root cause:** `start.sh` did `cd src && python3 main.py`, and + `run_player.sh` did the same. `src/main.py` is a *module* of the shared core, + not the Pi entry point — running it directly skips every patch in + `linux/run_linux.py`. Additionally `src/` is not the data directory, so + `base_dir` resolved one level too high. +* **Fix:** both scripts now run `.venv/bin/python linux/run_linux.py` from the + project root. `check_player_status.sh` / `stop_player.sh` match + `run_linux.py` instead of `python3 main.py` (they would otherwise never find + the process). +* **Verified:** `bash run_player.sh` runs clean; `bash -n` passes on all four. + +--- + +## 🟡 Also fixed (real but non-blocking) + +| # | Issue | Fix | +|---|-------|-----| +| [PI-006] | `main.py` set `SDL_AUDIODRIVER` **twice**; the second (`'alsa'`) was a silent no-op because `setdefault` never overwrites — it obscured which driver was live | Removed the duplicate, documented why | +| [PI-007] | `SettingsPopup.test_connection` hard-coded `/tmp/temp_auth_test.json` | Uses `tempfile.gettempdir()`; needed `import tempfile` added to `main.py`. Files with credentials are now always removed | +| [PI-008] | `player_auth.json` resolved against the *cwd*, so systemd/autostart launches "forgot" authentication and re-registered every start | `run_linux.py` pins it to an absolute path in the data dir | +| [PI-009] | `_bring_window_to_front_nonblocking` / focus keeper are Win32-only; on Wayland they ran every 0.5 s and logged "focus lost" forever | No-oped on Linux (`KIWY_FOCUS_KEEPER=1` restores) | +| [PI-010] | Clean shutdown (SIGTERM from the watchdog) wrote a bogus `FATAL: 0` crash log | `SystemExit(0)` is now a clean exit | +| [PI-011] | Web-link hand-off: without a dedicated `--user-data-dir` Chromium delegates the URL to an existing instance and exits in ~2 s | `LinuxChromiumAdapter` always uses a private profile, and clears stale `SingletonLock` | +| [PI-012] | `proc.terminate()` left Chromium's GPU/zygote/renderer children running (Leak → OOM over 24/7) | Kill the whole process group (`os.killpg`), the Linux twin of `taskkill /T` | + +--- + +## 🧪 Tested & Rejected Solutions Log + +| Date | What was tested | Result | Reason it failed | +|------|----------------|--------|-----------------| +| 2026-09-13 | `SDL_VIDEODRIVER=wayland` with the bundled SDL2 | ❌ | Bundled SDL2 has no wayland driver | +| 2026-09-13 | `SDL_VIDEODRIVER=x11` (+ XWayland present) | ❌ | No X server running on labwc; `x11 not available` | +| 2026-09-13 | `KIVY_GL_BACKEND=gles` vs `gl` | ➖ | Both equivalent; the failure was the driver, not GL | +| 2026-09-13 | Comma-separated `SDL_VIDEODRIVER` list | ✅ | Works — Kivy splits on `,` and SDL2 takes the first that initialises | +| 2026-09-13 | Relying on the Wayland socket without `WAYLAND_DISPLAY` | ❌ | SDL2 does not scan `XDG_RUNTIME_DIR` (unlike `wlopm`) | +| 2026-09-13 | System SDL2 symlinked over the bundled name | ✅ | Full driver set incl. wayland — **the fix** | + +--- + +## 🧰 Environment / dependency facts + +* **Must exist before the player will start:** + * `libsdl2-2.0-0` (system SDL2 **with** wayland) + * `libgl1-mesa-dri`, `libgles2` + * a running Wayland session (labwc) and a valid `XDG_RUNTIME_DIR` +* **Python packages** (all cp313 aarch64 wheels exist **except** evdev): + + | Package | Source | Note | + |---------|--------|------| + | kivy 2.3.1 | PyPI wheel | needs `fix_kivy_sdl2.sh` on Trixie | + | ffpyplayer 4.5.3 | PyPI wheel | video/audio backend | + | aiohttp, requests, bcrypt | PyPI wheel / apt | already installed system-wide | + | **evdev 2.0.0** | **sdist only** | `python3-dev` + `build-essential` required, or use apt `python3-evdev` | + +* **Tools present:** `wlopm`, `wlr-randr`, `vcgencmd`, `swayidle`, `chromium`, + `ffprobe`, `labwc`, `systemd-inhibit`, `zenity` +* **Tools MISSING:** `tvservice`, `xdotool`, `ydotool`, `chromium-browser`, + `glxinfo`, `ifconfig`, `dhclient` (last two matter for WiFi restart — Phase 3) +* **Networking:** NetworkManager is active; `dhcpcd`/`ifupdown` are not +* **`sudo` requires a password** → every boot-time `sudo` call is a hang risk + (see Phase 4 / `start.sh` `configure_display_resolution()`) + +--- + +## 🧪 How to verify + +```bash +cd /home/pi/Desktop/Kiwy-Signage + +# 1. SDL2 has the wayland driver (the #1 blocker) +bash linux/fix_kivy_sdl2.sh --check + +# 2. Platform patches wired correctly (WeakMethod trap, keep-awake, SDL2) +.venv/bin/python linux/test_linux_patches.py # expect: 21/21 passed + +# 3. Keyring bypass + footprint flags actually applied +.venv/bin/python linux/test_linux_browser_flags.py # expect: 27/27 passed + +# 4. Video decode + playback +.venv/bin/python linux/_probe_video.py # expect: position advances + +# 5. Chromium footprint per profile (PSS, real page) +.venv/bin/python linux/_probe_chromium_footprint.py https://moto-adv.com/ + +# 6. The player itself +bash run_player.sh # or: bash start.sh (watchdog) + +# 7. Diagnostics +.venv/bin/python linux/linux_display.py # backend/outputs/tools +bash linux/fix_kivy_sdl2.sh --revert # undo the SDL2 symlink +``` + +Useful escape hatches: + +| Variable | Effect | +|----------|--------| +| `KIWY_DISPLAY_TOOLS_DISABLED=1` | Disable all `wlopm`/`vcgencmd`/`swayidle` work | +| `KIWY_CHROMIUM_MODE=light\|minimal\|safe` | Chromium footprint profile (default `light`) | +| `KIWY_VENV=/path` | Point the SDL2 fix script at another virtualenv | + +--- + +## 📝 Next session + +* [ ] **Phase 2** — web links: verify Chromium kiosk on labwc end-to-end + (fullscreen, no desktop flash, no leaked processes, interaction postpones) +* [ ] Evaluate **apt `python3-kivy`** — it bundles no SDL2 and may remove the + need for `fix_kivy_sdl2.sh` entirely +* [ ] **Phase 4** — autostart via `~/.config/labwc/autostart` + + systemd unit; repair `start.sh`'s `configure_display_resolution()` + (wrong `/boot/config.txt` path on Trixie → `/boot/firmware/config.txt`) +* [ ] **Phase 3** — card reader (evdev/udev) and WiFi restart via `nmcli` + (the current `ifconfig`/`dhclient` path depends on packages that are gone) +* [ ] Orientation/rotation via `wlr-randr` (implemented, untested on hardware) +* [ ] 24/7 soak test with real playlist content +* [ ] Confirm the `dist/` deployment hazard documented for Windows has no + Linux equivalent (it does not — there is no bundle on Linux) diff --git a/linux/fix_kivy_sdl2.sh b/linux/fix_kivy_sdl2.sh new file mode 100755 index 0000000..e620f76 --- /dev/null +++ b/linux/fix_kivy_sdl2.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# fix_kivy_sdl2.sh — make Kivy use the system SDL2 (Wayland-capable). +# +# THE PROBLEM +# ----------- +# Kivy's PyPI wheel bundles a private SDL2 in ``Kivy.libs/`` that is compiled +# WITHOUT the Wayland video driver. Verified on this Pi: +# +# Kivy.libs/libSDL2-2-87637523.0.so.0.3000.7 -> x11, KMSDRM, offscreen, dummy, evdev +# /usr/lib/aarch64-linux-gnu/libSDL2-2.0.so.0 -> x11, wayland, KMSDRM, offscreen, dummy, evdev +# +# On Raspberry Pi OS "Trixie" the desktop is Wayland/labwc and there is no +# X server running, so the bundled build cannot create a window at all: +# +# [CRITICAL] Unable to find any valuable Window provider. +# sdl2 - RuntimeError: b'wayland,x11,dummy not available' +# +# Substituting the system library under Kivy's bundled filename resolves it +# (verified: "WINDOW OK size=(800, 600)"). +# +# WHY A SYMLINK AND NOT A COPY +# ---------------------------- +# The symlink survives ``pip install --upgrade kivy`` overwriting the file, is +# reversible, and keeps the distro's security updates in effect. A copy would +# silently become stale. +# +# Usage: +# bash linux/fix_kivy_sdl2.sh # apply (idempotent) +# bash linux/fix_kivy_sdl2.sh --check # report only, change nothing +# bash linux/fix_kivy_sdl2.sh --revert # remove the symlink + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +VENV_DIR="${KIWY_VENV:-$ROOT_DIR/.venv}" +BACKUP_DIR="$VENV_DIR/.kivy-sdl2-backup" +SYSTEM_SDL2="/usr/lib/aarch64-linux-gnu/libSDL2-2.0.so.0" + +MODE="apply" +case "${1:-}" in + --check) MODE="check" ;; + --revert) MODE="revert" ;; + "") MODE="apply" ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; +esac + +if [ ! -d "$VENV_DIR" ]; then + echo "ERROR: virtualenv not found at $VENV_DIR" >&2 + echo " Set KIWY_VENV=/path/to/venv if it lives elsewhere." >&2 + exit 1 +fi + +KIVY_LIBS="$(find "$VENV_DIR" -maxdepth 5 -type d -name 'Kivy.libs' 2>/dev/null | head -1)" +if [ -z "$KIVY_LIBS" ]; then + echo "No Kivy.libs directory found under $VENV_DIR — nothing to do." + echo "(Kivy is probably installed from apt, which uses the system SDL2 already.)" + exit 0 +fi + +# Kivy names the bundled library with a hash, so locate it by pattern rather +# than hard-coding the version. Symlinks must be matched too: once the fix is +# applied the original file IS a symlink, and skipping it would make this +# script wrongly conclude that nothing is bundled. +BUNDLED="$(find "$KIVY_LIBS" -maxdepth 1 -name 'libSDL2-2-*.so*' 2>/dev/null | head -1)" + +# Fallback: ask the compiled extension which SDL2 it actually loads. This is +# authoritative and also covers a future Kivy layout change. +if [ -z "$BUNDLED" ]; then + EXT="$(find "$VENV_DIR" -maxdepth 6 -name '_window_sdl2*.so' 2>/dev/null | head -1)" + if [ -n "$EXT" ]; then + NEEDED="$(ldd "$EXT" 2>/dev/null | awk '/libSDL2-2-/ {print $3; exit}')" + [ -n "$NEEDED" ] && BUNDLED="$NEEDED" + fi +fi + +echo "Kivy.libs : $KIVY_LIBS" +echo "bundled : ${BUNDLED:-}" +echo "system : $SYSTEM_SDL2" + +if [ ! -e "$SYSTEM_SDL2" ]; then + echo "ERROR: system SDL2 not found at $SYSTEM_SDL2" >&2 + echo " Install it with: sudo apt install libsdl2-2.0-0" >&2 + exit 1 +fi + +# Report the video drivers compiled into a library, so the fix is verifiable. +report_drivers() { + local lib="$1" + local py="${VENV_DIR}/bin/python" + [ -x "$py" ] || py="$(command -v python3)" + "$py" - "$lib" <<'PY' 2>/dev/null || echo " (could not inspect drivers)" +import ctypes, sys +try: + s = ctypes.CDLL(sys.argv[1]) + s.SDL_GetNumVideoDrivers.restype = ctypes.c_int + s.SDL_GetVideoDriver.restype = ctypes.c_char_p + s.SDL_GetVideoDriver.argtypes = [ctypes.c_int] + n = s.SDL_GetNumVideoDrivers() + print(' drivers:', ', '.join(s.SDL_GetVideoDriver(i).decode() for i in range(n))) +except Exception as exc: + print(' (inspection failed:', exc, ')') +PY +} + +case "$MODE" in + revert) + if [ -L "$BUNDLED" ]; then + rm -f "$BUNDLED" + if [ -f "$BACKUP_DIR/$(basename "$BUNDLED")" ]; then + cp -a "$BACKUP_DIR/$(basename "$BUNDLED")" "$BUNDLED" + echo "Reverted to the original bundled SDL2." + else + echo "Removed the symlink. Reinstall Kivy to restore the bundled library:" + echo " $VENV_DIR/bin/pip install --force-reinstall kivy" + fi + else + echo "Nothing to revert (no symlink in place)." + fi + exit 0 + ;; + + check) + echo + if [ -L "$BUNDLED" ]; then + echo "STATUS: fixed (bundled name points at the system SDL2)" + report_drivers "$BUNDLED" + else + echo "STATUS: NOT fixed — Kivy is using its own SDL2" + report_drivers "$BUNDLED" + fi + echo + echo "system SDL2:" + report_drivers "$SYSTEM_SDL2" + exit 0 + ;; + + apply) + if [ -z "$BUNDLED" ]; then + echo "No bundled libSDL2 found — Kivy already uses the system SDL2." + exit 0 + fi + + if [ -L "$BUNDLED" ]; then + echo "Already fixed (symlink in place). Verifying..." + report_drivers "$BUNDLED" + exit 0 + fi + + # Keep a copy of the original so --revert works without a reinstall. + mkdir -p "$BACKUP_DIR" + if [ ! -f "$BACKUP_DIR/$(basename "$BUNDLED")" ]; then + cp -a "$BUNDLED" "$BACKUP_DIR/" + echo "Backed up original -> $BACKUP_DIR/$(basename "$BUNDLED")" + fi + + ln -sf "$SYSTEM_SDL2" "$BUNDLED" + echo "Symlinked $BUNDLED -> $SYSTEM_SDL2" + echo + echo "Resulting drivers:" + report_drivers "$BUNDLED" + echo + echo "Done. Kivy can now create a Wayland window." + echo "NOTE: re-run this script after any 'pip install --upgrade kivy'." + ;; +esac diff --git a/linux/linux_browser.py b/linux/linux_browser.py new file mode 100644 index 0000000..d3fe467 --- /dev/null +++ b/linux/linux_browser.py @@ -0,0 +1,603 @@ +"""linux_browser.py — Chromium/Chrome kiosk adapter for Raspberry Pi (Wayland). + +Why this module exists +---------------------- +``src/weblink_session.py`` already owns the whole web-link lifecycle (launch → +verified visibility → interaction watching → teardown) and ships a generic +``ChromiumSubprocessAdapter`` that is the default on Linux. That adapter is +correct in structure but was never tuned for Raspberry Pi OS Trixie, where: + +* the session is **Wayland/labwc**, so Chromium needs an explicit Ozone + platform or it may come up as an X11 (XWayland) surface that the compositor + will not make fullscreen; +* Chromium is ``/usr/bin/chromium`` (there is no ``chromium-browser``); +* ``--start-maximized`` is not what makes a window fullscreen on labwc; + ``--kiosk`` is; +* without a dedicated ``--user-data-dir`` Chromium hands the URL to an + already-running instance, the process we launched exits in ~2 s, and the + session's ``wait_visible`` reports the item as failed; +* ``proc.terminate()`` only kills the parent; Chromium's GPU/zygote/renderer + children survive and accumulate over a 24/7 playlist. + +This adapter is the platform counterpart of the generic +``ChromiumSubprocessAdapter`` and is injected through the existing +``SignagePlayer.weblink_adapter_factory`` hook — no changes to the shared +``play_weblink`` code path are required. +""" + +from __future__ import annotations + +import os +import shutil +import signal +import sys +import subprocess +import time + +# The shared modules live in ../src. Add it explicitly so this file can be +# imported standalone (diagnostics, tests) and not only after run_linux.py has +# already put src/ on sys.path. +_SRC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src') +if _SRC_DIR not in sys.path: + sys.path.insert(0, _SRC_DIR) + +from weblink_session import ChromiumSubprocessAdapter # noqa: E402 + + +def _log(message, level='info'): + try: + from kivy.logger import Logger + + getattr(Logger, level, Logger.info)(f'[LinuxBrowser] {message}') + except Exception: + pass + + +#: Wayland flags. The platform MUST be named explicitly. +#: +#: Measured on Chromium 152 / Raspberry Pi OS Trixie (labwc, no X server): +#: +#: (no flag) -> "Missing X server or $DISPLAY", aborts +#: --ozone-platform-hint=auto -> "Missing X server or $DISPLAY", aborts +#: --ozone-platform=wayland -> starts (9 processes) +#: +#: ``--ozone-platform-hint=auto`` does NOT fall back to Wayland when there is no +#: X server, contrary to how it is usually described; it simply fails. So the +#: flag is chosen explicitly from the detected session instead of being hinted. +WAYLAND_FLAGS = [ + '--ozone-platform=wayland', + '--enable-features=UseOzonePlatform,WaylandWindowDecorations', +] + +#: Flags that stop Chromium asking for the **keyring** password. +#: +#: This is the prompt that appeared at start-up. On Raspberry Pi OS +#: ``gnome-keyring-daemon --components=secrets`` runs and +#: ``DBUS_SESSION_BUS_ADDRESS`` is set, so Chromium's default password-store +#: backend resolves to ``gnome-libsecret`` (Secret Service). Chromium then tries +#: to unlock the login keyring to hold its encryption key, which raises a modal +#: prompt that cannot be answered on an unattended signage screen. +#: +#: ``--password-store=basic`` forces the built-in plain store so Chromium never +#: contacts the Secret Service. ``--use-mock-keychain`` covers the equivalent +#: code path on other platforms. +#: +#: These used to sit inside ``APPLIANCE_FLAGS``, but that list was never +#: referenced from ``extra_launch_args()``, so none of them ever reached the +#: command line — the prompt looked unfixable. ``test_linux_browser_flags.py`` +#: now asserts they are actually applied. +KEYRING_BYPASS_FLAGS = [ + '--password-store=basic', + '--use-mock-keychain', +] + +#: Flags that reduce the memory footprint on a Pi 4. +#: +#: Measured for one real page (https://moto-adv.com/), **PSS** summed over the +#: whole browser tree — see ``_probe_chromium_footprint.py``: +#: +#: default flags 10 procs ~513 MB +#: + --disable-gpu 10 procs ~1038 MB <-- WORSE, do not use +#: + this list (light) 9 procs ~507 MB +#: + --single-process (minimal) 4 procs ~438 MB (~15% less) +#: +#: Two things worth knowing, both measured rather than assumed: +#: +#: * ``--disable-gpu`` is deliberately NOT included. Intuitively it should help, +#: but it moves rasterization out of the GPU process and into the renderer, +#: which *doubled* memory for a real page. +#: * Chromium's baseline cost is simply large. The remaining ~500 MB is the +#: browser itself, so flag tuning yields only single-digit percentages. +#: ``--single-process`` is the only large lever (~15%), and it is opt-in +#: because upstream labels it unsupported. +#: +#: RSS is NOT the right metric here: Chromium shares libraries and file pages +#: across its processes, so summing RSS double-counts and produced numbers that +#: were ~2x too high and misleadingly ranked a smaller configuration as larger. +#: +#: Select a profile with KIWY_CHROMIUM_MODE=light|minimal|safe. +LIGHT_WEIGHT_FLAGS = [ + # One renderer instead of a pool. + '--renderer-process-limit=1', + # Do not retain a renderer for a window that is not visible. + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + '--disable-breakpad', + # Keep the process count down; each helper is a forked Chromium. + '--disable-site-isolation-trials', + '--disable-features=site-per-process,IsolateOrigins', + # No persisted session state to load on start. + '--no-restore-session-state', +] + +#: ``--single-process`` is the one large lever (~438 MB vs ~507 MB) but Chromium +#: upstream labels the mode unsupported. Selected via +#: KIWY_CHROMIUM_MODE=minimal; verified stable over repeated launches of the +#: real weblink page. +#: +#: NOTE: ``--disable-gpu`` is intentionally absent from every list. It measured +#: WORSE (~1038 MB) and is not worth a dead constant to keep around. +MINIMAL_FLAGS = ['--single-process'] + +#: Everything a static page does not need. Each service is a separate process. +SERVICE_REDUCTION_FLAGS = [ + '--no-service-autorun', + '--disable-component-extensions-with-background-pages', + '--disable-default-apps', + '--disable-extensions', + '--disable-plugins-discovery', + '--disable-preconnect', + '--disable-domain-reliability', + '--disable-client-side-phishing-detection', + '--disable-hang-monitor', + '--metrics-recording-only', + '--no-pings', +] + + +def _chromium_mode(): + """Footprint profile: ``light`` (default), ``minimal`` or ``safe``. + + * ``light`` — safe reductions, no GPU, one renderer (recommended) + * ``minimal`` — adds ``--single-process``; smallest, can crash + * ``safe`` — no footprint flags at all, for ruling them out when debugging + """ + value = os.environ.get('KIWY_CHROMIUM_MODE', 'light').strip().lower() + if value not in ('light', 'minimal', 'safe'): + _log(f'unknown KIWY_CHROMIUM_MODE={value!r}; using "light"', 'warning') + return 'light' + return value + + +#: Flags that make a signage page behave like an appliance. +#: +#: NOTE: every list here must be referenced from ``extra_launch_args()``. This +#: list was dead code once, which silently disabled the keyring bypass along +#: with every kiosk nicety. +APPLIANCE_FLAGS = [ + '--noerrdialogs', + '--disable-infobars', + '--no-first-run', + '--no-default-browser-check', + '--disable-session-crashed-bubble', + '--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints,PasswordManagerOnboarding,AutofillServerCommunication,PasswordLeakDetection', + '--disable-background-networking', + '--disable-component-update', + '--disable-sync', + '--check-for-update-interval=31536000', + '--autoplay-policy=no-user-gesture-required', + '--hide-scrollbars', + '--disable-pinch', + '--overscroll-history-navigation=0', + '--force-device-scale-factor=1', + '--window-position=0,0', +] + +#: Never offer to save or autofill credentials — a second source of prompts. +#: The pages shown are public, so suppressing this costs nothing. +NO_PROMPTS_FLAGS = [ + '--disable-save-password-bubble', +] + + +def _browser_env(): + """Environment for the browser process only — never the player. + + Defence in depth for the keyring prompt. Even if Chromium ignores + ``--password-store=basic``, an emptied ``GNOME_KEYRING_CONTROL`` and a + removed ``DBUS_SESSION_BUS_ADDRESS`` mean the Secret Service cannot be + reached, so no unlock prompt can be raised at all. + + Scoped to the child deliberately: the player keeps its real session bus, + which other components may rely on. + """ + env = dict(os.environ) + env['GNOME_KEYRING_CONTROL'] = '' + env['CHROME_PASSWORD_STORE'] = 'basic' + # Nothing a single static page needs requires the session bus. + env.pop('DBUS_SESSION_BUS_ADDRESS', None) + env.pop('DBUS_SESSION_BUS_PID', None) + # Discourage portal / keyring autostart helpers from being pulled in. + env['XDG_DESKTOP_PORTAL_SUPPRESS'] = '1' + return env + + +def find_linux_browser(): + """Locate a Chromium-family browser, preferring the Debian/RPi names. + + ``chromium-browser`` is checked first only because older RPi OS releases + shipped it as the wrapper name; on Trixie the real binary is ``chromium``. + """ + for candidate in ( + 'chromium-browser', # RPi OS <= Bullseye wrapper + 'chromium', # RPi OS Bookworm/Trixie + 'google-chrome', + 'google-chrome-stable', + 'chrome', + 'microsoft-edge', + ): + path = shutil.which(candidate) + if path: + return path + return None + + +class LinuxChromiumAdapter(ChromiumSubprocessAdapter): + """Chromium kiosk subprocess for Raspberry Pi OS (Wayland/labwc). + + Subclasses :class:`ChromiumSubprocessAdapter` so the session's health + checking, interaction watching and generation-tokened teardown all keep + working; only the Linux-specific behaviour is overridden. + """ + + name = 'chromium-kiosk-linux' + embedded = False + + def __init__(self, browser_path=None, extra_flags=(), kiosk=True, + profile_dir=None, use_wayland=None): + super().__init__(browser_path=browser_path, extra_flags=extra_flags, + kiosk=kiosk) + self._profile_dir = profile_dir + # Flags follow the detected session. An explicit --ozone-platform is + # required on Wayland (see WAYLAND_FLAGS); when there is no Wayland + # socket the flags are omitted so Chromium uses its own default (X11). + self._use_wayland = _detect_wayland() if use_wayland is None else bool(use_wayland) + # Track our own process group so teardown can reap the whole tree. + self._pgid = None + self._preflight_done = False + + # ── Launch environment / session ───────────────────────────────── + def launch_env(self): + """Environment for the browser process only. + + This — not the ``--password-store`` flag — is what actually removes the + keyring password prompt. The base ``Popen`` inherited the player's + environment, which includes ``DBUS_SESSION_BUS_ADDRESS``; Chromium could + therefore reach the running ``gnome-keyring-daemon`` and tried to unlock + the login keyring. + """ + return _browser_env() + + def start_new_session(self): + """Give the browser its own process group so teardown can reap it. + + The base implementation used a plain ``Popen``, so the browser shared + the player's process group and ``os.killpg`` was never usable — every + weblink left Chromium's GPU/zygote/renderer children behind. + """ + return True + + # ── Flags ──────────────────────────────────────────────────────── + def extra_launch_args(self): + """Flags appended to the Chromium command line. + + Ordering is deliberate: appliance/keyring flags come first so a later + list can never accidentally be shadowed, and every module-level list is + referenced here. If you add a list above, add it here too — a list that + nothing references is how the keyring prompt survived a "fix". + """ + args = [] + + # 1. Never touch the Secret Service / keyring (the password prompt). + args.extend(KEYRING_BYPASS_FLAGS) + args.extend(NO_PROMPTS_FLAGS) + + # 2. Kiosk behaviour: no browser UI, no error dialogs, no autofill. + args.extend(APPLIANCE_FLAGS) + args.extend(SERVICE_REDUCTION_FLAGS) + + # 3. Footprint. A static signage page needs a fraction of Chromium's + # defaults; each avoided helper is a process the Pi 4 does not have + # RAM for. + mode = _chromium_mode() + if mode in ('light', 'minimal'): + args.extend(LIGHT_WEIGHT_FLAGS) + if mode == 'minimal': + args.extend(MINIMAL_FLAGS) + + # 4. Identity and geometry. + if self._profile_dir: + args.append('--user-data-dir=' + self._profile_dir) + if self._kiosk: + # --kiosk implies fullscreen and removes all browser UI, which is + # the supported path on wlroots compositors. + args.append('--kiosk') + if self._use_wayland: + args.extend(WAYLAND_FLAGS) + + return args + + def launch(self, url, width, height): + """Prepare the private profile, then delegate to the base launch. + + Any Chromium still holding the profile is killed first: a surviving + instance would swallow the URL and make our process exit immediately. + """ + self._preflight() + + if self._profile_dir is None: + self._profile_dir = os.path.join( + os.environ.get('KIWY_DATA_DIR') or os.getcwd(), '.kiosk-profile' + ) + try: + os.makedirs(self._profile_dir, exist_ok=True) + except Exception as exc: + _log(f'could not create kiosk profile {self._profile_dir}: {exc}', 'warning') + + self._kill_browsers_on_profile() + self._cleanup_stale_profile_locks() + ok = super().launch(url, width, height) + if ok and self._proc is not None: + self._pgid = _safe_getpgid(self._proc.pid) + return ok + + def _preflight(self): + """Warn once when Chromium's platform flags do not match this session. + + The failure this guards against is silent: on a Wayland session with no + X server, Chromium started without ``--ozone-platform=wayland`` aborts + after ~1 s with "Missing X server or $DISPLAY" on stderr, which reads + identically to a hand-off bug. Detecting the mismatch at launch time + turns an unexplained skipped weblink into an actionable log line. + """ + if getattr(self, '_preflight_done', False): + return + self._preflight_done = True + + has_x_server = bool(os.environ.get('DISPLAY')) + if self._use_wayland and not has_x_server: + _log('session is Wayland-only; launching Chromium with ' + '--ozone-platform=wayland (the default and --ozone-platform-hint ' + 'both abort with "Missing X server" here)') + elif not self._use_wayland and has_x_server: + _log('session is X11; launching Chromium without Wayland flags') + elif self._use_wayland and has_x_server: + _log('both Wayland and X11 available; preferring Wayland') + + # ── Startup verification ───────────────────────────────────────── + def wait_visible(self, timeout): + """Wait until the launched Chromium is genuinely up and not a hand-off. + + A plain "process still alive" check is not enough on Linux: a hand-off + launch also stays alive briefly, and a missing Wayland socket produces a + fast exit. Two signals are combined: + + * the process must survive the health grace period; and + * a Chromium window/toplevel must be observable for this PID. + + If the second cannot be established on this compositor we still return + success on the first, so a working-but-unprobeable setup is never + skipped (that would be worse than a possible blank frame). + """ + proc = self._proc + if proc is None: + return False, 'no process' + + deadline = time.monotonic() + max(1.0, float(timeout)) + grace = min(float(self._health_grace), max(0.5, float(timeout))) + grace_deadline = time.monotonic() + grace + + while time.monotonic() < grace_deadline: + if proc.poll() is not None: + return False, f'browser exited immediately (rc={proc.returncode})' + time.sleep(0.1) + + if _window_exists_for_pid(proc.pid): + return True, f'toplevel-for-pid={proc.pid}' + + while time.monotonic() < deadline: + if proc.poll() is not None: + return False, f'browser exited early (rc={proc.returncode})' + if _window_exists_for_pid(proc.pid): + return True, f'toplevel-for-pid={proc.pid}' + time.sleep(0.2) + + if proc.poll() is None: + # Alive past the timeout but not probeable — accept rather than + # skipping a page that is probably on screen. + return True, 'process-alive-unverified' + return False, 'browser window never appeared' + + # ── Teardown ───────────────────────────────────────────────────── + def teardown(self): + """Terminate the whole Chromium process group. + + ``proc.terminate()`` (the base behaviour) leaves the GPU, zygote and + renderer children behind; over a 24/7 playlist those accumulate until + the Pi runs out of memory. Killing the process group reaps them all. + """ + proc, self._proc = self._proc, None + pgid, self._pgid = self._pgid, None + if proc is None: + return + + if proc.poll() is None: + _kill_process_group(proc, pgid) + + # Belt and braces: reap anything else still holding this profile. + self._kill_browsers_on_profile() + self._cleanup_stale_profile_locks() + + # ── Helpers ────────────────────────────────────────────────────── + def _cleanup_stale_profile_locks(self): + """Remove the singleton lock a crashed Chromium left behind. + + Chromium refuses to start on a profile whose ``SingletonLock`` points at + a dead PID (or shows the "profile in use" dialog). Because our profile + is private to the player, clearing the lock is always safe. + """ + if not self._profile_dir: + return + for name in ('SingletonLock', 'SingletonSocket', 'SingletonCookie'): + path = os.path.join(self._profile_dir, name) + try: + if os.path.islink(path) or os.path.exists(path): + os.unlink(path) + _log(f'cleared stale profile lock {name}', 'debug') + except Exception: + pass + + def _kill_browsers_on_profile(self): + """Kill any Chromium holding our kiosk profile. + + Scans ``/proc//cmdline`` rather than shelling out to ``pgrep`` so + this works without procps and cannot match the wrong process. + """ + if not self._profile_dir: + return [] + marker = self._profile_dir + own_uid = os.getuid() + killed = [] + try: + for entry in os.listdir('/proc'): + if not entry.isdigit(): + continue + pid = int(entry) + if pid == os.getpid(): + continue + try: + if os.stat(f'/proc/{pid}').st_uid != own_uid: + continue + with open(f'/proc/{pid}/cmdline', 'rb') as fh: + cmdline = fh.read().replace(b'\x00', b' ').decode( + 'utf-8', 'replace' + ) + except (OSError, PermissionError, ProcessLookupError): + continue + if marker in cmdline and 'chrom' in cmdline.lower(): + try: + os.kill(pid, signal.SIGTERM) + killed.append(pid) + except Exception: + continue + except Exception as exc: + _log(f'profile scan failed: {exc}', 'debug') + if killed: + _log(f'terminated {len(killed)} leaked browser(s) on the kiosk ' + f'profile: {killed}') + return killed + + +# ── Module-level helpers ───────────────────────────────────────────── +def _detect_wayland(): + """True when the session looks like Wayland. + + Uses ``linux_display`` when importable (it also fills in an unset + ``WAYLAND_DISPLAY``, which Chromium needs for --ozone-platform-hint), and + falls back to a socket probe so this module stays independently testable. + """ + if os.environ.get('WAYLAND_DISPLAY'): + return True + try: + import linux_display + + linux_display.ensure_session_environment() + return linux_display.is_wayland() + except Exception: + runtime = os.environ.get('XDG_RUNTIME_DIR') or f'/run/user/{os.getuid()}' + return os.path.exists(os.path.join(runtime, 'wayland-0')) + + +def _safe_getpgid(pid): + try: + return os.getpgid(pid) + except Exception: + return None + + +def _kill_process_group(proc, pgid): + """SIGTERM then SIGKILL the browser's process group.""" + target = pgid if pgid and pgid != os.getpgid(0) else None + try: + if target: + os.killpg(target, signal.SIGTERM) + else: + proc.terminate() + except Exception: + try: + proc.terminate() + except Exception: + pass + + # Give Chromium a moment to flush and exit cleanly. + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if proc.poll() is not None: + break + time.sleep(0.1) + + if proc.poll() is None: + try: + if target: + os.killpg(target, signal.SIGKILL) + else: + proc.kill() + except Exception: + pass + try: + proc.wait(timeout=3) + except Exception: + pass + + +def _window_exists_for_pid(pid): + """Best-effort check that ``pid`` owns a visible top-level surface. + + Two independent probes, because neither works everywhere: + + 1. ``/proc//fd`` — Chromium holds the Wayland/X11 socket open once it + has connected and started creating surfaces. + 2. ``/proc//task/*/comm`` — the GPU/renderer children only appear once + the browser has actually started rendering. + + Returns False on any error; the caller falls back to "process alive". + """ + try: + fd_dir = f'/proc/{pid}/fd' + socket_links = 0 + for fd in os.listdir(fd_dir): + try: + target = os.readlink(os.path.join(fd_dir, fd)) + except OSError: + continue + if 'wayland' in target or 'X11-unix' in target: + socket_links += 1 + if socket_links: + return True + except Exception: + pass + + try: + task_dir = f'/proc/{pid}/task' + for tid in os.listdir(task_dir): + try: + with open(os.path.join(task_dir, tid, 'comm')) as fh: + comm = fh.read().strip() + except OSError: + continue + if comm in ('Chrome_ChildIOT', 'Chrome_IOThread'): + return True + except Exception: + pass + + return False diff --git a/linux/linux_display.py b/linux/linux_display.py new file mode 100644 index 0000000..18fcf02 --- /dev/null +++ b/linux/linux_display.py @@ -0,0 +1,425 @@ +"""linux_display.py — display power, keep-awake and rotation for Raspberry Pi OS. + +Why this module exists +---------------------- +On Raspberry Pi OS "Trixie" the desktop session is **Wayland/labwc**, and an +idle blanker is installed and running by default:: + + swayidle -w timeout 600 'wlopm --off *' resume 'wlopm --on *' + +A signage player must never blank, so that line fights the player for control of +the output. The historical implementation in ``main.py`` +(``signal_screen_activity``) shells out to X11 tools that no longer exist on +Trixie (``xdotool``, ``tvservice``, ``ydotool`` are all absent) and uses a +mis-escaped ``wlopm --on \\*`` argument, so it kept the screen awake on none of +the current installs. + +This module replaces that logic with the commands Trixie actually provides: + +* ``wlopm`` — Wayland output power management (present, works) +* ``vcgencmd display_power`` — Raspberry Pi firmware-level display power +* ``wlr-randr`` — output configuration, used for rotation (present) + +All work is best-effort and non-fatal: running the player over SSH, or on a +desktop without a compositor, must never crash or spam the log. + +The platform entry point (``linux/run_linux.py``) installs +:func:`linux_screen_activity` onto ``SignagePlayer`` before playback starts. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import time + +# ── Tunables ───────────────────────────────────────────────────────── +#: Don't re-issue the keep-awake commands more often than this (seconds). +#: ``signal_screen_activity`` is called on a 20 s Kivy interval; the commands +#: are idempotent but spawning processes on a Pi is not free. +MIN_REASSERT_INTERVAL = 5.0 + +#: Set to "1" to disable every display command (diagnostics / desktop testing). +DISABLE_ENV_VAR = 'KIWY_DISPLAY_TOOLS_DISABLED' + +#: Output name used when the compositor does not report one. +DEFAULT_OUTPUT = 'HDMI-A-1' + +_state = { + 'last_awake_at': 0.0, + 'blinker_pids': [], + 'logged_backend': False, + 'warned_no_backend': False, +} + + +def _log(message, level='info'): + """Log through Kivy when available, else print. Never raises.""" + try: + from kivy.logger import Logger + + getattr(Logger, level, Logger.info)(f'[Display] {message}') + except Exception: + try: + if level in ('error', 'warning'): + print(f'[Display] {message}') + except Exception: + pass + + +# ── Environment detection ──────────────────────────────────────────── +def is_wayland(): + """True when a Wayland compositor socket is reachable. + + Deliberately based on the socket, not on ``WAYLAND_DISPLAY``: the variable + is empty in exactly the launch contexts this module exists to fix (systemd, + cron, SSH), where the socket is nevertheless present and usable. + """ + return wayland_socket_path() is not None + + +def is_x11(): + """True when an X11 display is reachable (and Wayland is not).""" + return bool(os.environ.get('DISPLAY')) and not is_wayland() + + +def tools_disabled(): + """Honour the operator escape hatch.""" + return os.environ.get(DISABLE_ENV_VAR, '').strip().lower() in ('1', 'true', 'yes') + + +# ── Command helper ─────────────────────────────────────────────────── +def wayland_socket_path(): + """Absolute path of the Wayland socket, or None.""" + runtime = os.environ.get('XDG_RUNTIME_DIR') or f'/run/user/{os.getuid()}' + display = os.environ.get('WAYLAND_DISPLAY') or '' + if display: + # Absolute names are used as-is; relative names live in XDG_RUNTIME_DIR. + return display if os.path.isabs(display) else os.path.join(runtime, display) + candidate = os.path.join(runtime, 'wayland-0') + return candidate if os.path.exists(candidate) else None + + +def ensure_session_environment(): + """Fill in the display environment for our child processes. + + **This is the bug that made keep-awake silently useless.** A process started + by ``systemd``, a cron wrapper or an SSH session has ``XDG_RUNTIME_DIR`` set + but ``WAYLAND_DISPLAY`` *empty* — the compositor only exports it inside the + desktop session. ``wlopm`` then fails with:: + + ERROR: WAYLAND_DISPLAY is not set. + + and exits 1, so the panel blanks after the idle timeout. Detecting the + socket and exporting the name (derived from its filename, so an unusual + ``wayland-1`` still works) makes the tools function regardless of how the + player was launched. + + Only ever *sets* values that are missing, so an operator-provided value is + never overridden. Returns a dict of what was changed (for logging). + """ + changed = {} + + runtime = os.environ.get('XDG_RUNTIME_DIR') + if not runtime: + fallback = f'/run/user/{os.getuid()}' + if os.path.isdir(fallback): + os.environ['XDG_RUNTIME_DIR'] = fallback + changed['XDG_RUNTIME_DIR'] = fallback + runtime = fallback + + if not os.environ.get('WAYLAND_DISPLAY') and runtime: + try: + # Prefer wayland-0, else the lowest-numbered socket present. + candidates = sorted( + name for name in os.listdir(runtime) + if name.startswith('wayland-') and not name.endswith('.lock') + ) + if candidates: + os.environ['WAYLAND_DISPLAY'] = candidates[0] + changed['WAYLAND_DISPLAY'] = candidates[0] + except Exception: + pass + + return changed + + +def _run(args, timeout=5.0): + """Run a command quietly. Returns (returncode, stdout) — never raises. + + ``stderr`` is discarded: ``wlopm``/``wlr-randr`` are chatty about compositor + details we do not act on, and the player's log is noise-sensitive. + """ + ensure_session_environment() + try: + result = subprocess.run( + args, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + timeout=timeout, + text=True, + check=False, + ) + return result.returncode, (result.stdout or '').strip() + except FileNotFoundError: + return 127, '' + except subprocess.TimeoutExpired: + _log(f'{args[0]} timed out after {timeout}s', 'debug') + return 124, '' + except Exception as exc: # pragma: no cover - defensive + _log(f'{args[0]} failed: {exc}', 'debug') + return 1, '' + + +def _which(name): + try: + return shutil.which(name) + except Exception: + return None + + +# ── Output discovery ───────────────────────────────────────────────── +def list_outputs(): + """Return the connected Wayland output names (best effort). + + Uses ``wlopm`` (no arguments lists `` `` per line), falling + back to ``wlr-randr`` so rotation works even where ``wlopm`` is missing. + """ + if not is_wayland(): + return [] + code, out = _run(['wlopm']) + if code == 0 and out: + names = [] + for line in out.splitlines(): + parts = line.split() + if parts: + names.append(parts[0]) + if names: + return names + code, out = _run(['wlr-randr']) + if code == 0 and out: + names = [] + for line in out.splitlines(): + # Output lines are unindented: "HDMI-A-1 \"...\"" + if line and not line[0].isspace(): + names.append(line.split()[0]) + return names + return [] + + +def _target_output(): + """The output to address, or ``'*'`` so the compositor expands it. + + ``wlopm`` expands ``'*'`` itself, which is more robust than us guessing the + connector name (HDMI-A-1 / HDMI-A-2 / DSI-1 differ per board and port). + """ + return '*' + + +# ── Keep-awake ─────────────────────────────────────────────────────── +def keep_display_awake(force=False): + """Turn the display back on. Cheap, idempotent, rate-limited. + + Returns True when something was actually issued. + """ + if tools_disabled(): + return False + + now = time.monotonic() + if not force and (now - _state['last_awake_at']) < MIN_REASSERT_INTERVAL: + return False + _state['last_awake_at'] = now + + issued = False + failures = [] + + if is_wayland() and _which('wlopm'): + # NOTE: the argument must stay the literal '*' — the shell must not + # expand it (we pass a list, so no shell is involved) and wlopm does + # the matching. The old code passed a backslash-escaped '\*' through + # os.system(), so the compositor matched an output literally named '*' + # and nothing happened. + ensure_session_environment() + code, out = _run(['wlopm', '--on', _target_output()]) + issued = issued or code == 0 + if code != 0: + failures.append(f'wlopm --on {_target_output()} rc={code} {out}'.strip()) + elif is_x11(): + if _which('xset'): + code, _ = _run(['xset', 's', 'reset']) + issued = issued or code == 0 + _run(['xset', 'dpms', 'force', 'on']) + if _which('xdotool'): + # Nudge the pointer by a pixel and back — invisible, but enough to + # reset the X idle counter on compositors without a Wayland path. + code, _ = _run(['xdotool', 'mousemove_relative', '1', '1']) + if code == 0: + _run(['xdotool', 'mousemove_relative', '-1', '-1']) + issued = True + + # Firmware-level backstop: covers a blanked HDMI signal even when the + # compositor never reported the output as off. + if _which('vcgencmd'): + _run(['vcgencmd', 'display_power', '1']) + + if failures and not _state['warned_no_backend']: + _state['warned_no_backend'] = True + _log( + 'keep-awake command failed: ' + '; '.join(failures) + + f' (WAYLAND_DISPLAY={os.environ.get("WAYLAND_DISPLAY")!r})', + 'warning', + ) + elif not issued and not _state['warned_no_backend']: + _state['warned_no_backend'] = True + _log( + 'No usable display backend found (no Wayland socket and no X11 ' + 'display) — keep-awake is inactive.', + 'warning', + ) + return issued + + +def neutralise_idle_blanker(): + """Stop the desktop idle blanker from turning the panel off. + + On Raspberry Pi OS Trixie ``~/.config/labwc/autostart`` starts:: + + swayidle -w timeout 600 'wlopm --off *' resume 'wlopm --on *' + + That is exactly the behaviour a signage player must override, so the + ``swayidle`` process is terminated once at start-up and re-checked + periodically (in case the session restarts it). + + Only processes owned by the current user are touched, and a failure is + never fatal. Set ``KIWY_DISPLAY_TOOLS_DISABLED=1`` to opt out. + """ + if tools_disabled() or not _which('swayidle'): + return [] + + try: + result = subprocess.run( + ['pgrep', '-x', 'swayidle'], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, timeout=5, check=False, + ) + pids = [int(p) for p in (result.stdout or '').split() if p.strip().isdigit()] + except Exception: + return [] + + own_uid = os.getuid() + killed = [] + for pid in pids: + try: + # Only our own processes: swayidle is per-session and killing + # another user's would be a surprise. + if os.stat(f'/proc/{pid}').st_uid != own_uid: + continue + os.kill(pid, 15) # SIGTERM + killed.append(pid) + except (ProcessLookupError, PermissionError, FileNotFoundError): + continue + except Exception: + continue + + if killed: + _log(f'Stopped the idle blanker (swayidle pids: {killed}) — the display ' + f'will stay on. Disable with {DISABLE_ENV_VAR}=1.') + return killed + + +def _log_backend_once(): + if _state['logged_backend']: + return + _state['logged_backend'] = True + backend = 'wayland' if is_wayland() else ('x11' if is_x11() else 'none') + _log(f'backend={backend} outputs={list_outputs() or "unknown"} ' + f'WAYLAND_DISPLAY={os.environ.get("WAYLAND_DISPLAY") or "(unset)"} ' + f'wlopm={bool(_which("wlopm"))} vcgencmd={bool(_which("vcgencmd"))}') + + +# ── SignagePlayer replacement ──────────────────────────────────────── +def linux_screen_activity(self, dt): + """Drop-in replacement for ``SignagePlayer.signal_screen_activity``. + + Bound to a 20 s Kivy interval, so it must be cheap and must never raise. + """ + try: + _log_backend_once() + keep_display_awake() + # Cheap re-check: the blanker may have been restarted by the session, + # e.g. after a compositor reload. + if (time.monotonic() - _state.get('last_blinker_check', 0.0)) > 60.0: + _state['last_blinker_check'] = time.monotonic() + neutralise_idle_blanker() + except Exception as exc: # pragma: no cover - must never break the Clock + _log(f'screen activity signal failed (non-fatal): {exc}', 'debug') + + +# ── Orientation / resolution (Wayland-native) ──────────────────────── +#: Logical rotation for each supported orientation value from app_config.json. +ORIENTATION_TRANSFORMS = { + 'landscape': 'normal', + 'portrait': '90', + 'portrait-inverted': '270', + 'landscape-inverted': '180', +} + + +def apply_orientation(orientation): + """Rotate the display to match the configured orientation. + + ``Window.size`` cannot rotate a fullscreen Wayland surface, so the rotation + has to happen at the output level. ``wlr-randr`` is the tool that works on + labwc; the call is skipped silently when it is unavailable. + + Returns True when a transform was applied. + """ + if tools_disabled(): + return False + + key = str(orientation or '').strip().lower() + transform = ORIENTATION_TRANSFORMS.get(key) + if not transform or transform == 'normal': + return False + + if not is_wayland() or not _which('wlr-randr'): + _log(f'Orientation "{orientation}" requested but wlr-randr/Wayland is ' + f'unavailable — leaving the output unrotated.', 'warning') + return False + + outputs = list_outputs() or [DEFAULT_OUTPUT] + applied = False + for name in outputs: + code, _ = _run(['wlr-randr', '--output', name, '--transform', transform]) + applied = applied or code == 0 + if applied: + _log(f'Applied orientation "{orientation}" (transform={transform}) to ' + f'{outputs}') + return applied + + +def status(): + """Diagnostic snapshot (used by tests and the startup banner).""" + return { + 'wayland': is_wayland(), + 'wayland_socket': wayland_socket_path(), + 'wayland_display': os.environ.get('WAYLAND_DISPLAY') or '(unset)', + 'x11': is_x11(), + 'disabled': tools_disabled(), + 'outputs': list_outputs(), + 'wlopm': bool(_which('wlopm')), + 'wlr_randr': bool(_which('wlr-randr')), + 'vcgencmd': bool(_which('vcgencmd')), + } + + +if __name__ == '__main__': # pragma: no cover - manual diagnostics + import json + import sys + + if len(sys.argv) > 1 and sys.argv[1] == '--awake': + print('awake issued:', keep_display_awake(force=True)) + print('blinker killed:', neutralise_idle_blanker()) + print(json.dumps(status(), indent=2)) diff --git a/linux/monitor_player.py b/linux/monitor_player.py new file mode 100644 index 0000000..e549565 --- /dev/null +++ b/linux/monitor_player.py @@ -0,0 +1,467 @@ +"""monitor_player.py — CPU, GPU, temperature and memory monitor for the player. + +A 24/7 signage player on a Raspberry Pi 4 fails in slow, quiet ways: the SoC +throttles, the CPU governor parks a core, memory creeps up until the OOM killer +picks a victim. This samples the numbers that reveal those trends before they +become a blank screen. + +Usage: + # live view, 5s interval, until Ctrl+C + .venv/bin/python linux/monitor_player.py + + # 10 minutes at 2s, also written to CSV + .venv/bin/python linux/monitor_player.py --interval 2 --duration 600 + + # background logging only (no live view) + .venv/bin/python linux/monitor_player.py --quiet --duration 3600 \ + --csv logs/monitor-$(date +%F-%H%M).csv + +What is measured, and why each one matters: + +| Metric | Why it matters here | +|--------|--------------------| +| Temperature | Pi 4 throttles at 80 °C (soft limit 85 °C). Sustained high temp means the case or heatsink is inadequate for 24/7. | +| Throttle flags | ``vcgencmd get_throttled`` distinguishes *currently throttled* from *has throttled since boot* — the second one only (bits 16-19) reveals a problem that already happened. | +| ARM clock | Dropping below the configured max mid-run is the signature of thermal/voltage capping. | +| V3D / pixel clock | The VideoCore GPU clock. A weblink drives it; a static image barely does. | +| Core voltage | Under-voltage (bit 0 of the throttle mask) is almost always an inadequate PSU, and it corrupts SD cards. | +| Per-core CPU | One pegged core is normal for Kivy (single-threaded main loop); all four pegged is not. | +| Player PSS | Proportional set size of the player process — the honest figure. | +| Chromium total | A *count* as much as memory: a rising count means weblinks are leaking. | +| /dev/shm | Chromium renders through shared memory; if it fills, pages render blank. | + +GPU utilisation is deliberately absent: the Pi 4's V3D exposes no +``gpu_busy_percent`` (that is a Pi 5 / v3d-drm feature) and +``/sys/kernel/debug/dri/0/gpu_stats`` does not exist here. GPU activity is +therefore inferred from the clock domains and the temperature, which is the +best this hardware offers. +""" + +from __future__ import annotations + +import argparse +import csv +import os +import subprocess +import sys +import time +from datetime import datetime + +try: + import psutil +except ImportError: + print('psutil is required: .venv/bin/pip install psutil', file=sys.stderr) + raise SystemExit(2) + + +# ── Constants ──────────────────────────────────────────────────────── +#: Pi 4 begins soft-throttling around here. Warning threshold, not a limit. +TEMP_WARN_C = 70.0 +#: Active soft-throttle limit on Pi 4. +TEMP_CRITICAL_C = 80.0 + +#: ``vcgencmd get_throttled`` bit meanings. The 16-19 group is sticky: it +#: records that the condition occurred at any point since boot, which is the +#: only way to catch an intermittent brown-out on an unattended device. +THROTTLE_BITS = { + 0: ('NOW', 'under-voltage'), + 1: ('NOW', 'arm frequency capped'), + 2: ('NOW', 'currently throttled'), + 3: ('NOW', 'soft temperature limit'), + 16: ('HAS', 'under-voltage occurred'), + 17: ('HAS', 'arm frequency capping occurred'), + 18: ('HAS', 'throttling occurred'), + 19: ('HAS', 'soft temperature limit occurred'), +} + +CLOCK_DOMAINS = ('arm', 'core', 'v3d', 'h264', 'pixel') + +CSV_FIELDS = [ + 'timestamp', 'uptime_s', + 'cpu_total_pct', 'cpu0_pct', 'cpu1_pct', 'cpu2_pct', 'cpu3_pct', + 'load1', 'load5', 'load15', + 'temp_c', 'volt_core_v', + 'arm_mhz', 'core_mhz', 'v3d_mhz', 'pixel_mhz', 'h264_mhz', + 'governor', 'arm_cur_mhz_cfg', + 'mem_used_mb', 'mem_avail_mb', 'shm_used_mb', + 'player_pss_mb', 'player_cpu_pct', 'player_threads', + 'chromium_procs', 'chromium_pss_mb', + 'throttled_raw', 'throttle_flags', +] + + +def _run(args, timeout=5): + """Run a command, return stripped stdout, or '' on any failure.""" + try: + result = subprocess.run( + args, capture_output=True, text=True, timeout=timeout, check=False, + ) + return (result.stdout or '').strip() + except Exception: + return '' + + +def _read(path): + try: + with open(path) as fh: + return fh.read().strip() + except Exception: + return '' + + +# ── Individual metrics ─────────────────────────────────────────────── +def read_temp_c(): + """SoC temperature in °C from hwmon, falling back to vcgencmd/thermal.""" + raw = _read('/sys/class/hwmon/hwmon0/temp1_input') + if raw.isdigit(): + return int(raw) / 1000.0 + for zone in ('/sys/class/thermal/thermal_zone0/temp',): + raw = _read(zone) + if raw.isdigit(): + return int(raw) / 1000.0 + out = _run(['vcgencmd', 'measure_temp']) + # "temp=50.1'C" + try: + return float(out.split('=')[1].split("'")[0]) + except Exception: + return None + + +def read_clock_mhz(domain): + """Clock frequency in MHz for a vcgencmd domain.""" + out = _run(['vcgencmd', 'measure_clock', domain]) + # "frequency(48)=1800457088" + try: + return int(out.split('=')[1]) / 1_000_000.0 + except Exception: + return None + + +def read_core_volts(): + out = _run(['vcgencmd', 'measure_volts', 'core']) + # "volt=0.9160V" + try: + return float(out.split('=')[1].rstrip('V')) + except Exception: + return None + + +def read_throttled(): + """(raw_int, [human readable flags]) from vcgencmd get_throttled.""" + out = _run(['vcgencmd', 'get_throttled']) + # "throttled=0x0" + try: + raw = int(out.split('=')[1], 16) + except Exception: + return None, [] + flags = [] + for bit, (when, label) in THROTTLE_BITS.items(): + if raw & (1 << bit): + flags.append(f'{when}:{label}') + return raw, flags + + +def pss_mb(pid): + """PSS in MB for a process, or None. RSS double-counts shared pages.""" + total_kb = 0 + try: + with open(f'/proc/{pid}/smaps_rollup') as fh: + for line in fh: + if line.startswith('Pss:'): + total_kb = int(line.split()[1]) + break + except Exception: + return None + return total_kb / 1024.0 + + +def find_procs(pattern): + """PIDs whose cmdline matches ``pattern``, excluding this script.""" + pids = [] + me = os.getpid() + for proc in psutil.process_iter(['pid', 'cmdline']): + try: + if proc.info['pid'] == me: + continue + cmdline = ' '.join(proc.info['cmdline'] or []) + if pattern in cmdline and 'monitor_player' not in cmdline: + pids.append(proc.info['pid']) + except Exception: + continue + return pids + + +#: ``psutil.Process`` objects kept alive between samples. ``cpu_percent()`` +#: needs a *previous* reading for the same process to compute a delta, so a +#: freshly constructed Process always reports 0.0. Caching these is what makes +#: the player's own CPU figure meaningful instead of a constant zero. +_proc_cache: dict[int, 'psutil.Process'] = {} + + +def player_cpu_percent(pid): + """CPU % for ``pid`` since the previous sample.""" + try: + proc = _proc_cache.get(pid) + if proc is None or not proc.is_running(): + proc = psutil.Process(pid) + _proc_cache[pid] = proc + proc.cpu_percent(interval=None) # establish the baseline + return 0.0 + return proc.cpu_percent(interval=None) + except Exception: + return None + + +def sample(): + """Take one measurement. Never raises; missing metrics become None.""" + now = time.time() + row = {'timestamp': datetime.now().isoformat(timespec='seconds')} + + # CPU. interval=None means "since the previous call" — with a fixed sample + # period this yields the usage over exactly that window. + try: + row['cpu_total_pct'] = psutil.cpu_percent(interval=None) + per_core = psutil.cpu_percent(interval=None, percpu=True) + for i in range(4): + row[f'cpu{i}_pct'] = per_core[i] if i < len(per_core) else None + load = os.getloadavg() + row['load1'], row['load5'], row['load15'] = load + except Exception: + row.update({'cpu_total_pct': None, 'load1': None, 'load5': None, + 'load15': None}) + + # Thermal / power + row['temp_c'] = read_temp_c() + row['volt_core_v'] = read_core_volts() + + # Clocks + for domain in CLOCK_DOMAINS: + row[f'{domain}_mhz'] = read_clock_mhz(domain) + + # Governor and configured max + row['governor'] = _read( + '/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor') + cur = _read('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq') + row['arm_cur_mhz_cfg'] = int(cur) / 1000.0 if cur.isdigit() else None + + # Memory + try: + mem = psutil.virtual_memory() + row['mem_used_mb'] = (mem.total - mem.available) / 1024 / 1024 + row['mem_avail_mb'] = mem.available / 1024 / 1024 + shm = psutil.disk_usage('/dev/shm') + row['shm_used_mb'] = shm.used / 1024 / 1024 + except Exception: + row.update({'mem_used_mb': None, 'mem_avail_mb': None, + 'shm_used_mb': None}) + + # The player + player_pids = find_procs('run_linux.py') + if player_pids: + pid = player_pids[0] + row['player_pss_mb'] = pss_mb(pid) + row['player_cpu_pct'] = player_cpu_percent(pid) + try: + proc = _proc_cache.get(pid) or psutil.Process(pid) + with proc.oneshot(): + row['player_threads'] = proc.num_threads() + # Uptime from the process, for correlating with playback. + row['uptime_s'] = now - proc.create_time() + except Exception: + pass + else: + row['player_pss_mb'] = None + row['player_cpu_pct'] = None + row['player_threads'] = None + + # Chromium tree (weblinks). Count is as important as memory: a rising + # count means teardown is leaking, which is what kills a 24/7 install. + chromium_pids = find_procs('chromium') + row['chromium_procs'] = len(chromium_pids) + total = 0.0 + for pid in chromium_pids: + value = pss_mb(pid) + if value: + total += value + row['chromium_pss_mb'] = total if chromium_pids else 0.0 + + # Throttling + raw, flags = read_throttled() + row['throttled_raw'] = hex(raw) if raw is not None else None + row['throttle_flags'] = ','.join(flags) if flags else '' + + # Fill any field we never set so the CSV stays rectangular. + for field in CSV_FIELDS: + row.setdefault(field, None) + return row + + +# ── Presentation ───────────────────────────────────────────────────── +def fmt(value, width=6, spec='.1f'): + if value is None: + return ' ' * (width - 2) + '--' + return f'{value:{width}{spec}}' + + +def print_header(): + print() + print(' time CPU% cores(0-3) temp ARM V3D volt RAM ' + 'player chrome gov flags') + print(' ' + '-' * 84) + + +def print_row(row, tick): + cores = ' '.join( + fmt(row.get(f'cpu{i}_pct'), 3, '.0f') for i in range(4)) + flags = (row.get('throttle_flags') or '').replace('HAS:', 'had:') \ + .replace('NOW:', 'NOW:') + warn = '' + temp = row.get('temp_c') + if temp is not None and temp >= TEMP_CRITICAL_C: + warn = ' ***HOT***' + elif temp is not None and temp >= TEMP_WARN_C: + warn = ' *warm*' + + governor = (row.get('governor') or '')[:6] + print( + f' {row["timestamp"][11:19]} ' + f'{fmt(row.get("cpu_total_pct"), 4, ".0f")} ' + f' {cores} ' + f'{fmt(temp, 5)}C ' + f'{fmt(row.get("arm_mhz"), 6, ".0f")} ' + f'{fmt(row.get("v3d_mhz"), 5, ".0f")} ' + f'{fmt(row.get("volt_core_v"), 6, ".3f")}V' + f'{fmt(row.get("mem_used_mb"), 5, ".0f")}M ' + f'{fmt(row.get("player_pss_mb"), 4, ".0f")}M ' + f'{fmt(row.get("chromium_pss_mb"), 5, ".0f")}M ' + f'{governor:<6} ' + f'{flags}{warn}' + ) + + +def summarise(rows): + """Print min/avg/max for the run — the part that reveals a trend.""" + if not rows: + return + print('\n ' + '=' * 84) + print(f' SUMMARY over {len(rows)} samples ' + f'({rows[0]["timestamp"][11:19]} -> {rows[-1]["timestamp"][11:19]})') + print(' ' + '=' * 84) + + def stat(field, spec='.1f', unit=''): + values = [r[field] for r in rows + if isinstance(r.get(field), (int, float))] + if not values: + return f' {field:<18} (no data)' + return (f' {field:<18} min {min(values):>8{spec}}{unit} ' + f'avg {sum(values)/len(values):>8{spec}}{unit} ' + f'max {max(values):>8{spec}}{unit}') + + for field, spec, unit in ( + ('cpu_total_pct', '.1f', '%'), + ('temp_c', '.1f', 'C'), + ('arm_mhz', '.0f', 'MHz'), + ('v3d_mhz', '.0f', 'MHz'), + ('volt_core_v', '.3f', 'V'), + ('mem_avail_mb', '.0f', 'MB'), + ('player_pss_mb', '.0f', 'MB'), + ('player_cpu_pct', '.1f', '%'), + ('chromium_procs', '.0f', ''), + ('chromium_pss_mb', '.0f', 'MB'), + ): + print(stat(field, spec, unit)) + + # Trend: first vs last quarter, which is what matters for a leak. + def trend(field): + values = [r[field] for r in rows + if isinstance(r.get(field), (int, float))] + if len(values) < 8: + return '' + head = values[:max(1, len(values) // 4)] + tail = values[-max(1, len(values) // 4):] + delta = sum(tail) / len(tail) - sum(head) / len(head) + arrow = 'RISING ' if delta > 0 else 'falling' + return (f' {field:<18} {arrow} {delta:+8.1f} ' + f'(first->last quarter)') + + print('\n Trend (the leak check):') + for field in ('player_pss_mb', 'chromium_procs', 'mem_avail_mb', 'temp_c'): + line = trend(field) + if line: + print(line) + + # Throttle history is the headline finding on a Pi. + all_flags = set() + for row in rows: + if row.get('throttle_flags'): + all_flags.update(row['throttle_flags'].split(',')) + print('\n Throttle / power events during this run:') + print(f' {", ".join(sorted(all_flags)) if all_flags else "none — clean"}') + + +def main(): + parser = argparse.ArgumentParser( + description='Monitor CPU, GPU clock, temperature and memory for the player.') + parser.add_argument('--interval', type=float, default=5.0, + help='seconds between samples (default 5)') + parser.add_argument('--duration', type=float, default=0, + help='seconds to run; 0 = until Ctrl+C (default 0)') + parser.add_argument('--csv', default='', + help='write samples to this CSV file') + parser.add_argument('--quiet', action='store_true', + help='no live output (for background logging)') + args = parser.parse_args() + + # Prime the CPU counters: the first cpu_percent() call always returns 0.0 + # because it has no previous sample to compare against. + psutil.cpu_percent(interval=None) + psutil.cpu_percent(interval=None, percpu=True) + for pid in find_procs('run_linux.py'): + try: + _proc_cache[pid] = psutil.Process(pid) + _proc_cache[pid].cpu_percent(interval=None) + except Exception: + pass + + writer = None + csv_handle = None + if args.csv: + os.makedirs(os.path.dirname(os.path.abspath(args.csv)), exist_ok=True) + csv_handle = open(args.csv, 'w', newline='') + writer = csv.DictWriter(csv_handle, fieldnames=CSV_FIELDS) + writer.writeheader() + + if not args.quiet: + print_header() + + rows = [] + started = time.time() + tick = 0 + try: + while True: + time.sleep(args.interval) + tick += 1 + row = sample() + rows.append(row) + if writer: + writer.writerow(row) + csv_handle.flush() + if not args.quiet: + print_row(row, tick) + if args.duration and (time.time() - started) >= args.duration: + break + except KeyboardInterrupt: + if not args.quiet: + print('\n stopped by user') + finally: + if csv_handle: + csv_handle.close() + if not args.quiet: + print(f' CSV written to {args.csv}') + if not args.quiet: + summarise(rows) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/linux/run_linux.py b/linux/run_linux.py new file mode 100644 index 0000000..7d0eaf0 --- /dev/null +++ b/linux/run_linux.py @@ -0,0 +1,555 @@ +""" +Kiwy Signage Player — Linux / Raspberry Pi entry point +------------------------------------------------------ +This module prepares a Pi-correct environment, imports the shared application, +then injects the platform-specific behaviour. ``src/main.py`` itself stays +cross-platform and holds no platform patches. + +Usage: + python3 linux/run_linux.py # development / manual run + bash linux/start_player.sh # supervised run (watchdog) + +Target: Raspberry Pi OS "Trixie" 64-bit (Debian 13, aarch64, labwc/Wayland). + +What this file is responsible for +--------------------------------- +1. Environment, set *before* Kivy is imported (video/audio/GL/input backends). +2. ``sys.path`` so the shared modules in ``src/`` import cleanly. +3. Injecting the Linux web-link adapter through the existing + ``SignagePlayer.weblink_adapter_factory`` hook. +4. Replacing ``signal_screen_activity`` with the Wayland-aware implementation + in ``linux_display.py`` (the inherited one calls X11 tools absent on Trixie). +5. Pointing ``player_auth.json`` at the player's data directory. +6. A fatal-error surface that works without a console. +""" + +import json +import logging +import os +import platform +import sys +import traceback +from pathlib import Path + +# ===================================================================== +# 0. Resolve directories +# ===================================================================== +# Layout: /linux/run_linux.py -> is the data directory and +# /src holds the shared modules. +_HERE = Path(__file__).resolve().parent +ROOT_DIR = _HERE.parent +SRC_DIR = ROOT_DIR / 'src' +LOG_DIR = ROOT_DIR / 'logs' + +# The shared modules (main, weblink_session, player_auth, ...) live in src/. +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +DATA_DIR = str(ROOT_DIR) +LOG_DIR.mkdir(parents=True, exist_ok=True) + +# Consumed by src/playback_trace.py and our own helpers, so trace/log files land +# next to the player rather than in whatever directory it happened to start in. +os.environ.setdefault('KIWY_DATA_DIR', DATA_DIR) + + +def _early_log(message): + """Log before Kivy's Logger exists (and mirror to a file).""" + line = f'[run_linux] {message}' + print(line, flush=True) + try: + with open(LOG_DIR / 'startup.log', 'a') as fh: + from datetime import datetime + fh.write(f"{datetime.now().isoformat(timespec='seconds')} {line}\n") + except Exception: + pass + + +def _show_error(message, details=''): + """Report a fatal start-up failure where an operator can actually see it. + + On a Pi kiosk there is no console and no dialog framework yet (Kivy failed), + so the text is written to a log AND printed. If a ``zenity``-style dialog is + available it is used as a bonus, never as a requirement. + """ + _early_log(f'FATAL: {message}') + if details: + _early_log(details) + try: + crash_log = LOG_DIR / 'fatal_crash.log' + with open(crash_log, 'w') as fh: + fh.write(f'FATAL: {message}\n\n{details}\n') + except Exception: + pass + try: + import shutil + import subprocess + if shutil.which('zenity') and os.environ.get('WAYLAND_DISPLAY'): + subprocess.Popen( + ['zenity', '--error', '--width=520', + '--title=Kiwy Signage Player', + '--text=' + f'{message}\n\nSee logs/fatal_crash.log'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except Exception: + pass + + +# ===================================================================== +# 1. Environment — must be set BEFORE Kivy is imported +# ===================================================================== +# main.py uses os.environ.setdefault(), so values set here win. Anything the +# operator exports explicitly is left untouched (setdefault semantics). +def _configure_environment(): + env = os.environ + + # ── Session environment FIRST ─────────────────────────────────── + # A launch from systemd, cron or SSH has XDG_RUNTIME_DIR set but + # WAYLAND_DISPLAY *empty* — the compositor only exports it inside the + # desktop session. SDL2 does NOT scan XDG_RUNTIME_DIR on its own: with the + # variable unset it fails with "wayland not available" even though the + # socket exists. Detecting and exporting it here is what makes the player + # start under systemd/autostart at all. + try: + from linux_display import ensure_session_environment + + changed = ensure_session_environment() + if changed: + _early_log(f'session environment filled in: {changed}') + except Exception as exc: + _early_log(f'session environment detection skipped: {exc}') + + # ── Video output ──────────────────────────────────────────────── + # Raspberry Pi OS Trixie runs a Wayland (labwc) session. A comma-separated + # list is valid: Kivy splits it and SDL2 picks the first driver that + # initialises, so this also covers XWayland ('x11') and headless ('dummy'). + env.setdefault('SDL_VIDEODRIVER', 'wayland,x11,dummy') + + # ── Audio ─────────────────────────────────────────────────────── + # Trixie ships PipeWire, which exposes an ALSA compatibility layer and a + # PulseAudio-compatible socket. NOTE: main.py sets SDL_AUDIODRIVER twice + # (once via setdefault at import, once with a hard setdefault later), so + # the value chosen here is the one that sticks. + env.setdefault('SDL_AUDIODRIVER', 'alsa,pulse,dummy') + + # ── Kivy window / GL ──────────────────────────────────────────── + env.setdefault('KIVY_WINDOW', 'sdl2') + # Pi 4/5 use Mesa + V3D. 'gl' is the safe default; operators on odd stacks + # can export KIVY_GL_BACKEND=gles/sdl2 to change it. + env.setdefault('KIVY_GL_BACKEND', 'gl') + env.setdefault('KIVY_INPUTPROVIDERS', 'wayland,x11,probesysfs,hidinput,mtdev') + + # ── Media (ffpyplayer, hardware-friendly) ─────────────────────── + env.setdefault('KIVY_VIDEO', 'ffpyplayer') + env.setdefault('KIVY_AUDIO', 'ffpyplayer') + env.setdefault('FFPYPLAYER_CODECS', 'h264,h265,vp9,vp8') + # Pi 4 has 4 cores; more threads than that hurts more than it helps. + env.setdefault('FFMPEG_THREADS', '2') + env.setdefault('LIBPLAYER_BUFFER', '1048576') + + # ── Misc ──────────────────────────────────────────────────────── + env.setdefault('SDL_VIDEO_ALLOW_SCREENSAVER', '0') + # Kivy's own home; keeping it inside the project avoids surprises when the + # player is started by systemd with a different HOME. + env.setdefault('KIVY_HOME', str(ROOT_DIR / '.kivy')) + + +_configure_environment() + + +# ===================================================================== +# 2. Logging +# ===================================================================== +def _configure_logging(): + """Route Kivy/root logging to logs/ as well as stderr. + + journald already captures stderr when started by systemd, but a plain + ``runner`` launch (or a labwc autostart) would otherwise lose it. + """ + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname).1s %(name)s: %(message)s', + ) + try: + from logging.handlers import RotatingFileHandler + handler = RotatingFileHandler( + LOG_DIR / 'player.log', maxBytes=2 * 1024 * 1024, backupCount=2 + ) + handler.setFormatter( + logging.Formatter('%(asctime)s %(levelname).1s %(name)s: %(message)s') + ) + logging.getLogger().addHandler(handler) + except Exception as exc: + _early_log(f'file logging unavailable: {exc}') + + +_configure_logging() + + +# ===================================================================== +# 3. Import the shared application (env is already Pi-correct) +# ===================================================================== +def _bind_name(func, name): + """Give ``func`` the name Kivy will use when it resolves the callback later. + + Kivy's ``Clock`` wraps callbacks in a ``WeakMethod`` keyed on + ``func.__name__`` and re-resolves them with ``getattr(instance, name)``. A + replacement assigned under a different name than it was defined with + therefore raises ``AttributeError`` the first time the Clock fires — after + a delay, far from the cause. Renaming the function keeps the two in sync. + """ + try: + func.__name__ = name + func.__qualname__ = name + except Exception: + pass + return func + + +def _import_main(): + """Import ``src/main.py``, translating import failures into clear advice.""" + try: + import main as signage_main # noqa: WPS433 - deliberate late import + return signage_main + except SystemExit as exc: + # Kivy raises SystemExit(1) when no window provider can be created. + raise RuntimeError( + f'Kivy could not create a window (SystemExit {exc.code}).\n' + 'This usually means the display backends are missing.\n' + 'Try: sudo apt install libsdl2-2.0-0 libgl1-mesa-dri\n' + f'Backend: SDL_VIDEODRIVER={os.environ.get("SDL_VIDEODRIVER")} ' + f'KIVY_GL_BACKEND={os.environ.get("KIVY_GL_BACKEND")} ' + f'WAYLAND_DISPLAY={os.environ.get("WAYLAND_DISPLAY")} ' + f'DISPLAY={os.environ.get("DISPLAY")}' + ) from exc + + +# ===================================================================== +# 4. Platform patches +# ===================================================================== +def _reassert_graphics_config(): + """Re-apply fullscreen/window config after main.py has run its own. + + ``main.py`` sets ``graphics.fullscreen = 0`` and ``window_state = maximized`` + at import time. On the Pi the window must be a true fullscreen surface, so + the values are re-asserted here — after main's module body, before + ``App.run()`` creates the window. + """ + try: + from kivy.config import Config + + Config.set('graphics', 'fullscreen', '1') + Config.set('graphics', 'borderless', '1') + Config.set('graphics', 'resizable', '0') + Config.set('graphics', 'multisampling', '0') + Config.set('graphics', 'fast_rgba', '1') + Config.set('graphics', 'maxfps', '60') + Config.set('kivy', 'exit_on_escape', '0') + except Exception as exc: + _early_log(f'graphics config re-assert failed (non-fatal): {exc}') + + +def _patch_display(signage_main): + """Install the Wayland-aware screen-activity handler. + + ``main.py``'s inherited implementation shells out to ``tvservice``, + ``xdotool`` and ``ydotool`` — none of which exist on Trixie — and passes a + mis-escaped ``wlopm --on \\*``. It therefore never prevents blanking. + + NOTE: the replacement is registered under **every** name Kivy might use to + resolve the callback. Kivy's ``Clock`` stores ``func.__name__`` in a + WeakMethod and later does ``getattr(instance, that_name)``, so the attribute + name must match the function's own ``__name__`` exactly — otherwise the + Clock raises ``AttributeError`` the first time it fires (measured: the + player died 20 s in, on the first ``signal_screen_activity`` tick, with + ``'SignagePlayer' object has no attribute 'linux_screen_activity'``). + """ + try: + from linux_display import linux_screen_activity + except ImportError as exc: + _early_log(f'linux_display unavailable, keeping built-in handler: {exc}') + return False + + # Own name first: this is what Kivy's WeakMethod will look up. + setattr(signage_main.SignagePlayer, 'linux_screen_activity', linux_screen_activity) + # Then the attribute the app actually schedules. + signage_main.SignagePlayer.signal_screen_activity = linux_screen_activity + return True + + +def _patch_weblink_engines(signage_main): + """Inject the Raspberry Pi Chromium adapter. + + ``WeblinkSession`` owns launch/visibility/interaction/teardown; the adapter + is the only platform-specific part. Injecting it via the class-level + ``weblink_adapter_factory`` hook keeps ``main.py`` free of platform code. + """ + try: + from linux_browser import LinuxChromiumAdapter, find_linux_browser + except ImportError as exc: + _early_log(f'linux_browser unavailable, using the generic adapter: {exc}') + return False + + from kivy.logger import Logger + + browser = find_linux_browser() + + def _linux_weblink_adapter_factory(player): + adapters = [] + if browser: + adapters.append(LinuxChromiumAdapter(browser_path=browser, kiosk=True)) + else: + Logger.warning( + 'SignagePlayer: no Chromium/Chrome found — web links will be ' + 'skipped. Install with: sudo apt install chromium' + ) + return adapters + + signage_main.SignagePlayer.weblink_adapter_factory = staticmethod( + _bind_name(_linux_weblink_adapter_factory, 'weblink_adapter_factory') + ) + Logger.info( + f'SignagePlayer: web-link engine -> ' + f'{"chromium-kiosk-linux (" + browser + ")" if browser else "none found"}' + ) + return True + + +def _patch_temp_paths(signage_main): + """Make the Settings "Test connection" use a portable temp path. + + ``main.py`` hard-codes ``/tmp/temp_auth_test.json``. That usually works on + Linux, but the file can be left behind with live credentials and breaks + outright when ``/tmp`` is private (systemd ``PrivateTmp``) or read-only. + """ + try: + import tempfile + import threading + from kivy.clock import Clock + from player_auth import PlayerAuth + except Exception as exc: + _early_log(f'temp-path patch skipped: {exc}') + return False + + def _linux_test_connection(self): + """Copy of the original flow, using ``tempfile.gettempdir()``.""" + self.ids.connection_status.text = 'Testing connection...' + self.ids.connection_status.color = (1, 0.7, 0, 1) + + def run_test(): + temp_file = os.path.join(tempfile.gettempdir(), 'temp_auth_test.json') + try: + server_ip = self.ids.server_input.text.strip() + screen_name = self.ids.screen_input.text.strip() + quickconnect = self.ids.quickconnect_input.text.strip() + port = self.ids.port_input.text.strip() or self.player.config.get('port', '') + use_https = self.player.config.get('use_https', True) + verify_ssl = self.player.config.get('verify_ssl', True) + + if not all([server_ip, screen_name, quickconnect]): + Clock.schedule_once( + lambda dt: self.update_connection_status('Error: Fill all fields', False) + ) + return + + if server_ip.startswith(('http://', 'https://')): + server_url = server_ip + if ':' not in server_ip.replace('https://', '').replace('http://', ''): + if port and port not in ('443', '80'): + server_url = f'{server_ip}:{port}' + else: + protocol = 'https' if use_https else 'http' + if ':' in server_ip: + server_url = f'{protocol}://{server_ip}' + else: + server_url = f'{protocol}://{server_ip}:{port}' if port else f'{protocol}://{server_ip}' + + auth = PlayerAuth(temp_file, use_https=use_https, verify_ssl=verify_ssl) + success, error = auth.authenticate( + server_url=server_url, hostname=screen_name, + quickconnect_code=quickconnect, + ) + + if success: + player_name = auth.get_player_name() + Clock.schedule_once( + lambda dt: self.update_connection_status(f'✓ Connected: {player_name}', True) + ) + else: + Clock.schedule_once( + lambda dt: self.update_connection_status(f'✗ Failed: {error}', False) + ) + except Exception as exc: + Clock.schedule_once( + lambda dt: self.update_connection_status(f'✗ Error: {exc}', False) + ) + finally: + # Never leave a file containing credentials behind. + try: + if os.path.exists(temp_file): + os.remove(temp_file) + except Exception: + pass + + threading.Thread(target=run_test, daemon=True).start() + + signage_main.SettingsPopup.test_connection = _bind_name( + _linux_test_connection, 'test_connection' + ) + return True + + +def _patch_auth_path(signage_main): + """Keep ``player_auth.json`` in the player's data directory. + + ``player_auth.py`` defaults to the relative path ``player_auth.json``, which + resolves against the *current working directory*. Started by systemd, + labwc-autostart or a cron wrapper, that cwd differs — so the player would + "forget" its authentication and re-register on every launch. Pinning it to + an absolute path in the data dir removes that class of bug. + """ + try: + import player_auth as player_auth_module + except Exception as exc: + _early_log(f'auth-path patch skipped: {exc}') + return False + + local_auth = os.path.join(DATA_DIR, 'player_auth.json') + original_init = player_auth_module.PlayerAuth.__init__ + + def _linux_auth_init(self, config_file='player_auth.json', + use_https=True, verify_ssl=True): + try: + if not os.path.isabs(config_file): + config_file = os.path.join(DATA_DIR, os.path.basename(config_file)) + except Exception: + config_file = local_auth + original_init(self, config_file, use_https=use_https, verify_ssl=verify_ssl) + + player_auth_module.PlayerAuth.__init__ = _bind_name(_linux_auth_init, '__init__') + _early_log(f'player auth file -> {local_auth}') + + # get_playlists_v2 may already hold a globally cached auth instance created + # with the old relative path; drop it so the redirect applies. + try: + import get_playlists_v2 as gp + if getattr(gp, '_auth_instance', None) is not None: + gp._auth_instance = None + except Exception: + pass + + # Point the "Reset auth" button at the real file. + def _linux_reset_player_auth(self): + try: + if os.path.exists(local_auth): + os.remove(local_auth) + signage_main.Logger.info(f'SettingsPopup: Deleted auth file: {local_auth}') + self._show_temp_message( + '✓ Authentication reset - will reauthenticate on restart', + (0, 1, 0, 1), + ) + except Exception as exc: + signage_main.Logger.error(f'SettingsPopup: Failed to reset auth: {exc}') + + signage_main.SettingsPopup.reset_player_auth = _bind_name( + _linux_reset_player_auth, 'reset_player_auth' + ) + return True + + +def _patch_startup_hooks(signage_main): + """Apply orientation and stop the idle blanker once the app is up.""" + original_on_start = signage_main.SignagePlayerApp.on_start + + def _linux_on_start(self): + original_on_start(self) + try: + from linux_display import ( + apply_orientation, keep_display_awake, neutralise_idle_blanker, + status, + ) + + _early_log(f'display backend: {json.dumps(status())}') + # The desktop ships an idle blanker that powers the panel off after + # 10 minutes; a signage player must win that contest. + neutralise_idle_blanker() + keep_display_awake(force=True) + + root = getattr(self, 'root', None) + orientation = '' + if root is not None: + orientation = (getattr(root, 'config', {}) or {}).get('orientation', '') + if orientation: + apply_orientation(orientation) + except Exception as exc: + _early_log(f'startup display hook failed (non-fatal): {exc}') + + signage_main.SignagePlayerApp.on_start = _bind_name(_linux_on_start, 'on_start') + + +# ===================================================================== +# 5. Run +# ===================================================================== +def main(): + from kivy.logger import Logger + + Logger.info('=' * 78) + Logger.info('Kiwy Signage Player — Raspberry Pi / Linux Edition') + Logger.info(f'Python: {sys.version.split()[0]}') + Logger.info(f'Platform: {platform.platform()}') + Logger.info(f'Machine: {platform.machine()}') + Logger.info(f'Data dir: {DATA_DIR}') + Logger.info(f'Session: WAYLAND_DISPLAY={os.environ.get("WAYLAND_DISPLAY")} ' + f'DISPLAY={os.environ.get("DISPLAY")}') + Logger.info('=' * 78) + + signage_main = _import_main() + + _reassert_graphics_config() + patched = { + 'display': _patch_display(signage_main), + 'weblink': _patch_weblink_engines(signage_main), + 'temp': _patch_temp_paths(signage_main), + 'auth': _patch_auth_path(signage_main), + } + _patch_startup_hooks(signage_main) + Logger.info(f'SignagePlayer: platform patches -> {patched}') + + try: + signage_main.SignagePlayerApp().run() + except KeyboardInterrupt: + Logger.info('Application stopped by user (Ctrl+C)') + except SystemExit as exc: + _show_error( + f'Kivy exited: {exc}', + 'Kivy could not create a window. Check the display backends.\n' + f'SDL_VIDEODRIVER={os.environ.get("SDL_VIDEODRIVER")}\n' + f'KIVY_GL_BACKEND={os.environ.get("KIVY_GL_BACKEND")}', + ) + return 1 + except Exception as exc: + Logger.critical(f'Fatal error: {exc}') + Logger.exception('Full traceback:') + _show_error(str(exc), traceback.format_exc()) + return 1 + finally: + Logger.info('Application shutdown complete') + return 0 + + +if __name__ == '__main__': + exit_code = 0 + try: + exit_code = main() + except SystemExit as exc: + # A clean shutdown (SIGTERM from the watchdog, window closed after the + # password exit) arrives here as SystemExit(0). Reporting that as a + # fatal error wrote a bogus "FATAL: 0" crash log on every normal stop. + code = exc.code + exit_code = code if isinstance(code, int) else (0 if code is None else 1) + if exit_code: + _show_error(f'exited with code {exit_code}', traceback.format_exc()) + except BaseException as exc: # includes import-time failures + _show_error(str(exc), traceback.format_exc()) + raise + sys.exit(exit_code) diff --git a/linux/test_linux_browser_flags.py b/linux/test_linux_browser_flags.py new file mode 100644 index 0000000..758fa84 --- /dev/null +++ b/linux/test_linux_browser_flags.py @@ -0,0 +1,180 @@ +"""test_linux_browser_flags.py — guard the Chromium keyring bypass and footprint. + +The keyring password prompt is the bug this file exists to prevent from coming +back. It was "fixed" once before by adding ``--password-store=basic`` and +``--use-mock-keychain`` to a list (``APPLIANCE_FLAGS``) that **nothing ever +referenced**, so the flags never reached the command line and the prompt +persisted. A second stack-only fix (``--single-process``) also looked right on +paper but crashed with a real HTTP URL. + +So these checks are deliberately about *what actually reaches the process*, not +about what the constants say: + +1. ``extra_launch_args()`` really contains the keyring + footprint flags. +2. ``launch_env()`` really strips the D-Bus session bus, so Chromium cannot + reach ``gnome-keyring-daemon`` even if a flag is ever ignored. +3. Every module-level flag list is referenced by ``extra_launch_args()`` — + dead flag lists are the exact failure mode that hid bug #1. +4. The spawned process is in its own session (so process-group teardown works) + and its ``/proc//environ`` really lacks ``DBUS_SESSION_BUS_ADDRESS``. + +Run: + .venv/bin/python linux/test_linux_browser_flags.py +""" + +from __future__ import annotations + +import os +import re +import sys +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +for path in (str(HERE), str(ROOT / 'src')): + if path not in sys.path: + sys.path.insert(0, path) + +import linux_browser # noqa: E402 + +failures: list[str] = [] +checks = 0 + + +def check(label, condition, detail=''): + global checks + checks += 1 + if condition: + print(f' PASS {label}') + else: + print(f' FAIL {label}' + (f' — {detail}' if detail else '')) + failures.append(label) + + +def build_adapter(**kwargs): + adapter = linux_browser.LinuxChromiumAdapter( + browser_path='/usr/bin/chromium', kiosk=True, **kwargs + ) + adapter._profile_dir = '/tmp/kiwy-flag-test/.kiosk-profile' + return adapter + + +# ── 1. Keyring flags reach the command line ────────────────────────── +print('\n[1] Keyring bypass flags are applied') + +adapter = build_adapter() +args = adapter.extra_launch_args() + +for flag in ('--password-store=basic', '--use-mock-keychain'): + check(f'{flag} present', flag in args, + 'Chromium would contact gnome-keyring and prompt for a password') + +check('--kiosk present', '--kiosk' in args, 'weblink would not be fullscreen') + +if linux_browser._detect_wayland(): + check('--ozone-platform=wayland present', '--ozone-platform=wayland' in args, + 'Chromium 152 aborts without an explicit Ozone platform on labwc') + check( + 'no ineffective --ozone-platform-hint', + '--ozone-platform-hint=auto' not in args, + 'the hint flag does NOT fall back to Wayland and just fails', + ) + + +# ── 2. Environment actually disconnects the keyring ────────────────── +print('\n[2] launch_env() disconnects the Secret Service') + +env = adapter.launch_env() +check('launch_env() returns an environment', env is not None, + 'None means Popen inherits DBUS_SESSION_BUS_ADDRESS') +if env is not None: + check('DBUS_SESSION_BUS_ADDRESS removed', + 'DBUS_SESSION_BUS_ADDRESS' not in env, + 'Chromium could reach gnome-keyring-daemon and prompt') + check('DBUS_SESSION_BUS_PID removed', 'DBUS_SESSION_BUS_PID' not in env) + check('GNOME_KEYRING_CONTROL emptied', env.get('GNOME_KEYRING_CONTROL') == '', + 'points the keyring client at nothing') + check('CHROME_PASSWORD_STORE=basic', env.get('CHROME_PASSWORD_STORE') == 'basic') + check('PATH preserved', bool(env.get('PATH')), 'browser could not exec') + check('session bus is absent from the parent env to begin with', + 'DBUS_SESSION_BUS_ADDRESS' in os.environ, + 'precondition: this test only proves something if the player HAS a bus') + +check('start_new_session() is True', adapter.start_new_session() is True, + 'os.killpg cannot reap Chromium children without it') + + +# ── 3. No dead flag lists ──────────────────────────────────────────── +print('\n[3] Every flag list is referenced (no dead code)') + +source = (HERE / 'linux_browser.py').read_text() +# Names of module-level lists of flags. +lists = re.findall(r'^([A-Z_]+_FLAGS) = \[', source, re.MULTILINE) +check('flag lists found', len(lists) >= 5, f'only found {lists}') + +for name in lists: + # Count references that are NOT the definition itself. + uses = len(re.findall(rf'(? 0, + 'a flag list nothing reads is exactly how the keyring prompt hid') + + +# ── 4. End-to-end: the real process is detached from the bus ───────── +print('\n[4] Live launch: process environment and process group') + +browser = linux_browser.find_linux_browser() +if not browser: + print(' SKIP no Chromium installed') +else: + os.makedirs(adapter._profile_dir, exist_ok=True) + live = build_adapter() + started = live.launch('about:blank', 800, 600) + check('launch() returned True', started is True) + proc = live._proc + if started and proc is not None: + try: + time.sleep(1.5) + if proc.poll() is not None: + check('browser survived start-up', False, + f'exited rc={proc.returncode}') + else: + check('browser survived start-up', True) + + # Process group: must differ from the player's own group. + try: + pgid = os.getpgid(proc.pid) + check('browser is in its own process group', + pgid == proc.pid, + f'pgid={pgid} pid={proc.pid}; killpg would hit the player') + except Exception as exc: + check('browser is in its own process group', False, str(exc)) + + # /proc environ is authoritative: this is what the process sees. + try: + raw = Path(f'/proc/{proc.pid}/environ').read_bytes() + child_env = dict( + item.split('=', 1) for item in + raw.decode('utf-8', 'replace').split('\x00') if '=' in item + ) + check('live process has no DBUS_SESSION_BUS_ADDRESS', + 'DBUS_SESSION_BUS_ADDRESS' not in child_env, + 'the keyring prompt can still appear') + check('live process has basic password store', + child_env.get('CHROME_PASSWORD_STORE') == 'basic') + except Exception as exc: + check('live process environment readable', False, str(exc)) + finally: + live.teardown() + time.sleep(0.5) + check('teardown() left no process behind', live._proc is None) + + +# ── Summary ────────────────────────────────────────────────────────── +print(f'\n{checks - len(failures)}/{checks} checks passed') +if failures: + print('\nFailed:') + for name in failures: + print(f' - {name}') + sys.exit(1) +print('All checks passed.') diff --git a/linux/test_linux_patches.py b/linux/test_linux_patches.py new file mode 100644 index 0000000..9a62b5e --- /dev/null +++ b/linux/test_linux_patches.py @@ -0,0 +1,186 @@ +"""test_linux_patches.py — verify the Linux platform patches are wired correctly. + +Run with the project virtualenv: + .venv/bin/python linux/test_linux_patches.py + +These are the regressions that actually bit us during the Trixie port, so they +are asserted rather than left to manual testing: + +1. **Kivy WeakMethod name trap.** ``Clock`` stores ``callable.__name__`` and + re-resolves it with ``getattr(instance, name)``. If a patched method is + assigned under a name that differs from its own ``__name__``, the app dies + ~20 s later with ``AttributeError`` — far from the cause. Every method this + port replaces must be reachable under its own function name. +2. **SDL2 Wayland capability.** Kivy's PyPI wheel bundles an SDL2 *without* the + wayland driver; the system SDL2 has it. Getting this wrong means no window at + all on Raspberry Pi OS Trixie. +3. **WAYLAND_DISPLAY inference.** SDL2 requires the variable to be set — unlike + ``wlopm``, it does not scan ``XDG_RUNTIME_DIR``. A systemd/cron launch has it + unset, so the entry point must fill it in. + +The player does not need to be running; this only exercises wiring and detection. +""" + +from __future__ import annotations + +import ctypes +import glob +import os +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +SRC = ROOT / 'src' +VENV = Path(os.environ.get('KIWY_VENV') or (ROOT / '.venv')) + +for path in (str(HERE), str(SRC)): + if path not in sys.path: + sys.path.insert(0, path) + +failures: list[str] = [] +checks = 0 + + +def check(label, condition, detail=''): + global checks + checks += 1 + if condition: + print(f' PASS {label}') + else: + print(f' FAIL {label}' + (f' — {detail}' if detail else '')) + failures.append(label) + + +# ── 1. WeakMethod name trap ────────────────────────────────────────── +print('\n[1] Patched methods are resolvable by their own __name__') + +# Import the entry point's patching helpers without running the app. Importing +# run_linux executes its env setup, which is harmless and actually desirable +# here (it fills in WAYLAND_DISPLAY for the checks below). +import run_linux # noqa: E402 + +main = run_linux._import_main() + +patches = { + 'signal_screen_activity': run_linux._patch_display(main), + 'weblink_adapter_factory': run_linux._patch_weblink_engines(main), + 'test_connection': run_linux._patch_temp_paths(main), + 'reset_player_auth': run_linux._patch_auth_path(main), +} + +for label, applied in patches.items(): + check(f'{label} patch applied', applied, 'patch reported failure') + +player_cls = main.SignagePlayer +for attr in ('signal_screen_activity', 'weblink_adapter_factory'): + func = getattr(player_cls, attr, None) + check(f'{attr} exists', func is not None, 'attribute missing') + if func is None: + continue + + # For a staticmethod, inspect the underlying function. + raw = player_cls.__dict__.get(attr) + func = raw.__func__ if isinstance(raw, staticmethod) else raw + name = getattr(func, '__name__', '') + + # The real invariant: Kivy resolves the callback with + # getattr(instance, func.__name__), so that lookup must succeed and return + # the same function. The name need not equal the attribute it replaced, but + # it MUST be reachable on the class. + resolved = getattr(player_cls, name, None) + check( + f'{attr}: getattr(cls, {name!r}) resolves', + resolved is not None, + f'Kivy Clock would raise AttributeError: no attribute {name!r}', + ) + check( + f'{attr}: resolved function is the patched one', + resolved is func, + f'{name!r} resolved to a different object', + ) + +# The Windows-only focus machinery must be gone from the shared app, so nothing +# schedules Win32 work on a Wayland session. +for attr in ('_focus_keeper_tick', '_focus_guardian_tick', + '_bring_window_to_front_nonblocking', '_start_focus_guardian'): + check( + f'{attr} removed (Windows-only)', + not hasattr(player_cls, attr), + 'the platform-specific focus code should not be in the shared app', + ) + + +# ── 2. Display module behaviour ────────────────────────────────────── +print('\n[2] linux_display detection and keep-awake') + +import linux_display as display # noqa: E402 + +status = display.status() +print(f' info status = {status}') + +if status['wayland']: + check('WAYLAND_DISPLAY is set after detection', + bool(os.environ.get('WAYLAND_DISPLAY')), + 'SDL2/wlopm need this variable') + check('wlopm available', status['wlopm'], 'install wlopm') + check('keep_display_awake reports success', + display.keep_display_awake(force=True) is True, + 'wlopm --on did not succeed') +else: + print(' SKIP not a Wayland session — display checks skipped') + +# With the tools explicitly disabled nothing must be attempted. +os.environ[display.DISABLE_ENV_VAR] = '1' +check('DISABLE escape hatch suppresses keep-awake', + display.keep_display_awake(force=True) is False) +check('DISABLE escape hatch suppresses blanker kill', + display.neutralise_idle_blanker() == []) +del os.environ[display.DISABLE_ENV_VAR] + + +# ── 3. SDL2 Wayland capability ─────────────────────────────────────── +print('\n[3] SDL2 in use supports the wayland driver') + +def drivers_of(lib_path): + try: + lib = ctypes.CDLL(str(lib_path)) + lib.SDL_GetNumVideoDrivers.restype = ctypes.c_int + lib.SDL_GetVideoDriver.restype = ctypes.c_char_p + lib.SDL_GetVideoDriver.argtypes = [ctypes.c_int] + n = lib.SDL_GetNumVideoDrivers() + return [lib.SDL_GetVideoDriver(i).decode() for i in range(n)] + except Exception as exc: + return [f''] + + +ext = glob.glob(str(VENV / '**' / '_window_sdl2*.so'), recursive=True) +if not ext: + print(' SKIP no Kivy SDL2 extension found in this venv') +else: + # Ask the dynamic loader which libSDL2 the extension actually resolves. + linked = subprocess.run( + ['ldd', ext[0]], capture_output=True, text=True, check=False, + ).stdout + resolved = None + for line in linked.splitlines(): + if 'libSDL2-2-' in line and '=>' in line: + resolved = line.split('=>')[1].split('(')[0].strip() + break + check('Kivy resolves an SDL2 library', resolved is not None, 'ldd found none') + if resolved: + used = drivers_of(resolved) + print(f' info {resolved}\n drivers = {used}') + check('resolved SDL2 supports wayland', 'wayland' in used, + 'run: bash linux/fix_kivy_sdl2.sh') + + +# ── Summary ────────────────────────────────────────────────────────── +print(f'\n{checks - len(failures)}/{checks} checks passed') +if failures: + print('\nFailed:') + for name in failures: + print(f' - {name}') + sys.exit(1) +print('All checks passed.') diff --git a/linux/test_media_state.py b/linux/test_media_state.py new file mode 100644 index 0000000..06ad479 --- /dev/null +++ b/linux/test_media_state.py @@ -0,0 +1,171 @@ +"""test_media_state.py — the conversion-flag state machine. + +The rules here are easy to get subtly wrong, and getting them wrong is +user-visible in two opposite ways: playing a file that is mid-conversion +(truncated video), or skipping forever an item that is actually fine. + +Covered: + +1. A normal-size video resolves to itself and is playable. +2. An oversized video with no completed conversion reports ``pending``. +3. While the ``.kiwy-converting`` marker exists it reports ``converting``. +4. Once the output + metadata exist it resolves to the **converted** file. +5. A stale marker (from a crash) does not park the item forever. +6. Metadata that does not match the current source is ignored — otherwise a + different video reusing the same filename would play the previous one. +7. The MP4 header parser agrees with ffprobe (the player must not spawn a + subprocess on the playback path, so it reads the container directly). + +Run: + .venv/bin/python linux/test_media_state.py +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +SRC = ROOT / 'src' +for path in (str(HERE), str(SRC)): + if path not in sys.path: + sys.path.insert(0, path) + +import media_state as ms # noqa: E402 + +failures: list[str] = [] +checks = 0 + + +def check(label, condition, detail=''): + global checks + checks += 1 + print(f' {"PASS" if condition else "FAIL"} {label}' + + (f' — {detail}' if not condition and detail else '')) + if not condition: + failures.append(label) + + +def make_video(path, width, height, seconds=1): + """Create a tiny real video of the given size (or None if ffmpeg is absent).""" + if not shutil.which('ffmpeg'): + return False + cmd = [ + 'ffmpeg', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', f'testsrc=size={width}x{height}:rate=10:duration={seconds}', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', + '-y', path, + ] + return subprocess.run(cmd, capture_output=True, check=False).returncode == 0 + + +workdir = Path(tempfile.mkdtemp(prefix='kiwy-mediastate-')) +print(f'workdir: {workdir}') + +try: + big = workdir / 'big.mp4' + small = workdir / 'small.mp4' + + if not make_video(big, 2560, 1440) or not make_video(small, 1280, 720): + print('SKIP: ffmpeg not available to build fixtures') + raise SystemExit(0) + + # ── 1. Within-limit video is playable as itself ────────────────── + print('\n[1] A within-limit video resolves to itself') + chosen, state = ms.resolve_playable(str(small)) + check('state is ready', state == 'ready', f'got {state}') + check('chosen path is the original', chosen == str(small), f'got {chosen}') + + # ── 2. Oversized + no conversion -> pending ────────────────────── + print('\n[2] Oversized video with no conversion reports pending') + chosen, state = ms.resolve_playable(str(big)) + check('state is pending', state == 'pending', f'got {state}') + oversized, _w, _h = ms.is_oversized(str(big)) + check('is_oversized is True', oversized is True) + check('a within-limit file is not oversized', + ms.is_oversized(str(small))[0] is False) + + # ── 3. Converting marker -> converting ─────────────────────────── + print('\n[3] The converting marker suppresses playback') + ms.begin_conversion(str(big)) + chosen, state = ms.resolve_playable(str(big)) + check('state is converting', state == 'converting', f'got {state}') + check('is_converting is True', ms.is_converting(str(big)) is True) + + # ── 4. Completed conversion resolves to the output ─────────────── + print('\n[4] A finished conversion resolves to the converted file') + output = ms.normalized_output(str(big)) + shutil.copy2(str(small), output) # stand-in for the 1080p result + with open(output + ms.MARKER_SUFFIX, 'w') as fh: + json.dump({'source_size': os.path.getsize(big), + 'width': 2560, 'height': 1440}, fh) + ms.end_conversion(str(big)) + + chosen, state = ms.resolve_playable(str(big)) + check('state is ready', state == 'ready', f'got {state}') + check('chosen path is the converted file', chosen == output, f'got {chosen}') + + # ── 5. Stale marker is ignored ─────────────────────────────────── + print('\n[5] A stale (crashed) marker does not block the item forever') + os.remove(output) + os.remove(output + ms.MARKER_SUFFIX) + ms.begin_conversion(str(big)) + old = time.time() - (ms.STALE_CONVERSION_SECONDS + 60) + os.utime(ms.converting_marker(str(big)), (old, old)) + check('a stale marker is not treated as converting', + ms.is_converting(str(big)) is False) + _chosen, state = ms.resolve_playable(str(big)) + check('so the item is pending rather than converting', + state == 'pending', f'got {state}') + ms.end_conversion(str(big)) + + # ── 6. Mismatched metadata is ignored ──────────────────────────── + print('\n[6] Metadata for a different source is rejected') + shutil.copy2(str(small), output) + with open(output + ms.MARKER_SUFFIX, 'w') as fh: + json.dump({'source_size': 12345, # does not match big.mp4 + 'width': 2560, 'height': 1440}, fh) + check('a stale output is not accepted', ms.normalized_file(str(big)) is None) + _chosen, state = ms.resolve_playable(str(big)) + check('the item is treated as pending', state == 'pending', f'got {state}') + + # ── 7. Header parser agrees with ffprobe ───────────────────────── + print('\n[7] The dependency-free MP4 parser matches ffprobe') + for label, path, expect in (('2560x1440', str(big), (2560, 1440)), + ('1280x720', str(small), (1280, 720))): + parsed = ms.read_video_size(path) + check(f'{label} parsed correctly', parsed == expect, f'got {parsed}') + + if shutil.which('ffprobe'): + out = subprocess.run( + ['ffprobe', '-v', 'error', '-select_streams', 'v:0', + '-show_entries', 'stream=width,height', '-of', 'csv=p=0', str(big)], + capture_output=True, text=True, check=False).stdout.strip() + fw, fh = (int(x) for x in out.split(',')[:2]) + check('parser matches ffprobe for the oversized file', + ms.read_video_size(str(big)) == (fw, fh), + f'parser={ms.read_video_size(str(big))} ffprobe={(fw, fh)}') + + check('a non-video file yields (None, None)', + ms.read_video_size(str(workdir / 'missing.mp4')) == (None, None)) + check('a corrupt file yields (None, None)', (lambda p: ( + p.write_bytes(b'not a video'), ms.read_video_size(str(p)))[1] + )(workdir / 'corrupt.mp4') == (None, None)) + +finally: + shutil.rmtree(workdir, ignore_errors=True) + +print(f'\n{checks - len(failures)}/{checks} checks passed') +if failures: + print('\nFailed:') + for name in failures: + print(f' - {name}') + raise SystemExit(1) +print('All checks passed.') diff --git a/linux/video_normalizer.py b/linux/video_normalizer.py new file mode 100644 index 0000000..beaecff --- /dev/null +++ b/linux/video_normalizer.py @@ -0,0 +1,545 @@ +"""video_normalizer.py — downscale oversized videos for Raspberry Pi playback. + +The problem +----------- +The Pi 4 has no usable 4K decode path. Measured on the target device: + + sample-30s.mp4 (1920x1080) decode 3.03x realtime ✅ + 16118765_3840_2160_30fps.mp4 (4K) decode 0.90x realtime ❌ + +ffpyplayer decodes in software (there is no hardware H.264 *decode* wired into +its pipeline), so a 4K clip cannot be decoded fast enough to feed the screen in +real time. The visible symptom is a video that shows one frame and then sits +still, or stutters badly, while the playlist timer ticks on. + +The fix +------- +Normalise oversized media to at most 1920x1080 **once, at sync time**, and hand +the player the smaller file. Playback then always runs against a resolution the +Pi can decode comfortably. + +Timing (measured, 18.3s 4K clip -> 1080p): + + hardware encode (h264_v4l2m2m) 31s one-off, at download time + software encode (libx264) much slower + +31 s is a real cost, but it is paid **once per file** during the playlist sync — +which already runs on a worker thread and already downloads tens of megabytes. +It is never paid during playback, which is the only place it would matter. + +Hardware encoding is used when available because the Pi 4's H.264 *encoder* is a +separate block from its decoder and works well; software encoding of 4K on this +SoC is slow enough to be impractical. + +Scope +----- +Triggered by **resolution only** — ``width > max_width or height > max_height``. +A video already within bounds is left untouched (byte-identical), so this never +degrades content that already plays. + +Audio is preserved: if the source has an audio track it is copied through +(``-c:a copy``, falling back to AAC). That matters because a *silent* video +triggers a separate bug in the SDL2_mixer path — see ``_video_has_audio`` in +``src/main.py`` — so we must not accidentally create one. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import time + +# Importing kivy.logger installs Kivy's own argument parser, which then rejects +# this module's CLI flags ("option --dry-run not recognized") and exits. Setting +# this BEFORE the import keeps Kivy out of argv handling. It must precede the +# kivy import below, hence the import-order exception. +os.environ.setdefault('KIVY_NO_ARGS', '1') + +try: + from kivy.logger import Logger +except Exception: # pragma: no cover - importable without Kivy (tests/CLI) + class Logger: # type: ignore + @staticmethod + def _noop(*args, **kwargs): + pass + info = debug = warning = error = staticmethod(_noop) + +# The player and the normaliser must agree on the on-disk contract for +# "is this converting / has it been converted". That contract lives in one +# place (src/media_state.py) and is shared rather than reimplemented. +_SRC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src') +if _SRC_DIR not in sys.path: + sys.path.insert(0, _SRC_DIR) + +import media_state # noqa: E402 + + +def _log(message, level='info'): + try: + getattr(Logger, level, Logger.info)(f'[VideoNormalizer] {message}') + except Exception: + pass + + +#: Default output ceiling. The Pi's practical decode limit; matches the +#: ``max_resolution`` default used elsewhere in the project. +DEFAULT_MAX_WIDTH = 1920 +DEFAULT_MAX_HEIGHT = 1080 + +#: Marker suffix written next to a normalised file, recording what was done. +#: Re-exported from :mod:`media_state` so the player and normaliser cannot drift. +MARKER_SUFFIX = media_state.MARKER_SUFFIX + +#: Encoder preference: the Pi's hardware block first, then a CPU fallback. +#: ``h264_v4l2m2m`` is the V4L2 mem2mem H.264 encoder (verified working on the +#: target Pi 4); libx264 is the portable fallback for other Linux hosts. +HW_ENCODER = 'h264_v4l2m2m' +SW_ENCODER = 'libx264' + +#: Bitrate for the normalised output. 1080p signage at ~5 Mbps is visually +#: lossless for this use and keeps files small. +TARGET_BITRATE = '5M' +MAX_BITRATE = '8M' +BUFSIZE = '10M' + + +def _run(args, timeout=30): + """Run a command; return (returncode, stdout+stderr). Never raises.""" + try: + result = subprocess.run( + args, capture_output=True, text=True, timeout=timeout, check=False, + ) + return result.returncode, (result.stdout or '') + (result.stderr or '') + except FileNotFoundError: + return 127, 'not found' + except subprocess.TimeoutExpired: + return 124, 'timeout' + except Exception as exc: + return 1, str(exc) + + +def find_ffprobe(): + return shutil.which('ffprobe') + + +def find_ffmpeg(): + return shutil.which('ffmpeg') + + +def probe_video(path): + """Return a dict describing ``path``, or None when it cannot be probed. + + Keys: width, height, codec, duration, has_audio, pix_fmt, level. + """ + ffprobe = find_ffprobe() + if not ffprobe or not os.path.isfile(path): + return None + + code, out = _run([ + ffprobe, '-v', 'error', '-print_format', 'json', + '-show_streams', '-show_format', path, + ], timeout=20) + if code != 0: + _log(f'ffprobe failed for {os.path.basename(path)}: {out.strip()[:200]}', + 'warning') + return None + + try: + data = json.loads(out) + except Exception: + return None + + info = { + 'width': None, 'height': None, 'codec': None, 'pix_fmt': None, + 'level': None, 'duration': None, 'has_audio': False, + } + for stream in data.get('streams', []): + if stream.get('codec_type') == 'video' and info['width'] is None: + info['width'] = stream.get('width') + info['height'] = stream.get('height') + info['codec'] = stream.get('codec_name') + info['pix_fmt'] = stream.get('pix_fmt') + info['level'] = stream.get('level') + elif stream.get('codec_type') == 'audio': + info['has_audio'] = True + + fmt = data.get('format', {}) + try: + info['duration'] = float(fmt.get('duration')) + except (TypeError, ValueError): + info['duration'] = None + return info + + +def hardware_encoder_available(): + """True when the Pi's V4L2 H.264 encoder can actually be opened. + + Checked by running a tiny real encode rather than by grepping + ``ffmpeg -encoders``: the encoder is listed on builds where the kernel + device is missing or busy, and a failed encode at sync time would be far + worse than a slightly slower software one. + """ + ffmpeg = find_ffmpeg() + if not ffmpeg: + return False + code, _ = _run([ + ffmpeg, '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10:duration=0.5', + '-c:v', HW_ENCODER, '-f', 'null', '-', + ], timeout=60) + return code == 0 + + +def needs_normalization(path, max_width=DEFAULT_MAX_WIDTH, + max_height=DEFAULT_MAX_HEIGHT): + """(bool, info) — True when ``path`` exceeds the playback ceiling. + + Only resolution is considered. A file at or below the ceiling is returned + untouched so content that already plays is never re-encoded. + """ + info = probe_video(path) + if info is None: + return False, None + width, height = info.get('width'), info.get('height') + if not width or not height: + return False, info + return (width > max_width or height > max_height), info + + +def _target_size(width, height, max_width, max_height): + """Scale ``width``x``height`` to fit the ceiling, preserving aspect ratio. + + Dimensions are forced even: H.264 4:2:0 requires even width and height, and + an odd value makes the encoder fail outright. + """ + scale = min(max_width / width, max_height / height) + new_w = int(width * scale) + new_h = int(height * scale) + # Round down to even numbers (never up — that could exceed the ceiling). + new_w -= new_w % 2 + new_h -= new_h % 2 + return max(2, new_w), max(2, new_h) + + +def normalized_path(original_path, max_width=DEFAULT_MAX_WIDTH, + max_height=DEFAULT_MAX_HEIGHT): + """Deterministic output path for the normalised version of a file. + + Kept next to the original (not in a cache dir) so the existing media + clean-up logic, which prunes unreferenced files under ``media/``, can do its + normal job: the playlist ends up referencing the normalised file and the + oversized original is pruned automatically. + """ + directory, name = os.path.split(original_path) + stem, ext = os.path.splitext(name) + return os.path.join( + directory, f'{stem}_kiwy{max_height}p{ext or ".mp4"}' + ) + + +def normalize_video(path, max_width=DEFAULT_MAX_WIDTH, + max_height=DEFAULT_MAX_HEIGHT, force=False, dry_run=False): + """Downscale ``path`` if it exceeds the ceiling. + + Returns a dict: + {'status': ..., 'output': ..., 'info': ..., 'elapsed_s': ...} + + ``status`` is one of: + ``within_limit`` — already at or below the ceiling, untouched + ``reused`` — a previous conversion exists and was verified + ``normalized`` — a new file was produced + ``failed`` — could not normalise (caller should use the original) + ``would`` — dry_run only + + The three success states are kept distinct on purpose: reporting a + converted file as "ok" alongside its *original* 4K dimensions reads as + "nothing to do" and would hide a missing conversion. + + Never raises: a failure leaves the original file untouched and the caller + keeps playing it, because a large-but-playable video is better than none. + """ + result = {'status': 'within_limit', 'output': None, 'info': None, + 'elapsed_s': 0.0} + + if not os.path.isfile(path): + result['status'] = 'failed' + result['info'] = 'file not found' + return result + + oversized, info = needs_normalization(path, max_width, max_height) + result['info'] = info + if not oversized: + return result + + output = normalized_path(path, max_width, max_height) + + # Already converted in a previous sync: reuse it. ``media_state`` owns this + # decision so the player resolves the same file, by the same rules. + existing = media_state.normalized_file(path, max_height) + if not force and existing: + result['status'] = 'reused' + result['output'] = existing + _log(f'{os.path.basename(path)} already normalised -> ' + f'{os.path.basename(existing)}') + return result + + new_w, new_h = _target_size(info['width'], info['height'], + max_width, max_height) + source_mb = os.path.getsize(path) / 1024 / 1024 + _log(f'{os.path.basename(path)} is {info["width"]}x{info["height"]} ' + f'({source_mb:.1f} MB) — above {max_width}x{max_height}; ' + f'normalising to {new_w}x{new_h}') + + if dry_run: + result['status'] = 'would' + result['output'] = output + return result + + ffmpeg = find_ffmpeg() + if not ffmpeg: + _log('ffmpeg not found — cannot normalise', 'warning') + result['status'] = 'failed' + result['info'] = 'ffmpeg not found' + return result + + # Write to a temp file and rename on success, so a partial or failed + # conversion can never be picked up as a valid video. + # + # The temp name keeps the real extension: ffmpeg infers the output muxer + # from the filename, so a bare ".part" fails with "Unable to choose an + # output format". The file is only ever moved to ``output`` after a + # successful encode, so the temp name is not user-visible. + root, ext = os.path.splitext(output) + tmp_out = f'{root}.tmp{ext or ".mp4"}' + try: + if os.path.exists(tmp_out): + os.remove(tmp_out) + except OSError: + pass + + audio_args = ['-c:a', 'copy'] if info.get('has_audio') else ['-an'] + + # The scale filter runs on the CPU (the slow part for 4K); the encoder is + # hardware when possible. + vf = f'scale={new_w}:{new_h}:flags=fast_bilinear,format=yuv420p' + + def build(codec_args): + """Assemble one ffmpeg command. + + ``-map`` is explicit so extra streams (subtitles, cover art, a second + audio track) cannot change the output shape between runs. + """ + cmd = [ffmpeg, '-hide_banner', '-loglevel', 'error', '-i', path, + '-map', '0:v:0'] + if info.get('has_audio'): + cmd += ['-map', '0:a:0?'] + cmd += ['-vf', vf] + codec_args + audio_args + [ + '-movflags', '+faststart', + '-pix_fmt', 'yuv420p', + '-y', tmp_out, + ] + return cmd + + # Try hardware first, then fall back to software. A hardware encoder that + # is listed but unusable (device busy, kernel mismatch) fails here rather + # than producing a broken file. + attempts = [] + if hardware_encoder_available(): + attempts.append(( + f'hardware ({HW_ENCODER})', + build(['-c:v', HW_ENCODER, '-b:v', TARGET_BITRATE, + '-maxrate', MAX_BITRATE, '-bufsize', BUFSIZE]), + )) + attempts.append(( + f'software ({SW_ENCODER})', + build(['-c:v', SW_ENCODER, '-preset', 'ultrafast', '-crf', '23', + '-maxrate', MAX_BITRATE, '-bufsize', BUFSIZE]), + )) + + started = time.monotonic() + + # Publish the "converting" flag BEFORE the first encoder runs. The player + # watches this marker and skips the item while it exists, so an item must be + # flagged for the entire window in which its file is being rewritten. + marker_written = media_state.begin_conversion( + path, note=f'{info["width"]}x{info["height"]} -> {new_w}x{new_h}') + if not marker_written: + _log(f'could not write the conversion marker for ' + f'{os.path.basename(path)}; the player may try to show the file ' + f'while it is being rewritten', 'warning') + + try: + return _run_encoders( + attempts, path, output, tmp_out, info, source_mb, result, + started, f'{new_w}x{new_h}') + finally: + # Always clear the flag, including on failure: leaving it set would + # make the item unplayable until it went stale. + media_state.end_conversion(path) + + +def _run_encoders(attempts, path, output, tmp_out, info, source_mb, + result, started, size_note): + """Try each encoder in turn; move the output into place on success.""" + for label, cmd in attempts: + code, out = _run(cmd, timeout=1800) + if code == 0 and os.path.isfile(tmp_out) and os.path.getsize(tmp_out) > 0: + try: + os.replace(tmp_out, output) + except OSError as exc: + _log(f'could not move normalised file into place: {exc}', + 'warning') + result['status'] = 'failed' + return result + result['elapsed_s'] = time.monotonic() - started + result['status'] = 'normalized' + result['output'] = output + + out_mb = os.path.getsize(output) / 1024 / 1024 + _log(f'normalised with {label} in {result["elapsed_s"]:.0f}s: ' + f'{source_mb:.1f} MB -> {out_mb:.1f} MB ' + f'({os.path.basename(output)}) now {size_note}') + + # Metadata last: its presence is what marks the conversion complete + # for media_state.normalized_file(), so it must never exist for a + # half-written output. + try: + with open(output + MARKER_SUFFIX, 'w') as fh: + json.dump({ + 'source': os.path.basename(path), + 'source_size': os.path.getsize(path), + 'width': info['width'], + 'height': info['height'], + 'output_width': int(size_note.split('x')[0]), + 'output_height': int(size_note.split('x')[1]), + 'encoder': label, + 'normalized_at': time.strftime('%Y-%m-%dT%H:%M:%S'), + }, fh, indent=2) + except Exception: + pass + return result + + _log(f'{label} failed (rc={code}): {out.strip()[:300]}', 'warning') + + try: + if os.path.exists(tmp_out): + os.remove(tmp_out) + except OSError: + pass + result['status'] = 'failed' + result['info'] = 'all encoders failed' + _log(f'could not normalise {os.path.basename(path)}; ' + f'the original will be used', 'warning') + return result + + +def normalize_media_dirs(media_dirs, max_width=DEFAULT_MAX_WIDTH, + max_height=DEFAULT_MAX_HEIGHT, dry_run=False): + """Normalise every oversized video under the given directories. + + Intended for bulk/offline use (``--all``) and for verifying an install. + Returns a list of per-file result dicts. + """ + results = [] + seen = set() + for directory in media_dirs: + if not os.path.isdir(directory): + continue + for root, _dirs, files in os.walk(directory): + for name in sorted(files): + if not name.lower().endswith( + ('.mp4', '.mkv', '.mov', '.webm', '.avi', '.m4v')): + continue + if '_kiwy' in name or name.endswith(MARKER_SUFFIX): + continue # already an output, never re-process + path = os.path.join(root, name) + if path in seen: + continue + seen.add(path) + size = os.path.getsize(path) + key = (path, size) + if key in seen: + continue + results.append(normalize_video( + path, max_width, max_height, dry_run=dry_run)) + return results + + +# ── CLI ────────────────────────────────────────────────────────────── +def main(): + import argparse + + parser = argparse.ArgumentParser( + description='Downscale oversized videos for Raspberry Pi playback.') + parser.add_argument('paths', nargs='+', + help='video files or directories to inspect') + parser.add_argument('--max-width', type=int, default=DEFAULT_MAX_WIDTH) + parser.add_argument('--max-height', type=int, default=DEFAULT_MAX_HEIGHT) + parser.add_argument('--dry-run', action='store_true', + help='report what would change, convert nothing') + parser.add_argument('--force', action='store_true', + help='re-convert even if a normalised copy exists') + args = parser.parse_args() + + print(f'max size : {args.max_width}x{args.max_height}') + print(f'ffmpeg : {find_ffmpeg()}') + print(f'ffprobe : {find_ffprobe()}') + if not args.dry_run: + hw = 'available' if hardware_encoder_available() else 'unavailable (will use libx264)' + print(f'hw enc : {hw}') + print() + + changed = 0 + for target in args.paths: + if os.path.isdir(target): + results = normalize_media_dirs( + [target], args.max_width, args.max_height, args.dry_run) + changed += sum(1 for r in results + if r['status'] in ('normalized', 'would', 'reused')) + continue + + if args.force: + oversized, info = needs_normalization( + target, args.max_width, args.max_height) + if info: + out = normalized_path(target, args.max_width, args.max_height) + for stale in (out, out + MARKER_SUFFIX): + try: + os.remove(stale) + except OSError: + pass + + result = normalize_video(target, args.max_width, args.max_height, + force=args.force, dry_run=args.dry_run) + info = result.get('info') + name = os.path.basename(target) + if result['status'] == 'within_limit': + if isinstance(info, dict) and info.get('width'): + print(f' ok {name} {info["width"]}x{info["height"]} ' + f'(within limit)') + else: + print(f' ok {name}') + elif result['status'] == 'reused': + print(f' reuse {name} {info["width"]}x{info["height"]} ' + f'-> {os.path.basename(result["output"])}') + changed += 1 + elif result['status'] == 'normalized': + print(f' DONE {name} -> {os.path.basename(result["output"])} ' + f'({result["elapsed_s"]:.0f}s)') + changed += 1 + elif result['status'] == 'would': + print(f' WOULD {name} {info["width"]}x{info["height"]} ' + f'-> {os.path.basename(result["output"])}') + changed += 1 + else: + print(f' FAILED {name} {result.get("info")}') + + print(f'\n{changed} file(s) need normalisation') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/media/edited_media/5/eye_e_v2.jpg b/media/edited_media/5/eye_e_v2.jpg deleted file mode 100644 index 0d0363eb6910734965e7246b4cbfa62529f8232e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 395259 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ã-li1h0)-=VP>=M=`P|L~0Q}y%T{a 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%0A&RkWtzVkAj_IXA=2u{_3hio$@>2>-eaNq>m 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

p#{j)65(m*ApqxWTLR*U9RfBidTq^KPwv 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 oytsgehCDRnp&<_qd1%N(LmnFP(2$3QJT&B?;lB skip this lap (the file is being rewritten) + # pending -> skip and request conversion + # ready -> play the normalised 1080p file instead + # + # Resolution is read from the MP4 header directly, so this costs no + # subprocess on the playback path. + if os.path.splitext(file_name)[1].lower() in VIDEO_EXTENSIONS: + playable, state = resolve_playable(media_path) + if state == 'converting': + Logger.info( + f"SignagePlayer: {file_name} is being converted to " + f"1920x1080 - skipping this lap" + ) + trace("video_skipped_converting", name=file_name) + self._note_conversion_pending() + self._advance_without_wait() + return + if state == 'pending': + Logger.warning( + f"SignagePlayer: {file_name} is above 1920x1080 and not " + f"converted yet - skipping and requesting conversion" + ) + trace("video_skipped_pending", name=file_name) + self._request_conversion(media_path) + self._note_conversion_pending() + self._advance_without_wait() + return + if playable != media_path: + Logger.info( + f"SignagePlayer: Using normalised video " + f"{os.path.basename(playable)} instead of {file_name}" + ) + trace("video_using_normalized", source=file_name, + normalized=os.path.basename(playable)) + media_path = playable + # ──────────────────────────────────────────────────────────────── # Remove status label if showing self.ids.status_label.opacity = 0 @@ -1860,11 +1904,16 @@ class SignagePlayer(Widget): item_muted = True if item_muted: Logger.info(f"SignagePlayer: Video muted per playlist (audio={item_audio}, muted={item_muted})") + # A new item is starting, so any intro loop has served its + # purpose. Stopping it here (not in the gate above) means the + # loop only ends once real content is about to render. + self._stop_intro_loop() self.play_video(media_path, duration, muted=item_muted) elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']: # Image file Logger.debug(f"SignagePlayer: Media type: IMAGE") trace("starting_image", path=media_path) + self._stop_intro_loop() self.play_image(media_path, duration, force_reload=force_reload) else: Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}") @@ -1918,7 +1967,7 @@ class SignagePlayer(Widget): Needed because ffpyplayer initialises SDL2_mixer from the FIRST audio file it opens and reuses those parameters. Handing it a video with NO audio track (integer division of rate/channels by zero internally) - crashes the process with an access violation in SDL2_mixer.dll + crashes the process with an access violation in the SDL2_mixer library (0xc0000005) — observed when a silent 4K clip entered the playlist after an AAC stereo clip had already been played. @@ -1926,7 +1975,7 @@ class SignagePlayer(Widget): determine the streams, we report True and let the normal path proceed. """ try: - ffprobe = os.path.join(os.path.dirname(sys.executable), '_internal', 'ffprobe.exe') + ffprobe = os.path.join(os.path.dirname(sys.executable), '_internal', 'ffprobe') if not os.path.exists(ffprobe): # Development run: fall back to PATH / the ffpyplayer bundle. import shutil as _shutil @@ -1981,10 +2030,10 @@ class SignagePlayer(Widget): # CALLING thread during unload. Kivy's own on_eos handler sets # state='stop' (which joins the ffpyplayer decode thread) while the # event is being dispatched — i.e. on the Kivy main thread. When - # that thread is slow to exit, the join parks the UI thread and - # Windows declares the app hung (AppHangB1, observed after ~30-45 - # minutes of looping). Must run BEFORE the widget is constructed: - # the decode thread is created during play(). See src/video_safety.py. + # that thread is slow to exit, the join parks the UI thread and the + # whole player freezes (observed after ~30-45 minutes of looping). + # Must run BEFORE the widget is constructed: the decode thread is + # created during play(). See src/video_safety.py. suppress_kivy_video_blocking_unload() # Create Video widget with optimized settings for smooth playback. @@ -2016,11 +2065,7 @@ class SignagePlayer(Widget): # Add to content area self.ids.content_area.add_widget(self.current_widget) - # Start the focus keeper so the window stays foreground for the - # whole video duration (handles SDL surface swaps at load and any - # later re-swaps). The keeper is non-blocking. - self._start_focus_keeper(duration + 1) - trace("video_focus_keeper_started") + trace("video_loaded") # Start a progress watchdog. ffpyplayer does NOT reliably dispatch # EOS — the trace shows the video looping its tail for the gap @@ -2087,170 +2132,6 @@ class SignagePlayer(Widget): pass self._video_watchdog_event = None - def _bring_window_to_front_nonblocking(self): - """Bring the Kivy window forward WITHOUT stalling the UI. - - The heavy Win32 bring-to-front (EnumWindows + AttachThreadInput + - SetForegroundWindow) can take ~1.5s and was blocking the main thread - at every video start (see playback_trace.log: video_loaded then +1.5s - before video_focus_reasserted). We therefore run it as a scheduled - Kivy Clock callback. - - IMPORTANT (why this fixes the overnight focus loss): the previous - implementation spawned a `threading.Thread` and called - `Window.raise_window()` (an SDL call — NOT thread-safe) plus Win32 - `SetForegroundWindow` from that random worker thread. Windows applies - its "foreground lock" against background processes and is - particularly hostile to SetForegroundWindow called from a non-input - thread, so once the app ran in the background the keeper could detect - the lost focus forever but could never win it back (17,681 - focus_keeper_focus_lost ticks overnight, window never raised). - - The Win32 bring-to-front helpers we use here are safe to call from the - main/Kivy thread (they use ShowWindowAsync + SetWindowPos Z-order - flash, which work even when the process is backgrounded), and running - them on the SDL thread avoids touching SDL from a foreign thread. - """ - # Skip entirely if the window is already foreground — cheap path. - try: - check = getattr(self, '_is_foreground_win', None) - if check is not None and check(): - return - except Exception: - pass - - def _do(): - try: - from kivy.core.window import Window as _KivyWindow - _KivyWindow.show() - _KivyWindow.raise_window() - except Exception: - pass - try: - _bring = getattr(self, '_bring_kivy_to_front_win', None) - if _bring is not None: - _bring() - except Exception: - pass - # Run on the Kivy main thread (SDL thread) so we never touch SDL from - # a foreign thread. Non-blocking: scheduled, not blocking the frame. - Clock.schedule_once(lambda dt: _do(), 0) - - def _start_focus_keeper(self, duration): - """Periodically keep the Kivy window in the foreground. - - Runs for up to `duration` seconds while a video is on screen. Each tick - first does a CHEAP foreground check; the expensive bring-to-front only - runs when focus was actually lost. Re-asserts focus on the SDL thread - (via Clock) rather than a worker thread. - """ - self._stop_focus_keeper() - self._focus_keeper_elapsed = 0.0 - self._focus_keeper_duration = float(duration) - self._focus_keeper_misses = 0 - self._focus_keeper_event = Clock.schedule_interval( - self._focus_keeper_tick, 0.5 - ) - trace("focus_keeper_started", duration=duration) - - def _focus_keeper_tick(self, dt): - """One focus-keeper tick: cheap check, heavy action only if needed.""" - self._focus_keeper_elapsed += dt - if self._focus_keeper_elapsed > self._focus_keeper_duration: - self._stop_focus_keeper() - return - # Cheap foreground check first — avoids the 1.5s Win32 work entirely - # when the window already has focus. - try: - check = getattr(self, '_is_foreground_win', None) - if check is not None and check(): - self._focus_keeper_misses = 0 - return # already focused, nothing to do - except Exception: - pass - - self._focus_keeper_misses += 1 - trace("focus_keeper_focus_lost") - - # Lightweight SDL raise every tick (cheap, safe on the SDL thread). - try: - from kivy.core.window import Window as _KivyWindow - _KivyWindow.raise_window() - except Exception: - pass - - # The heavy Win32 bring-to-front (AttachThreadInput + - # SetForegroundWindow + Z-order flash) only runs on a throttled cadence - # (once per second) so a persistently-lost focus can't stutter video. - if self._focus_keeper_misses % 2 == 1: - self._bring_window_to_front_nonblocking() - - def _stop_focus_keeper(self): - """Cancel any active focus keeper interval.""" - ev = getattr(self, '_focus_keeper_event', None) - if ev is not None: - try: - Clock.unschedule(ev) - except Exception: - pass - self._focus_keeper_event = None - self._focus_keeper_elapsed = 0.0 - self._focus_keeper_misses = 0 - - # ── Continuous focus guardian (Windows foreground re-assertion) ────── - # The video-only focus keeper stops as soon as the playlist moves to an - # image, so if focus is lost while an image is on screen (which is exactly - # what happened overnight) nothing ever pulls the window back to the front. - # The guardian runs for the whole playback session and only skips itself - # while the player is paused or a weblink browser is showing. - - def _start_focus_guardian(self): - """Start the continuous foreground guardian (idempotent).""" - if getattr(self, '_focus_guardian_event', None) is not None: - return - self._focus_guardian_misses = 0 - self._focus_guardian_event = Clock.schedule_interval( - self._focus_guardian_tick, self._focus_guardian_interval - ) - Logger.debug("SignagePlayer: Focus guardian started") - - def _focus_guardian_tick(self, dt): - """Re-assert the Kivy window to the foreground if focus was lost.""" - # Don't fight the weblink browser — it legitimately owns foreground. - if getattr(self, '_weblink_proc', None) is not None: - return - # Don't steal focus while the user is interacting with the app - # (settings/exit popups, paused playback). - if self.is_paused: - return - try: - check = getattr(self, '_is_foreground_win', None) - if check is not None and check(): - self._focus_guardian_misses = 0 - return # already foreground - except Exception: - pass - - self._focus_guardian_misses += 1 - # Only raise after the foreground has actually been lost a couple of - # ticks in a row (avoids fighting transient focus such as a click on - # a notification that the user immediately closes). - if self._focus_guardian_misses >= 2: - trace("focus_guardian_reassert", - misses=self._focus_guardian_misses) - self._bring_window_to_front_nonblocking() - - def _stop_focus_guardian(self): - """Stop the continuous foreground guardian.""" - ev = getattr(self, '_focus_guardian_event', None) - if ev is not None: - try: - Clock.unschedule(ev) - except Exception: - pass - self._focus_guardian_event = None - self._focus_guardian_misses = 0 - def _on_video_eos(self, instance): """Callback when video reaches end of stream. @@ -2385,7 +2266,6 @@ class SignagePlayer(Widget): widget = self.current_widget self.current_widget = None self._video_eos_pending = False - self._stop_focus_keeper() self._stop_video_watchdog() threading.Thread( target=self._teardown_video_async, @@ -2403,7 +2283,6 @@ class SignagePlayer(Widget): self.current_widget = None # Reset the EOS guard so the next video can advance normally. self._video_eos_pending = False - self._stop_focus_keeper() self._stop_video_watchdog() Logger.debug("SignagePlayer: Previous widget removed") trace("widget_removed") @@ -2606,6 +2485,208 @@ class SignagePlayer(Widget): except Exception: pass + # ── Video normalisation: skip while converting, loop intro if nothing else ── + def _request_conversion(self, media_path): + """Ask the normaliser to convert ``media_path`` (fire and forget). + + ``linux/video_normalizer.py`` owns the conversion; the player only + signals intent, so the ffmpeg dependency stays out of the playback + process. The child writes a ``.kiwy-converting`` marker immediately, + which is what stops every subsequent lap from re-requesting it. + """ + try: + normalizer = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'linux', 'video_normalizer.py') + if not os.path.exists(normalizer): + Logger.warning( + f"SignagePlayer: video normaliser not found at {normalizer}" + ) + return False + subprocess.Popen( + [sys.executable, normalizer, media_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, # survive the player; not killed on exit + ) + Logger.info( + f"SignagePlayer: requested 1080p conversion of " + f"{os.path.basename(media_path)}" + ) + return True + except Exception as exc: + Logger.error(f"SignagePlayer: could not start conversion: {exc}") + return False + + def _note_conversion_pending(self): + """Remember that the playlist is blocked on a conversion.""" + self._waiting_for_conversion = True + + def _advance_without_wait(self): + """Move to the next item promptly, without the normal dwell delay. + + Skipped items must not occupy their configured duration: a 19 s 4K slot + that cannot be shown should not add 19 s of nothing to each lap. The + advance is scheduled on the Clock rather than called directly so the + stack unwinds (the same reason ``_skip_to_next_media`` schedules). + """ + Clock.unschedule(self.next_media) + Clock.schedule_once(lambda dt: self.next_media(), 0.1) + + def _has_playable_item(self): + """True when at least one item can be shown right now. + + ``converting`` and ``pending`` video items do not count — those are + exactly the case that triggers the intro loop. + """ + for item in self.playlist: + if self._item_is_weblink(item): + return True + file_name = item.get('file_name', '') + if not file_name: + continue + ext = os.path.splitext(file_name)[1].lower() + path = os.path.join(self.media_dir, file_name) + if ext in VIDEO_EXTENSIONS: + _playable, state = resolve_playable(path) + if state == 'ready': + return True + continue + # Images (and anything else) are considered playable if present. + if ext in IMAGE_EXTENSIONS and os.path.exists(path): + return True + return False + + def _intro_path(self): + return os.path.join(self.resources_path, 'intro1.mp4') + + def _maybe_loop_intro_until_ready(self): + """Loop the intro video while the whole playlist is unavailable. + + When the (single-item) playlist is entirely a video still being + converted, there is nothing to show. A blank screen is the worst + possible outcome for signage, so the intro is looped instead — it is + the one asset guaranteed to be local, small and playable. + + Returns True when the intro loop has taken over playback. + """ + if self._intro_loop_active: + return True + if self._has_playable_item(): + self._waiting_for_conversion = False + return False + + if not self._waiting_for_conversion: + # Nothing playable and no conversion in flight: not our case to + # handle (the normal error/skip path owns that). + return False + + intro = self._intro_path() + if not os.path.exists(intro): + Logger.warning( + "SignagePlayer: no playable media and no intro video to loop" + ) + return False + + Logger.info( + "SignagePlayer: nothing playable yet (video conversion in " + "progress) - looping the intro video until it is ready" + ) + trace("intro_loop_started") + self._intro_loop_active = True + + try: + suppress_kivy_video_blocking_unload() + except Exception: + pass + + try: + self._remove_current_widget() + except Exception: + pass + + # ``eos: loop`` keeps the widget cycling by itself; the poll below is a + # safety net in case EOS is not dispatched (a known ffpyplayer quirk on + # this platform). + try: + widget = Video( + source=intro, + state='play', + options={'eos': 'loop'}, + size_hint=(1, 1), + pos_hint={'center_x': 0.5, 'center_y': 0.5}, + ) + self.current_widget = widget + self._intro_loop_widget = widget + self.ids.content_area.add_widget(widget) + except Exception as exc: + Logger.error(f"SignagePlayer: could not start the intro loop: {exc}") + self._intro_loop_active = False + return False + + # Poll for a playable item. Kept well above the 30 s sync interval so + # the check is cheap, but frequent enough that the converted video + # starts within a few seconds of being ready. + self._stop_intro_loop_event() + self._intro_loop_event = Clock.schedule_interval( + self._intro_loop_tick, 3.0 + ) + return True + + def _intro_loop_tick(self, dt): + """Leave the intro loop as soon as something is playable again.""" + if not self._intro_loop_active: + return + if not self._has_playable_item(): + return + + Logger.info( + "SignagePlayer: a playable item became available - leaving the " + "intro loop" + ) + trace("intro_loop_ended") + self._stop_intro_loop_event() + self._intro_loop_active = False + self._waiting_for_conversion = False + + # Restart the playlist so the newly-converted item is played (and, for + # a single-item playlist, from its beginning rather than mid-loop). + self.current_index = 0 + self.play_current_media() + + def _stop_intro_loop_event(self): + event = self._intro_loop_event + if event is not None: + try: + Clock.unschedule(event) + except Exception: + pass + self._intro_loop_event = None + + def _stop_intro_loop(self): + """Tear the intro loop down (idempotent).""" + self._stop_intro_loop_event() + if not self._intro_loop_active: + return + self._intro_loop_active = False + widget, self._intro_loop_widget = self._intro_loop_widget, None + if widget is not None: + try: + if widget in self.ids.content_area.children: + self.ids.content_area.remove_widget(widget) + except Exception: + pass + # Stop it off the main thread: unload() joins the decode thread. + try: + threading.Thread( + target=self._teardown_video_async, args=(widget,), + daemon=True, name='intro-loop-teardown', + ).start() + except Exception: + pass + if self.current_widget is widget: + self.current_widget = None + def _kill_weblink_process(self): """Terminate the kiosk browser, idle watcher and any pre-warm process. @@ -2618,7 +2699,6 @@ class SignagePlayer(Widget): session.close() except Exception as e: Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}") - self._weblink_proc = None # Raise the Kivy window back to the front — when Chromium ran in kiosk # mode it covered the Kivy window entirely; the window manager won't @@ -2729,9 +2809,7 @@ class SignagePlayer(Widget): """Pre-warm the browser for an upcoming web-link item. Delegated to :class:`WeblinkSession`, which asks the preferred engine to - warm its binary and page cache. Platform layers may override this (the - Windows wrapper disables pre-warm because an off-screen browser there - interferes with audio and GPU resources). + warm its binary and page cache. """ session = self.get_weblink_session() session.prewarm(url) @@ -3228,14 +3306,6 @@ class SignagePlayerApp(App): def on_stop(self): Logger.info("SignagePlayerApp: Application stopped") - # Stop the continuous focus guardian - try: - if self.root and hasattr(self.root, '_stop_focus_guardian'): - self.root._stop_focus_guardian() - Logger.info("SignagePlayerApp: Focus guardian stopped") - except Exception as e: - Logger.debug(f"SignagePlayerApp: Error stopping focus guardian: {e}") - # Close any kiosk browser opened for a weblink item try: if self.root and hasattr(self.root, '_kill_weblink_process'): diff --git a/src/media_state.py b/src/media_state.py new file mode 100644 index 0000000..6a05070 --- /dev/null +++ b/src/media_state.py @@ -0,0 +1,301 @@ +"""media_state.py — conversion flags and normalised-path resolution. + +Shared by the player (``src/main.py``) and the normaliser +(``linux/video_normalizer.py``), so both agree on one on-disk contract. + +Why this exists +--------------- +A 4K video cannot be decoded in real time on a Pi 4 (measured 0.90x realtime), +so oversized media is downscaled to 1080p at sync time. That conversion takes +~31 s for an 18 s clip — long enough that the playlist may reach the item first. + +The player therefore needs to know, cheaply and without re-probing the file: + +* has this item already been converted? -> play the smaller file instead +* is it still converting? -> skip it this lap rather than + showing a frozen frame + +Rather than store that inside the playlist JSON (which the server owns and +overwrites on every sync), the state lives in a **sidecar file next to the +media**. That keeps the server contract untouched and survives a playlist +re-download. + +Marker files, all next to the media file:: + + .kiwy-converting EXISTS while a conversion is in flight + _kiwy1080p.mp4 the converted output + _kiwy1080p.mp4.kiwy-normalized.json metadata for that output +""" + +from __future__ import annotations + +import json +import os +import time + +#: Suffix of the in-progress marker. Its presence alone means "skip this item". +CONVERTING_SUFFIX = '.kiwy-converting' + +#: Suffix of the completed-conversion metadata file. +MARKER_SUFFIX = '.kiwy-normalized.json' + +#: Default output ceiling (the Pi's practical decode limit). +DEFAULT_MAX_WIDTH = 1920 +DEFAULT_MAX_HEIGHT = 1080 + +#: A conversion marker older than this is treated as abandoned, so a crash +#: mid-conversion cannot park an item forever. The 4K clip measured 31 s, so +#: this is a very generous multiple. +STALE_CONVERSION_SECONDS = 30 * 60 + + +def converting_marker(path): + return path + CONVERTING_SUFFIX + + +def normalized_output(path, max_height=DEFAULT_MAX_HEIGHT): + """Deterministic path of the converted file for ``path``. + + Must match ``linux/video_normalizer.normalized_path`` exactly — that is the + only reason this function is duplicated there rather than imported from a + runtime module the player should not depend on. + """ + directory, name = os.path.split(path) + stem, ext = os.path.splitext(name) + return os.path.join(directory, f'{stem}_kiwy{max_height}p{ext or ".mp4"}') + + +def is_converting(path): + """True when a conversion for ``path`` is currently in flight. + + A marker left behind by a crash is ignored once it is older than + :data:`STALE_CONVERSION_SECONDS`, so an interrupted conversion cannot make + an item permanently unplayable. + """ + marker = converting_marker(path) + try: + if not os.path.isfile(marker): + return False + age = time.time() - os.path.getmtime(marker) + if age > STALE_CONVERSION_SECONDS: + return False + return True + except OSError: + return False + + +def begin_conversion(path, note=''): + """Create the in-progress marker for ``path``. + + Returns True when the marker was written. Also removes any stale marker + first, so the age always reflects the current attempt. + """ + marker = converting_marker(path) + try: + clear_conversion(path) + except Exception: + pass + try: + with open(marker, 'w') as fh: + json.dump({ + 'source': os.path.basename(path), + 'source_size': os.path.getsize(path), + 'started_at': time.strftime('%Y-%m-%dT%H:%M:%S'), + 'note': note, + }, fh, indent=2) + return True + except Exception: + return False + + +def end_conversion(path): + """Remove the in-progress marker. Safe to call unconditionally.""" + try: + os.remove(converting_marker(path)) + return True + except OSError: + return False + + +def clear_conversion(path): + """Remove a stale in-progress marker (used before starting a new attempt).""" + marker = converting_marker(path) + try: + if os.path.isfile(marker): + os.remove(marker) + except OSError: + pass + + +def conversion_metadata(path, max_height=DEFAULT_MAX_HEIGHT): + """Parsed metadata for a finished conversion, or None.""" + meta_file = normalized_output(path, max_height) + MARKER_SUFFIX + try: + with open(meta_file) as fh: + return json.load(fh) + except Exception: + return None + + +def normalized_file(path, max_height=DEFAULT_MAX_HEIGHT): + """The converted file for ``path`` **only if it is valid and complete**. + + Returns None when no conversion has finished, when the output is missing or + empty, or when the metadata does not match the source that is currently on + disk (which catches a file being replaced by a different video of the same + name after a playlist change). + + Deliberately does not require ``is_converting`` to be False: a conversion + that finished but crashed before removing its marker still produced a + usable file. + """ + output = normalized_output(path, max_height) + try: + if not os.path.isfile(output) or os.path.getsize(output) == 0: + return None + except OSError: + return None + + meta = conversion_metadata(path, max_height) + if not isinstance(meta, dict): + # No metadata: accept the output only if it is newer than the source. + try: + return output if os.path.getmtime(output) >= os.path.getmtime(path) else None + except OSError: + return None + + try: + if meta.get('source_size') != os.path.getsize(path): + # The source changed since the conversion -> the output is stale. + return None + except OSError: + return None + return output + + +def resolve_playable(path, max_width=DEFAULT_MAX_WIDTH, + max_height=DEFAULT_MAX_HEIGHT): + """Pick the file to hand to the video player for ``path``. + + Returns ``(chosen_path, state)`` where ``state`` is one of: + + ``ready`` — the original is within limits, or already converted + ``converting`` — a conversion is in flight; the caller must SKIP this item + ``pending`` — oversized and not yet converted; the caller must SKIP this + item (it would stutter or freeze) and let the sync convert + it + + ``chosen_path`` is the original file when ``state`` is ``pending`` or + ``converting``; callers should not use it in those cases. + + Order matters. The size check must come **before** reporting ``pending``: + a normal 1080p video has no conversion output and no marker, so testing for + those alone would classify every ordinary video as "needs converting" and + skip the entire playlist. + """ + # A finished conversion always wins: it is the file the Pi can decode. + converted = normalized_file(path, max_height) + if converted: + return converted, 'ready' + + if is_converting(path): + return path, 'converting' + + # Only an oversized file needs converting. Anything within the ceiling plays + # as-is, which is the common case and must stay cheap. + oversized, _width, _height = is_oversized(path, max_width, max_height) + if oversized: + return path, 'pending' + + return path, 'ready' + + +def is_oversized(path, max_width=DEFAULT_MAX_WIDTH, max_height=DEFAULT_MAX_HEIGHT): + """(bool, width, height) — True when the file exceeds the playback ceiling. + + Uses the conversion metadata when present (no ffprobe spawn), and otherwise + falls back to reading the MP4 container header directly. Never shells out: + this runs on the playback path, where a subprocess would be far too costly. + """ + width, height = read_video_size(path) + if not width or not height: + return False, width, height + return (width > max_width or height > max_height), width, height + + +def read_video_size(path): + """Read (width, height) from an MP4/MOV container with no external tools. + + Walks the box structure to the ``tkhd`` box, which stores the track's + display size as 16.16 fixed-point. Handles both the 32-bit (version 0) and + 64-bit (version 1) header layouts. + + Returns ``(None, None)`` for anything it cannot parse (non-MP4, fragmented, + truncated) so the caller can fall back to treating the file as playable — + being wrong in that direction means a possible stutter; being wrong the + other way would mean skipping a video that actually plays. + """ + try: + with open(path, 'rb') as fh: + data = fh.read() + except OSError: + return None, None + + def find_box(buf, start, end, box_type): + """Find the first box of ``box_type`` among the children in range.""" + pos = start + while pos + 8 <= end: + size = int.from_bytes(buf[pos:pos + 4], 'big') + kind = buf[pos + 4:pos + 8] + header = 8 + if size == 1: # 64-bit extended size + if pos + 16 > end: + return None + size = int.from_bytes(buf[pos + 8:pos + 16], 'big') + header = 16 + elif size == 0: # extends to end of file + size = end - pos + if size < header or pos + size > end: + return None + if kind == box_type: + return pos + header, pos + size + pos += size + return None + + # moov -> trak -> tkhd + moov = find_box(data, 0, len(data), b'moov') + if not moov: + return None, None + pos, end = moov + + # Scan the tracks for the first one that carries a tkhd with a real size. + while True: + trak = find_box(data, pos, end, b'trak') + if not trak: + return None, None + tpos, tend = trak + tkhd = find_box(data, tpos, tend, b'tkhd') + if tkhd: + kpos, kend = tkhd + try: + version = data[kpos] + # tkhd layout: version(1) flags(3) [creation(4/8)] + # [modification(4/8)] track_id(4) reserved(4) [duration(4/8)] + # reserved(8) layer(2) alt_group(2) volume(2) reserved(2) + # matrix(36) width(4) height(4) + offset = kpos + 4 + if version == 1: + offset += 8 + 8 + 4 + 4 + 8 + else: + offset += 4 + 4 + 4 + 4 + 4 + offset += 8 + 2 + 2 + 2 + 2 + 36 # reserved..matrix + if offset + 8 <= kend: + raw_w = int.from_bytes(data[offset:offset + 4], 'big') + raw_h = int.from_bytes(data[offset + 4:offset + 8], 'big') + width = raw_w >> 16 # 16.16 fixed point + height = raw_h >> 16 + if width and height: + return width, height + except (IndexError, ValueError): + pass + pos = tend diff --git a/src/network_monitor.py b/src/network_monitor.py index e685d12..849e322 100644 --- a/src/network_monitor.py +++ b/src/network_monitor.py @@ -6,15 +6,16 @@ Checks server connectivity and manages WiFi restart on connection failure import subprocess import time import random -import platform +import shutil import requests from datetime import datetime from kivy.logger import Logger from kivy.clock import Clock -# Detect platform once so the ping / WiFi-restart commands below can -# pick the correct syntax (Linux vs Windows). -IS_WINDOWS = platform.system() == 'Windows' +#: Interface used by the restart fallbacks. Raspberry Pi OS names the wireless +#: interface wlan0; only used by the legacy path (NetworkManager resolves the +#: device itself via `nmcli device`). +WIFI_INTERFACE = 'wlan0' class NetworkMonitor: @@ -104,13 +105,9 @@ class NetworkMonitor: Logger.info(f"NetworkMonitor: Pinging server: {hostname}") - # Ping the server hostname with 3 attempts. - # Windows ping uses -n for count and -w for timeout (ms), - # while Linux uses -c and -W. - if IS_WINDOWS: - cmd = ['ping', '-n', '3', '-w', '3000', hostname] - else: - cmd = ['ping', '-c', '3', '-W', '3', hostname] + # Ping the server hostname with 3 attempts (Linux syntax: -c count, + # -W per-packet timeout in seconds). + cmd = ['ping', '-c', '3', '-W', '3', hostname] result = subprocess.run( cmd, capture_output=True, @@ -135,9 +132,12 @@ class NetworkMonitor: def _restart_wifi(self): """ Restart WiFi by turning it off for a specified duration then back on. - Uses the platform-appropriate commands: - - Linux (Raspberry Pi): sudo rfkill / ifconfig / dhclient - - Windows: netsh wlan disconnect / connect + + Raspberry Pi OS Trixie manages networking with **NetworkManager**, so + ``nmcli`` is the correct interface and needs no ``sudo``. The legacy + rfkill/ifconfig path is kept only as a fallback for non-NetworkManager + installs (it requires root: see ``setup_wifi_control.sh``). + This runs in a separate thread to not block the main application. """ def wifi_restart_thread(): @@ -146,10 +146,10 @@ class NetworkMonitor: Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE") Logger.info("NetworkMonitor: ====================================") - if IS_WINDOWS: - self._restart_wifi_windows() + if shutil.which('nmcli'): + self._restart_wifi_nmcli() else: - self._restart_wifi_linux() + self._restart_wifi_legacy() except subprocess.TimeoutExpired: Logger.error("NetworkMonitor: WiFi restart command timeout") @@ -161,58 +161,90 @@ class NetworkMonitor: thread = threading.Thread(target=wifi_restart_thread, daemon=True) thread.start() - def _restart_wifi_windows(self): - """Windows WiFi restart using netsh. Turn off for the wait period, - then turn back on so Windows reconnects to the preferred network.""" + def _restart_wifi_nmcli(self): + """WiFi restart via NetworkManager (the default on Raspberry Pi OS). + + ``nmcli`` is what actually manages the radio on Trixie, works without + ``sudo`` for a user in the ``netdev``/``NetworkManager`` group, and + reconnects to the saved profile automatically. The previous + implementation used ``rfkill``/``ifconfig``/``dhclient`` — ``ifconfig`` + and ``dhclient`` are not even installed on a stock Trixie image, so the + restart silently did nothing. + """ wait_minutes = self.wifi_restart_duration / 60 Logger.info( - f"NetworkMonitor: Windows WiFi restart — off for {wait_minutes:.0f} min" + f"NetworkMonitor: NetworkManager WiFi restart — off for " + f"{wait_minutes:.0f} min" ) - # Turn WiFi OFF + # Turn the radio OFF (persists across the wait, unlike a disconnect). off = subprocess.run( - ['netsh', 'wlan', 'disconnect'], - capture_output=True, text=True, timeout=10 + ['nmcli', 'radio', 'wifi', 'off'], + capture_output=True, text=True, timeout=15 ) if off.returncode == 0: - Logger.info("NetworkMonitor: ✓ WiFi turned OFF (netsh wlan disconnect)") + Logger.info("NetworkMonitor: ✓ WiFi radio disabled (nmcli radio wifi off)") else: + # Some builds deny `radio off` to non-root; a device disconnect + # still forces a reconnect without needing privileges. Logger.warning( - f"NetworkMonitor: netsh wlan disconnect failed: {off.stderr.strip()}" + f"NetworkMonitor: nmcli radio off failed ({off.stderr.strip()}); " + f"falling back to device disconnect" + ) + subprocess.run( + ['nmcli', 'device', 'disconnect', WIFI_INTERFACE], + capture_output=True, text=True, timeout=15 ) - # Wait with WiFi OFF Logger.info( f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes " f"(started {datetime.now().strftime('%H:%M:%S')})" ) time.sleep(self.wifi_restart_duration) Logger.info( - f"NetworkMonitor: Wait period completed at {datetime.now().strftime('%H:%M:%S')}" + f"NetworkMonitor: Wait period completed at " + f"{datetime.now().strftime('%H:%M:%S')}" ) - # Turn WiFi back ON — Windows reconnects to the preferred network + # Turn the radio back ON and let NetworkManager auto-connect. on = subprocess.run( - ['netsh', 'wlan', 'connect'], - capture_output=True, text=True, timeout=10 + ['nmcli', 'radio', 'wifi', 'on'], + capture_output=True, text=True, timeout=15 ) if on.returncode == 0: - Logger.info("NetworkMonitor: ✓ WiFi re-enabled (netsh wlan connect)") + Logger.info("NetworkMonitor: ✓ WiFi radio enabled (nmcli radio wifi on)") else: - # 'netsh wlan connect' without a profile may return non-zero even - # though the radio comes back on; log it but don't fail hard. - Logger.warning( - f"NetworkMonitor: netsh wlan connect returned {on.returncode}: " - f"{on.stderr.strip()} (may reconnect automatically)" + Logger.error(f"NetworkMonitor: nmcli radio on failed: {on.stderr.strip()}") + + # Give NetworkManager a moment, then nudge the saved connection up. + time.sleep(10) + connect = subprocess.run( + ['nmcli', 'device', 'connect', WIFI_INTERFACE], + capture_output=True, text=True, timeout=30 + ) + if connect.returncode == 0: + Logger.info("NetworkMonitor: ✓ Reconnected to the saved WiFi profile") + else: + # NetworkManager often reconnects on its own before we get here, in + # which case `device connect` reports "already connected". + Logger.info( + f"NetworkMonitor: nmcli device connect returned " + f"{connect.returncode}: {connect.stderr.strip()} " + f"(NetworkManager may have reconnected automatically)" ) Logger.info("NetworkMonitor: ====================================") Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED") Logger.info("NetworkMonitor: ====================================") - def _restart_wifi_linux(self): - """Linux (Raspberry Pi) WiFi restart using rfkill/ifconfig/dhclient.""" - # Turn off WiFi using rfkill (more reliable on Raspberry Pi) + def _restart_wifi_legacy(self): + """Fallback for systems without NetworkManager: rfkill + ip. + + Requires passwordless ``sudo`` for the exact commands used — see + ``setup_wifi_control.sh``. Note ``ifconfig``/``dhclient`` are legacy and + not installed on current images; ``ip`` is used instead. + """ + # Turn off WiFi using rfkill (works regardless of the network stack) Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...") result = subprocess.run( ['sudo', 'rfkill', 'block', 'wifi'], @@ -224,19 +256,19 @@ class NetworkMonitor: if result.returncode == 0: Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)") else: - Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...") + Logger.error(f"NetworkMonitor: rfkill failed, trying ip link...") Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}") - # Fallback to ifconfig + # Fallback to ip link (ifconfig is not installed on Trixie) result2 = subprocess.run( - ['sudo', 'ifconfig', 'wlan0', 'down'], + ['sudo', 'ip', 'link', 'set', WIFI_INTERFACE, 'down'], capture_output=True, text=True, timeout=10 ) if result2.returncode == 0: - Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)") + Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ip link)") else: Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}") Logger.error(f"NetworkMonitor: Return code: {result2.returncode}") @@ -275,7 +307,7 @@ class NetworkMonitor: # Also bring interface up result2 = subprocess.run( - ['sudo', 'ifconfig', 'wlan0', 'up'], + ['sudo', 'ip', 'link', 'set', WIFI_INTERFACE, 'up'], capture_output=True, text=True, timeout=10 @@ -284,19 +316,14 @@ class NetworkMonitor: if result2.returncode == 0: Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully") - # Wait a bit for connection to establish + # Wait a bit for connection to establish. + # NOTE: the DHCP client is deliberately not invoked here — + # `dhclient` is not installed on Raspberry Pi OS Trixie and + # hand-rolling it would fight NetworkManager/dhcpcd, which handle + # the lease automatically once the interface comes back up. Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...") time.sleep(10) - # Try to restart DHCP - Logger.info("NetworkMonitor: Requesting IP address...") - subprocess.run( - ['sudo', 'dhclient', 'wlan0'], - capture_output=True, - text=True, - timeout=15 - ) - Logger.info("NetworkMonitor: ====================================") Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED") Logger.info("NetworkMonitor: ====================================") diff --git a/src/playback_trace.py b/src/playback_trace.py index 9f7397d..aa2647c 100644 --- a/src/playback_trace.py +++ b/src/playback_trace.py @@ -1,8 +1,8 @@ """ playback_trace.py — Always-on playback transition logger. -Kivy's log level is forced to 'warning' in main.py / run_win.py, which -suppresses every Logger.info()/Logger.debug() line. That made it impossible +Kivy's log level is forced to 'warning' in main.py, which suppresses every +Logger.info()/Logger.debug() line. That made it impossible to see why the player skips/crashes at the weblink->image and video->next transitions. diff --git a/src/video_safety.py b/src/video_safety.py index 12d3302..ece7564 100644 --- a/src/video_safety.py +++ b/src/video_safety.py @@ -2,9 +2,9 @@ The bug this fixes ------------------ -The player hung (Windows `AppHangB1`) after ~30-45 minutes of looping, always -at a *video* item. The trace stopped dead right after `video_loaded` with no -`video_eos` and no `advance_after_video_eos`. +The player froze after ~30-45 minutes of looping, always at a *video* item. +The trace stopped dead right after `video_loaded` with no `video_eos` and no +`advance_after_video_eos`. Cause — a blocking ``join()`` that Kivy performs on the calling thread: @@ -23,8 +23,7 @@ Cause — a blocking ``join()`` that Kivy performs on the calling thread: 4. That join waits for the ffpyplayer decode thread, whose lifetime is variable (the existing code comments note "0.4s up to 100s+"). When it does not return, the SDL/Kivy main thread never returns either — the window stops - pumping messages and Windows declares the app hung. - +pumping messages and the whole player appears hung. Why it only shows up after a while: our own EOS handler deliberately does not set ``state = 'stop'`` (that path is already handled off-thread), so the hang depends on Kivy's own handler firing first and on that particular video's diff --git a/src/weblink_session.py b/src/weblink_session.py index 438da90..cf51211 100644 --- a/src/weblink_session.py +++ b/src/weblink_session.py @@ -2,17 +2,14 @@ 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) +Web-link items used to be implemented in several places at once — ``main.py`` +(Chromium subprocess + ``/dev/input`` idle watchdog) and a platform wrapper +(Chrome subprocess + overlay + focus fighting) — each owning its own process +handle, watchdog and teardown logic. That made "who owns the browser" +ambiguous, which caused the documented failure modes: leaked browsers, skipped +items, lost foreground and blank screens when a page failed to load. -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: +This module replaces all of them with a single owner: :class:`WeblinkSession` — orchestrates one weblink item at a time. * validates the URL before anything is launched @@ -224,7 +221,7 @@ class WeblinkAdapter: embedded = False #: Set by adapters that render in-window but can still *prove* the page - #: appeared (WebView2). Without this, an embedded engine is trusted + #: appeared (an embedded engine). Without this, an embedded engine is trusted #: blindly, so a page that fails to load (unreachable host on a closed #: network, DNS failure, 404) would sit on screen for the whole slot #: instead of being skipped. @@ -281,24 +278,17 @@ class WeblinkAdapter: class WebInputSources: - """Raw input devices + a global pointer-position tap. + """Raw input devices, used to detect viewer interaction with a web page. - 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. + ``/dev/input/event*`` (Linux/Raspberry Pi) catches touch, mouse and + keyboard activity on every browser engine, but needs read permission + (the player's user must be in the ``input`` group). 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): @@ -341,7 +331,7 @@ class WebInputSources: """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``). + caller's loop would spin. """ if not self._devices: if timeout > 0: @@ -368,32 +358,6 @@ class WebInputSources: 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. @@ -419,7 +383,7 @@ class InteractionWatcher(threading.Thread): 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, + interaction_grace=1.0, embedded=False, wait_visible=None, visible_timeout=15.0, launched_at=None, name='weblink-interaction'): super().__init__(daemon=True, name=name) @@ -437,11 +401,6 @@ class InteractionWatcher(threading.Thread): 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() @@ -496,16 +455,17 @@ class InteractionWatcher(threading.Thread): 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: + if not self._input.devices: Logger.warning( "SignagePlayer: Web link interaction watcher — no /dev/input " - f"devices accessible; using a fixed {self.duration:.0f}s timer" + f"devices accessible; using a fixed {self.duration:.0f}s timer. " + "Add the player's user to the 'input' group to enable " + "interaction-based postponement." ) 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" ) @@ -542,8 +502,6 @@ class InteractionWatcher(threading.Thread): 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()) @@ -680,8 +638,7 @@ class InteractionWatcher(threading.Thread): 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. + Used on Linux/Raspberry Pi, and by platform subclasses elsewhere. """ name = 'chromium-subprocess' @@ -701,7 +658,7 @@ class ChromiumSubprocessAdapter(WeblinkAdapter): @staticmethod def find_browser(): for candidate in ('chromium-browser', 'chromium', 'google-chrome', - 'chrome', 'msedge'): + 'chrome'): path = shutil.which(candidate) if path: return path @@ -715,14 +672,35 @@ class ChromiumSubprocessAdapter(WeblinkAdapter): def extra_launch_args(self): """Extra flags appended to the browser command line. - Subclasses override this instead of duplicating ``launch()``. The - Windows adapter uses it to inject a dedicated ``--user-data-dir``, - which is mandatory there: without it Chrome/Edge hands the URL to an - already-running instance, the process we launched exits immediately - and the weblink never becomes visible. + Subclasses override this instead of duplicating ``launch()``. A platform + adapter uses it to inject a dedicated ``--user-data-dir``, which is + mandatory on Chromium: without it the browser hands the URL to an + already-running instance, the process we launched exits immediately and + the weblink never becomes visible. """ return () + def launch_env(self): + """Environment for the browser process, or None to inherit. + + A separate hook from :meth:`extra_launch_args` because some platform + requirements cannot be expressed as command-line flags. On Linux the + decisive example is the **keyring prompt**: disconnecting the browser + from the D-Bus session bus is what actually stops it asking for a + password (see ``linux/linux_browser.py::_browser_env``). + """ + return None + + def start_new_session(self): + """Whether the browser should get its own process group. + + Required for the process-group teardown on Linux: without it the + browser shares the player's group, ``os.killpg`` cannot be used, and + Chromium's GPU/zygote/renderer children survive teardown and accumulate + over a 24/7 playlist. + """ + return False + def launch(self, url, width, height): browser = self._browser or self.find_browser() if not browser: @@ -755,7 +733,15 @@ class ChromiumSubprocessAdapter(WeblinkAdapter): args += self._extra_flags args += [str(arg) for arg in self.extra_launch_args()] - self._proc = subprocess.Popen(args) + try: + self._proc = subprocess.Popen( + args, + env=self.launch_env(), + start_new_session=self.start_new_session(), + ) + except Exception as exc: + Logger.error(f"SignagePlayer: failed to launch browser: {exc}") + return False return True def is_alive(self): @@ -768,7 +754,7 @@ class ChromiumSubprocessAdapter(WeblinkAdapter): 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). + window handle (see linux/linux_browser.py). """ if self._proc is None: return False, 'no process' @@ -978,7 +964,7 @@ class WeblinkSession: if self._watcher is not None: self._watcher.stop() # An embedded engine normally cannot be verified (CEF), but some can - # (WebView2) — for those the visibility wait must still run, + # (an embedded engine) — for those the visibility wait must still run, # otherwise a page that never loads is shown as a blank screen for # the whole duration instead of being skipped. verify = adapter.wait_visible if ( diff --git a/start.sh b/start.sh index 05e6d97..fbb47f7 100755 --- a/start.sh +++ b/start.sh @@ -241,10 +241,17 @@ while true; do if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then source "$SCRIPT_DIR/.venv/bin/activate" fi - - # Start the player - cd "$SCRIPT_DIR/src" - python3 main.py & + + # Start the player. + # + # NOTE: the entry point is linux/run_linux.py, NOT src/main.py. Running + # main.py directly skips the Raspberry Pi platform patches (Wayland session + # environment, display keep-awake, Chromium kiosk adapter), which is why + # the player used to blank after 10 minutes and fail to show web links. + # Run from the project root so the data directory is the repo root and the + # relative config/media/playlists paths resolve as the app expects. + cd "$SCRIPT_DIR" + python3 linux/run_linux.py & PLAYER_PID=$! log_message "Player PID: $PLAYER_PID" diff --git a/stop_player.sh b/stop_player.sh index 4e94414..121e95a 100755 --- a/stop_player.sh +++ b/stop_player.sh @@ -15,15 +15,17 @@ echo "Stopping watchdog..." pkill -f "bash.*start.sh" # Kill player +# NOTE: match the linux/run_linux.py entry point, not "python3 main.py" — the +# player is started as `python3 linux/run_linux.py` from the project root. echo "Stopping player..." -pkill -f "python3 main.py" +pkill -f "run_linux.py" # Give processes time to exit gracefully sleep 2 # Force kill if still running pkill -9 -f "bash.*start.sh" 2>/dev/null -pkill -9 -f "python3 main.py" 2>/dev/null +pkill -9 -f "run_linux.py" 2>/dev/null # Clean up heartbeat and stop flag files rm -f "$SCRIPT_DIR/.player_heartbeat" diff --git a/windows/2.3.0 b/windows/2.3.0 deleted file mode 100644 index e4e0eb0..0000000 --- a/windows/2.3.0 +++ /dev/null @@ -1,34 +0,0 @@ -Requirement already satisfied: ffpyplayer in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (4.5.3) -Requirement already satisfied: requests in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2.34.2) -Requirement already satisfied: aiohttp in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (3.14.3) -Requirement already satisfied: bcrypt in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (5.0.0) -Requirement already satisfied: certifi in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2026.7.22) -Requirement already satisfied: pyinstaller in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (6.21.0) -Requirement already satisfied: kivy[base] in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2.3.1) -Requirement already satisfied: Kivy-Garden>=0.1.4 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.1.5) -Requirement already satisfied: docutils in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.23) -Requirement already satisfied: pygments in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (2.20.0) -Requirement already satisfied: filetype in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (1.2.0) -Requirement already satisfied: kivy-deps.angle~=0.4.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.4.0) -Requirement already satisfied: kivy-deps.sdl2~=0.8.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.8.0) -Requirement already satisfied: kivy-deps.glew~=0.3.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.3.1) -Requirement already satisfied: pypiwin32 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (223) -Requirement already satisfied: pillow<11,>=9.5.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (10.4.0) -Requirement already satisfied: charset_normalizer<4,>=2 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (3.4.9) -Requirement already satisfied: idna<4,>=2.5 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (3.18) -Requirement already satisfied: urllib3<3,>=1.26 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (2.7.0) -Requirement already satisfied: aiohappyeyeballs>=2.5.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (2.7.1) -Requirement already satisfied: aiosignal>=1.4.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.4.0) -Requirement already satisfied: attrs>=17.3.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (26.1.0) -Requirement already satisfied: frozenlist>=1.1.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.8.0) -Requirement already satisfied: multidict<7.0,>=4.5 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (6.7.1) -Requirement already satisfied: propcache>=0.2.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (0.5.2) -Requirement already satisfied: typing_extensions>=4.4 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (4.16.0) -Requirement already satisfied: yarl<2.0,>=1.17.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.24.5) -Requirement already satisfied: altgraph in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (0.17.5) -Requirement already satisfied: packaging>=22.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (26.2) -Requirement already satisfied: pefile>=2022.5.30 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (2024.8.26) -Requirement already satisfied: pyinstaller-hooks-contrib>=2026.6 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (2026.6) -Requirement already satisfied: pywin32-ctypes>=0.2.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (0.2.3) -Requirement already satisfied: setuptools>=42.0.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (83.0.0) -Requirement already satisfied: pywin32>=223 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pypiwin32->kivy[base]) (312) diff --git a/windows/README_WINDOWS_BUILD.md b/windows/README_WINDOWS_BUILD.md deleted file mode 100644 index 72aa5d6..0000000 --- a/windows/README_WINDOWS_BUILD.md +++ /dev/null @@ -1,286 +0,0 @@ -# Kiwy Signage Player - Windows Edition - -Build and run the Kiwy digital signage player on Windows as a standalone `.exe`. - -## 📋 Requirements Analysis - -The original app was built for **Raspberry Pi (Linux)**, using these technologies: - -| Component | Original (RPi/Linux) | Windows Equivalent | -|-----------|---------------------|-------------------| -| **GUI** | Kivy 2.3+ | Kivy 2.3+ (works cross-platform) | -| **Video** | ffpyplayer | ffpyplayer (needs FFmpeg DLLs) | -| **Card Reader** | evdev (Linux input) | ✅ Raw Input API + LL-hook fallback | -| **Screen Keep-Awake** | xset, xdotool, Wayland | `SetThreadExecutionState` (Win32 API) | -| **Weblink** | chromium-browser (kiosk) | Chrome/Edge (--kiosk mode) | -| **Audio** | ALSA/PulseAudio | DirectSound | -| **Window Backend** | SDL2 (Wayland/X11) | SDL2 (Windows native) | -| **OpenGL** | Desktop GL | ANGLE (DirectX wrapper) | - -### What works on Windows -- ✅ Media playback (images, videos via ffpyplayer) -- ✅ Playlist sync from DigiServer (HTTP/HTTPS) -- ✅ Touch & mouse controls -- ✅ Settings popup -- ✅ Image editing/annotation -- ✅ Password-protected exit -- ✅ Web links (opens in Chrome/Edge kiosk) -- ✅ Network monitoring -- ✅ Auto-update playlist -- ✅ Card reader authentication (Raw Input API — see below) - -### What is disabled on Windows -- ❌ HDMI power management (tvservice is RPi-specific) -- ❌ WiFi restart (uses Linux `nmcli`) - -## 🚀 Quick Start (Development) - -### Prerequisites -1. **Python 3.12+** (64-bit) — [python.org](https://python.org) - - ⚠️ **Python 3.13+ is NOT supported** — Kivy 2.3.1 does not have pre-built wheels for it - - ⚠️ **Python 3.14 is NOT supported** — no Kivy wheels available - - ✅ **Python 3.12.9** is the recommended version (confirmed working) -2. **FFmpeg** — for video codec support - - Download from [ffmpeg.org](https://ffmpeg.org/download.html) - - Add `bin\` folder to your PATH -3. **Visual C++ Redistributable** — [latest](https://aka.ms/vs/17/release/vc_redist.x64.exe) - -### Install & Run -```batch -cd windows - -REM Create virtual environment with Python 3.12 -py -3.12 -m venv venv -:: OR specify full path: -:: "C:\Users\Dell-PC\AppData\Local\Programs\Python\Python312\python.exe" -m venv venv - -venv\Scripts\activate - -REM Install dependencies -pip install -r requirements_win.txt - -REM Run in development mode -python run_win.py -``` - -## 📦 Building the .exe - -### One-Command Build -```batch -cd windows -build_win.bat -``` - -### Manual Build -```batch -cd windows -venv\Scripts\activate -pip install -r requirements_win.txt -pyinstaller build.spec --clean --noconfirm -``` - -### Output -``` -windows\dist\KiwySignagePlayer\ - ├── KiwySignagePlayer.exe # Main executable - ├── config/ # Config files (auto-copied) - ├── resources/ # Icons, intro video - └── ... (supporting DLLs) -``` - -For a **single-file .exe**, edit `build.spec` — uncomment the `exe_onefile` section and comment out the `coll = COLLECT(...)` section. - -## 🌐 Web Links (embedded WebView2) - -Web links render with **WebView2**, embedded as a child window *inside* the -Kivy window. Because it is not a separate browser process, it cannot open -behind the player, cannot be handed off to an existing browser and exit, and -never leaves leaked `msedge.exe`/`chrome.exe` processes behind. - -WebView2 has **two** parts, and they are handled differently: - -| Part | What it is | How it ships | -|------|------------|--------------| -| **SDK** | `Microsoft.Web.WebView2.Core.dll` + `WebView2Loader.dll` — the API surface | Bundled in the exe (`windows\webview2_sdk\`, ~860 KB) | -| **Runtime** | `msedgewebview2.exe` — the actual Chromium engine | Microsoft's evergreen component. Ships with Windows 11 and nearly all Windows 10 machines. **Installed automatically on first start if missing.** | - -### Automatic Runtime installation - -On start-up the player checks for the Runtime (registry `pv` value under the -Edge Update client GUID, with a live SDK probe as fallback). If it is absent it -runs the installer silently: - -``` -MicrosoftEdgeWebview2Setup.exe /silent /install -``` - -Deliberately **not elevated** — an unelevated run performs a *per-user* install, -so no UAC dialog ever appears on the signage display. - -Installers are looked up in this order, so you can drop a replacement next to -the `.exe` without rebuilding: - -1. `KIWY_WEBVIEW2_INSTALLER` environment variable -2. `\webview2_runtime\` -3. `\` (next to the executable) -4. bundled copy inside the exe - -| Installer | Size | Bundled? | Use when | -|-----------|------|----------|----------| -| `MicrosoftEdgeWebview2Setup.exe` | ~1.7 MB | ✅ yes | Machine has internet (downloads the Runtime) | -| `MicrosoftEdgeWebView2RuntimeInstallerX64.exe` | ~203 MB | ⬜ opt-in | Machine is **offline** | - -To bundle the offline installer (adds ~200 MB to the exe): - -```powershell -cd windows -.\webview2_runtime\download_runtime_installers.ps1 -Offline -venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm -``` - -Re-download the installers at any time (they are Microsoft-signed; the script -verifies the signature): - -```powershell -.\webview2_runtime\download_runtime_installers.ps1 -``` - -### If WebView2 is unavailable - -Web links fall back to the Chrome/Edge subprocess engine (see -`src/weblink_session.py`). That engine still works, but reintroduces the old -drawbacks — a separate browser window, possible background/z-order behaviour, -and browser processes to clean up. - -### Troubleshooting - -| Problem | Solution | -|---------|----------| -| Web links show a black/blank page | Check `logs\` for `[WebView2]` lines. Confirm the Runtime version is reported. | -| Runtime install did not happen | A failed attempt is not retried for 6 hours (marker: `logs\.webview2_install_attempt`). Delete that file to retry immediately. | -| Need it working offline | Bundle the standalone installer (see above). | - -## ⚙️ Configuration - -**No configuration ships with the exe.** On a machine that has never been set -up, `config\app_config.json` is absent, so after the splash video the player -shows a *"Player is not configured"* notice, waits 5 seconds and then opens the -**Settings** screen automatically. Enter the server details and playback starts -right away — no restart required. - -On a machine that already has a valid `config\app_config.json`, the first-run -flow is skipped entirely and the cached playlist plays immediately. - -1. Config is created next to the **executable** (not in `%APPDATA%`) - - The .exe creates: `config/`, `media/`, `playlists/`, `logs/` locally - - This allows you to copy the entire `dist\KiwySignagePlayer\` folder anywhere and it works -2. The player is considered configured once `server_ip`, `screen_name` and - `quickconnect_key` all hold real values. Placeholder values - (`localhost`, `kivy-player`, `1234567`, `127.0.0.1`) count as unconfigured. -3. Edit `config\app_config.json` (next to the .exe) to set your server: - -```json -{ - "server_ip": "192.168.0.109", - "port": "8080", - "screen_name": "Birou_IT", - "quickconnect_key": "8887779", - "orientation": "Landscape", - "touch": "True", - "max_resolution": "1920x1080", - "edit_feature_enabled": true, - "use_https": false, - "verify_ssl": false -} -``` - -## 💳 Card Reader (Windows Edition) - -The card reader now works on Windows via the **Raw Input API** (with a -low-level keyboard-hook fallback). It replaces the Linux-only `evdev` -implementation automatically when `run_win.py` starts. - -- Detection mirrors the Linux logic: - 1. A device named with `card` / `reader` / `rfid` - 2. A USB HID keyboard (non-PS/2) — most card readers enumerate this way - 3. Any remaining keyboard (excluding touchscreens/mice) -- Only keystrokes from the **selected device** are captured, so the - operator's real keyboard cannot pollute card data. -- Card data ends on **Enter** (same as Linux). - -### Card reader config (optional) - -Add any of these to `config\app_config.json` next to the .exe: - -```json -{ - "card_reader_mode": "auto", // "auto" | "raw" | "hook" - "card_reader_device": "", // e.g. "VID_08FF" to force a specific device - "card_reader_timeout": 5 // seconds -} -``` - -- `card_reader_mode`: `auto` (default, tries Raw Input then falls back), - `raw` (force Raw Input), or `hook` (force the low-level keyboard hook). -- `card_reader_device`: optional substring of the device name to pin the - reader (e.g. `VID_08FF`, `HID#VID_08FF`). Run the manual test below to see - the exact device names on your host. -- `card_reader_timeout`: how long the swipe popup waits (default 5 s). - -### Manual card reader test (no GUI) - -```batch -cd windows -venv\Scripts\activate -python win_card_reader.py -``` - -Swipe a card within 10 seconds — the tool prints the captured data, then -exits. The detected devices are listed in the console/log. - -## 🧪 Testing - -```batch -cd windows -venv\Scripts\activate -python run_win.py -``` - -## 🔧 Troubleshooting - -| Problem | Solution | -|---------|----------| -| **"ffpyplayer not found"** | Install: `pip install ffpyplayer` | -| **"No video" / black screen** | Install FFmpeg and add to PATH. Try `KIVY_GL_BACKEND=angle_sdl2` or `KIVY_GL_BACKEND=gl` | -| **Kivy window doesn't open** | Run from command prompt to see error messages. Ensure GPU drivers are up to date. | -| **Weblinks not opening** | Install Google Chrome or Microsoft Edge | -| **Can't connect to server** | Check firewall. Try `use_https: false` and `verify_ssl: false` for testing | -| **Antivirus flags .exe** | Add the output folder to antivirus exclusions. This is a false positive common with PyInstaller. | - -## 📁 Project Structure (Build) - -``` -Kiwy-Signage/ -├── windows/ -│ ├── run_win.py # Windows entry point (patches platform differences) -│ ├── build.spec # PyInstaller configuration -│ ├── build_win.bat # One-click build script -│ ├── pyi_runtime_hook.py # PyInstaller runtime hook -│ ├── requirements_win.txt # Windows Python dependencies -│ └── README_WINDOWS_BUILD.md # This file -├── src/ -│ ├── main.py # Main application (original) -│ ├── get_playlists_v2.py # Playlist sync -│ ├── player_auth.py # Authentication -│ ├── ssl_utils.py # SSL/HTTPS -│ ├── keyboard_widget.py # On-screen keyboard -│ ├── network_monitor.py # Network monitoring -│ ├── edit_popup.py # Image editing -│ └── signage_player.kv # Kivy UI layout -├── config/ -│ ├── app_config.json # Player configuration -│ └── resources/ # Icons, images, intro video -├── media/ # Downloaded media (created at runtime) -├── playlists/ # Playlist files (created at runtime) -└── logs/ # Log files (created at runtime) -``` diff --git a/windows/_check_syntax.py b/windows/_check_syntax.py deleted file mode 100644 index 938c643..0000000 --- a/windows/_check_syntax.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Quick syntax check for build files.""" -import ast, sys - -files = [ - r'c:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\run_win.py', - r'c:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\pyi_runtime_hook.py', -] - -for f in files: - try: - with open(f, encoding='utf-8') as fh: - ast.parse(fh.read()) - print(f"OK: {f}") - except SyntaxError as e: - print(f"SYNTAX ERROR in {f}: {e}") - sys.exit(1) - -print("All files OK") diff --git a/windows/app_icon.ico b/windows/app_icon.ico deleted file mode 100644 index 9a95f3bc48e8f1808aea66012596c4747e01c049..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43979 zcmafZWmFu&()Qr)?u&=uPJqBdaCdhn5G1%S4#C}BgL`lm4-UcIf;$8Y_T}FD_dDmU zb4IFbW_r5&DeGSc9q50&ClUa-{ww3)_#bbA4gja9000L6 zk1xal00kldfI$AoN4*1p^MA+x2n49<0f3h(07R-NNn@ar{(BR^kd={8`*;0Mjo8SD z|ISo5Z>9hMJ1;9Crs0|GmZWH7V2K}=ce5_!;h)Ht#Gu=nkg9}+6CHXy+GE0cD59^e zCYGr-os%VojUVF92}4iD&jyd+Tx3EWg6d!|rdI!*fe_X`DVkRyG2?0dB72wSOMQ^F zn?NT!xlyjRr@(no1YEY?iBz?~#s|D?yo&fv_r&my!H^Z3g4xZn=yl_U? z(vPYvB*P%XYPXTiUo|Hux8t6B^s$!$ymLVU3X>SYI*)APUsqMk zzm>y7rIS;^D9{_KQ0b1@ehHqyOt{z#)AI#6TzoSAadqF9rPb3Mw$PgJH0nP{FLurK-@VAr2UH~~Q{s^dG|8=! z6SGKUk7p%UE@~atas{V^?~wP(Ha9-&7rj_62z$qVrg9C*$Rcf(2l0TYXemx>B;1Kw zM7$u^yJyE?%VQTTG+%oatx+WRF;ff0b+j!XGtP$}6k&1Vp4 z6!H7{LQbc7`Y@+?cvp2?x@x8-c&&&g6&l_qu(pS_$9|4W?r_viH+Q3a@8_N6?CfQa zjSp{cH!s3YFqQLM{PUR;Y`+a5-xutq#@0eRnJ=EIGkC0>xP|Cp4LmNEd1F`Hqpm({ z^5N|Y!l8e#wqTUJO{dBc_V(@m|?gQ(L{I4^L09&P(YH_%Ksuc)epQrU#In9kcluoH0m*6wnhcS0^iaSFa$ zlrHZ2T3;+U9nCqlx#!TwLtF_Z@WU$wM~0AdN=&St()Kr7PfCy&HrXzo zrovB3-(HvPU7xqB7M5^RH(=@^+maHvhto=1<$;pinvqtCZGR<0CVxd5iM-ts`QPxsIt447Z;z!vZHNkDY9LbFSAn7siiOydAT(87xwPb@-`c< zOs=B>8U8M|=O_YQ^yf3nAeUknNfqx; z7?h>4D@~lWog%ZkC`7@wiqpZQuednS+|0vGwu!XjVIyq>qOiEfl&m=oawpJ*{gjLR zTjsLSL^jyget!$6z3%^coud@aW^(Iaojy^IT`38XiUo_9JK1SF3>P5cxyrr?s#@oD z0?l@6oGVNN6r*^O4OzQMyA90lZucau6H!yWrZ#Rhp>d8Qt zqe5@Vha>HwWp7&q4$Pj+o2;@_&%0*uN4)ox;xY{H{7jY@H8{Cd8>5N)TY5G;uA+Fb zJUy(wb^#K9VypP}C+2_xCmzPSWU^IJ4bzhng6sT0g;z&d+MbtoyT12PMFw-≶Wi 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ðVz%=;5Pezc{ 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 diff --git a/windows/archive_list.txt b/windows/archive_list.txt deleted file mode 100644 index dd8ef93..0000000 --- a/windows/archive_list.txt +++ /dev/null @@ -1,317 +0,0 @@ -Options in 'KiwySignagePlayer.exe' (PKG/CArchive): - pyi-contents-directory _internal -Contents of 'KiwySignagePlayer.exe' (PKG/CArchive): - position, length, uncompressed_length, is_compressed, typecode, name - 0, 233, 289, 1, 'm', 'struct' - 233, 2778, 4826, 1, 'm', 'pyimod01_archive' - 3011, 13580, 32114, 1, 'm', 'pyimod02_importers' - 16591, 2722, 6130, 1, 'm', 'pyimod03_ctypes' - 19313, 917, 1614, 1, 'm', 'pyimod04_pywin32' - 20230, 1110, 1921, 1, 's', 'pyiboot01_bootstrap' - 21340, 2848, 5575, 1, 's', 'pyi_runtime_hook' - 24188, 1432, 2700, 1, 's', 'pyi_rth_inspect' - 25620, 949, 1509, 1, 's', 'pyi_rth_pkgutil' - 26569, 1316, 2303, 1, 's', 'pyi_rth_multiprocessing' - 27885, 656, 998, 1, 's', 'pyi_rth_setuptools' - 28541, 160, 198, 1, 's', 'pyi_rth_ffpyplayer' - 28701, 423, 688, 1, 's', 'pyi_rth_kivy' - 29124, 28242, 65483, 1, 's', 'run_win' - 57366, 520, 930, 1, 'b', 'COPYING.txt' - 57886, 944666, 2343424, 1, 'b', 'PIL\\_imaging.cp312-win_amd64.pyd' - 1002552, 117575, 262656, 1, 'b', 'PIL\\_imagingcms.cp312-win_amd64.pyd' - 1120127, 900284, 1819648, 1, 'b', 'PIL\\_imagingft.cp312-win_amd64.pyd' - 2020411, 9107, 24064, 1, 'b', 'PIL\\_imagingmath.cp312-win_amd64.pyd' - 2029518, 6961, 14848, 1, 'b', 'PIL\\_imagingtk.cp312-win_amd64.pyd' - 2036479, 209833, 412160, 1, 'b', 'PIL\\_webp.cp312-win_amd64.pyd' - 2246312, 263, 433, 1, 'b', 'README-SDL.txt' - 2246575, 816508, 2509824, 1, 'b', 'SDL2.dll' - 3063083, 91632, 173568, 1, 'b', 'SDL2_image.dll' - 3154715, 143874, 285184, 1, 'b', 'SDL2_mixer.dll' - 3298589, 874449, 1799680, 1, 'b', 'SDL2_ttf.dll' - 4173038, 58181, 120400, 1, 'b', 'VCRUNTIME140.dll' - 4231219, 26333, 49744, 1, 'b', 'VCRUNTIME140_1.dll' - 4257552, 34004, 74088, 1, 'b', '_asyncio.pyd' - 4291556, 46620, 86888, 1, 'b', '_bz2.pyd' - 4338176, 59751, 127848, 1, 'b', '_ctypes.pyd' - 4397927, 124899, 259432, 1, 'b', '_decimal.pyd' - 4522826, 61874, 134648, 1, 'b', '_elementtree.pyd' - 4584700, 30861, 67576, 1, 'b', '_hashlib.pyd' - 4615561, 89779, 160616, 1, 'b', '_lzma.pyd' - 4705340, 20837, 37736, 1, 'b', '_multiprocessing.pyd' - 4726177, 28975, 58224, 1, 'b', '_overlapped.pyd' - 4755152, 19049, 33784, 1, 'b', '_queue.pyd' - 4774201, 41220, 85864, 1, 'b', '_socket.pyd' - 4815421, 71923, 179192, 1, 'b', '_ssl.pyd' - 4887344, 15676, 27128, 1, 'b', '_uuid.pyd' - 4903020, 21645, 39416, 1, 'b', '_wmi.pyd' - 4924665, 87854, 227328, 1, 'b', 'ada92cb5d92a588d1b93__mypyc.cp312-win_amd64.pyd' - 5012519, 105554, 260608, 1, 'b', 'aiohttp\\_http_parser.cp312-win_amd64.pyd' - 5118073, 21089, 44544, 1, 'b', 'aiohttp\\_http_writer.cp312-win_amd64.pyd' - 5139162, 16199, 34816, 1, 'b', 'aiohttp\\_websocket\\mask.cp312-win_amd64.pyd' - 5155361, 63949, 138752, 1, 'b', 'aiohttp\\_websocket\\reader_c.cp312-win_amd64.pyd' - 5219310, 12, 4, 1, 'b', 'attrs-26.1.0.dist-info\\INSTALLER' - 5219322, 3482, 8754, 1, 'b', 'attrs-26.1.0.dist-info\\METADATA' - 5222804, 1673, 3556, 1, 'b', 'attrs-26.1.0.dist-info\\RECORD' - 5224477, 92, 87, 1, 'b', 'attrs-26.1.0.dist-info\\WHEEL' - 5224569, 662, 1109, 1, 'b', 'attrs-26.1.0.dist-info\\licenses\\LICENSE' - 5225231, 26493495, 77325312, 1, 'b', 'avcodec-60.dll' - 31718726, 1747062, 3856896, 1, 'b', 'avdevice-60.dll' - 33465788, 20925644, 39591424, 1, 'b', 'avfilter-9.dll' - 54391432, 7870655, 16813056, 1, 'b', 'avformat-60.dll' - 62262087, 821334, 2193408, 1, 'b', 'avutil-58.dll' - 63083421, 396887, 1333532, 1, 'b', 'base_library.zip' - 63480308, 140181, 305152, 1, 'b', 'bcrypt\\_bcrypt.pyd' - 63620489, 131713, 240216, 1, 'b', 'certifi\\cacert.pem' - 63752202, 8, 0, 1, 'b', 'certifi\\py.typed' - 63752210, 4372, 10752, 1, 'b', 'charset_normalizer\\cd.cp312-win_amd64.pyd' - 63756582, 4370, 10752, 1, 'b', 'charset_normalizer\\md.cp312-win_amd64.pyd' - 63760952, 198, 286, 1, 'b', 'config\\app_config.json' - 63761150, 36525, 36970, 1, 'b', 'config\\resources\\access-card.png' - 63797675, 4402, 4404, 1, 'b', 'config\\resources\\arrow.png' - 63802077, 37299, 37689, 1, 'b', 'config\\resources\\backward.png' - 63839376, 281239, 288324, 1, 'b', 'config\\resources\\card-checked.png' - 64120615, 10025, 11406, 1, 'b', 'config\\resources\\edit-pen.png' - 64130640, 4034, 4023, 1, 'b', 'config\\resources\\exit.png' - 64134674, 38397, 38800, 1, 'b', 'config\\resources\\forward.png' - 64173071, 10979543, 10985920, 1, 'b', 'config\\resources\\intro1.mp4' - 75152614, 32847, 33152, 1, 'b', 'config\\resources\\pause.png' - 75185461, 14060, 14977, 1, 'b', 'config\\resources\\pencil.png' - 75199521, 35800, 36471, 1, 'b', 'config\\resources\\play.png' - 75235321, 25241, 25611, 1, 'b', 'config\\resources\\settings.png' - 75260562, 2132580, 4916728, 1, 'b', 'd3dcompiler_47.dll' - 77393142, 124, 151, 1, 'b', 'docutils\\docutils.conf' - 77393266, 311, 670, 1, 'b', 'docutils\\parsers\\rst\\include\\README.rst' - 77393577, 244, 433, 1, 'b', 'docutils\\parsers\\rst\\include\\html-roles.txt' - 77393821, 2167, 10925, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamsa.txt' - 77395988, 2013, 7242, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamsb.txt' - 77398001, 591, 1723, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamsc.txt' - 77398592, 1521, 6721, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamsn.txt' - 77400113, 1164, 3825, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamso.txt' - 77401277, 2796, 11763, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamsr.txt' - 77404073, 640, 3101, 1, 'b', 'docutils\\parsers\\rst\\include\\isobox.txt' - 77404713, 824, 4241, 1, 'b', 'docutils\\parsers\\rst\\include\\isocyr1.txt' - 77405537, 502, 1882, 1, 'b', 'docutils\\parsers\\rst\\include\\isocyr2.txt' - 77406039, 434, 869, 1, 'b', 'docutils\\parsers\\rst\\include\\isodia.txt' - 77406473, 656, 3010, 1, 'b', 'docutils\\parsers\\rst\\include\\isogrk1.txt' - 77407129, 451, 1705, 1, 'b', 'docutils\\parsers\\rst\\include\\isogrk2.txt' - 77407580, 721, 2880, 1, 'b', 'docutils\\parsers\\rst\\include\\isogrk3.txt' - 77408301, 719, 3035, 1, 'b', 'docutils\\parsers\\rst\\include\\isogrk4-wide.txt' - 77409020, 252, 372, 1, 'b', 'docutils\\parsers\\rst\\include\\isogrk4.txt' - 77409272, 843, 4397, 1, 'b', 'docutils\\parsers\\rst\\include\\isolat1.txt' - 77410115, 1404, 8466, 1, 'b', 'docutils\\parsers\\rst\\include\\isolat2.txt' - 77411519, 641, 3334, 1, 'b', 'docutils\\parsers\\rst\\include\\isomfrk-wide.txt' - 77412160, 273, 519, 1, 'b', 'docutils\\parsers\\rst\\include\\isomfrk.txt' - 77412433, 470, 1931, 1, 'b', 'docutils\\parsers\\rst\\include\\isomopf-wide.txt' - 77412903, 292, 639, 1, 'b', 'docutils\\parsers\\rst\\include\\isomopf.txt' - 77413195, 649, 3231, 1, 'b', 'docutils\\parsers\\rst\\include\\isomscr-wide.txt' - 77413844, 315, 776, 1, 'b', 'docutils\\parsers\\rst\\include\\isomscr.txt' - 77414159, 1301, 4066, 1, 'b', 'docutils\\parsers\\rst\\include\\isonum.txt' - 77415460, 1443, 4613, 1, 'b', 'docutils\\parsers\\rst\\include\\isopub.txt' - 77416903, 2727, 9726, 1, 'b', 'docutils\\parsers\\rst\\include\\isotech.txt' - 77419630, 7648, 45428, 1, 'b', 'docutils\\parsers\\rst\\include\\mmlalias.txt' - 77427278, 2069, 9010, 1, 'b', 'docutils\\parsers\\rst\\include\\mmlextra-wide.txt' - 77429347, 1820, 6800, 1, 'b', 'docutils\\parsers\\rst\\include\\mmlextra.txt' - 77431167, 371, 1036, 1, 'b', 'docutils\\parsers\\rst\\include\\s5defs.txt' - 77431538, 1405, 6112, 1, 'b', 'docutils\\parsers\\rst\\include\\xhtml1-lat1.txt' - 77432943, 717, 1945, 1, 'b', 'docutils\\parsers\\rst\\include\\xhtml1-special.txt' - 77433660, 1859, 7028, 1, 'b', 'docutils\\parsers\\rst\\include\\xhtml1-symbol.txt' - 77435519, 2221, 7300, 1, 'b', 'docutils\\writers\\html4css1\\html4css1.css' - 77437740, 69, 114, 1, 'b', 'docutils\\writers\\html4css1\\template.txt' - 77437809, 467, 1145, 1, 'b', 'docutils\\writers\\html5_polyglot\\italic-field-names.css' - 77438276, 2018, 6219, 1, 'b', 'docutils\\writers\\html5_polyglot\\math.css' - 77440294, 2867, 8279, 1, 'b', 'docutils\\writers\\html5_polyglot\\minimal.css' - 77443161, 2760, 7531, 1, 'b', 'docutils\\writers\\html5_polyglot\\plain.css' - 77445921, 3791, 11887, 1, 'b', 'docutils\\writers\\html5_polyglot\\responsive.css' - 77449712, 69, 114, 1, 'b', 'docutils\\writers\\html5_polyglot\\template.txt' - 77449781, 3768, 12002, 1, 'b', 'docutils\\writers\\html5_polyglot\\tuftig.css' - 77453549, 276, 422, 1, 'b', 'docutils\\writers\\latex2e\\default.tex' - 77453825, 2571, 7548, 1, 'b', 'docutils\\writers\\latex2e\\docutils.sty' - 77456396, 299, 480, 1, 'b', 'docutils\\writers\\latex2e\\titlepage.tex' - 77456695, 268, 424, 1, 'b', 'docutils\\writers\\latex2e\\titlingpage.tex' - 77456963, 429, 675, 1, 'b', 'docutils\\writers\\latex2e\\xelatex.tex' - 77457392, 13789, 16500, 1, 'b', 'docutils\\writers\\odf_odt\\styles.odt' - 77471181, 1802, 6366, 1, 'b', 'docutils\\writers\\pep_html\\pep.css' - 77472983, 589, 1001, 1, 'b', 'docutils\\writers\\pep_html\\template.txt' - 77473572, 193, 278, 1, 'b', 'docutils\\writers\\s5_html\\themes\\README.rst' - 77473765, 40, 38, 1, 'b', 'docutils\\writers\\s5_html\\themes\\big-black\\__base__' - 77473805, 454, 910, 1, 'b', 'docutils\\writers\\s5_html\\themes\\big-black\\framing.css' - 77474259, 1351, 3605, 1, 'b', 'docutils\\writers\\s5_html\\themes\\big-black\\pretty.css' - 77475610, 464, 905, 1, 'b', 'docutils\\writers\\s5_html\\themes\\big-white\\framing.css' - 77476074, 1341, 3565, 1, 'b', 'docutils\\writers\\s5_html\\themes\\big-white\\pretty.css' - 77477415, 483, 1002, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\framing.css' - 77477898, 193, 261, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\opera.css' - 77478091, 371, 648, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\outline.css' - 77478462, 1569, 4383, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\pretty.css' - 77480031, 440, 818, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\print.css' - 77480471, 255, 450, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\s5-core.css' - 77480726, 177, 283, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\slides.css' - 77480903, 4542, 15801, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\slides.js' - 77485445, 43, 41, 1, 'b', 'docutils\\writers\\s5_html\\themes\\medium-black\\__base__' - 77485488, 1431, 4029, 1, 'b', 'docutils\\writers\\s5_html\\themes\\medium-black\\pretty.css' - 77486919, 476, 943, 1, 'b', 'docutils\\writers\\s5_html\\themes\\medium-white\\framing.css' - 77487395, 1422, 3989, 1, 'b', 'docutils\\writers\\s5_html\\themes\\medium-white\\pretty.css' - 77488817, 42, 40, 1, 'b', 'docutils\\writers\\s5_html\\themes\\small-black\\__base__' - 77488859, 1434, 4028, 1, 'b', 'docutils\\writers\\s5_html\\themes\\small-black\\pretty.css' - 77490293, 472, 940, 1, 'b', 'docutils\\writers\\s5_html\\themes\\small-white\\framing.css' - 77490765, 1431, 3999, 1, 'b', 'docutils\\writers\\s5_html\\themes\\small-white\\pretty.css' - 77492196, 6437, 27642, 1, 'b', 'edit_popup.py' - 77498633, 175382, 381440, 1, 'b', 'ffmpeg.exe' - 77674015, 701649, 1808896, 1, 'b', 'ffplay.exe' - 78375664, 85267, 193536, 1, 'b', 'ffprobe.exe' - 78460931, 100772, 248320, 1, 'b', 'ffpyplayer\\pic.cp312-win_amd64.pyd' - 78561703, 20085, 44032, 1, 'b', 'ffpyplayer\\player\\clock.cp312-win_amd64.pyd' - 78581788, 63850, 141312, 1, 'b', 'ffpyplayer\\player\\core.cp312-win_amd64.pyd' - 78645638, 22718, 50176, 1, 'b', 'ffpyplayer\\player\\decoder.cp312-win_amd64.pyd' - 78668356, 27571, 60928, 1, 'b', 'ffpyplayer\\player\\frame_queue.cp312-win_amd64.pyd' - 78695927, 63687, 162816, 1, 'b', 'ffpyplayer\\player\\player.cp312-win_amd64.pyd' - 78759614, 22292, 49152, 1, 'b', 'ffpyplayer\\player\\queue.cp312-win_amd64.pyd' - 78781906, 34855, 80896, 1, 'b', 'ffpyplayer\\threading.cp312-win_amd64.pyd' - 78816761, 79458, 192000, 1, 'b', 'ffpyplayer\\tools.cp312-win_amd64.pyd' - 78896219, 51273, 116736, 1, 'b', 'ffpyplayer\\writer.cp312-win_amd64.pyd' - 78947492, 30805, 69632, 1, 'b', 'frozenlist\\_frozenlist.cp312-win_amd64.pyd' - 78978297, 4749, 19539, 1, 'b', 'get_playlists_v2.py' - 78983046, 120595, 464896, 1, 'b', 'glew32.dll' - 79103641, 1710, 6172, 1, 'b', 'keyboard_widget.py' - 79105351, 92036, 235520, 1, 'b', 'kivy\\_clock.cp312-win_amd64.pyd' - 79197387, 93303, 225792, 1, 'b', 'kivy\\_event.cp312-win_amd64.pyd' - 79290690, 24318, 51200, 1, 'b', 'kivy\\_metrics.cp312-win_amd64.pyd' - 79315008, 48723, 118784, 1, 'b', 'kivy\\core\\audio\\audio_sdl2.cp312-win_amd64.pyd' - 79363731, 16010, 34304, 1, 'b', 'kivy\\core\\clipboard\\_clipboard_sdl2.cp312-win_amd64.pyd' - 79379741, 30469, 66048, 1, 'b', 'kivy\\core\\image\\_img_sdl2.cp312-win_amd64.pyd' - 79410210, 33746, 75264, 1, 'b', 'kivy\\core\\text\\_text_sdl2.cp312-win_amd64.pyd' - 79443956, 56988, 134656, 1, 'b', 'kivy\\core\\text\\text_layout.cp312-win_amd64.pyd' - 79500944, 68847, 163840, 1, 'b', 'kivy\\core\\window\\_window_sdl2.cp312-win_amd64.pyd' - 79569791, 17971, 39424, 1, 'b', 'kivy\\core\\window\\window_info.cp312-win_amd64.pyd' - 79587762, 52367, 122880, 1, 'b', 'kivy\\graphics\\boxshadow.cp312-win_amd64.pyd' - 79640129, 20841, 44544, 1, 'b', 'kivy\\graphics\\buffer.cp312-win_amd64.pyd' - 79660970, 47050, 120320, 1, 'b', 'kivy\\graphics\\cgl.cp312-win_amd64.pyd' - 79708020, 81600, 253952, 1, 'b', 'kivy\\graphics\\cgl_backend\\cgl_debug.cp312-win_amd64.pyd' - 79789620, 18632, 43520, 1, 'b', 'kivy\\graphics\\cgl_backend\\cgl_gl.cp312-win_amd64.pyd' - 79808252, 20135, 45056, 1, 'b', 'kivy\\graphics\\cgl_backend\\cgl_glew.cp312-win_amd64.pyd' - 79828387, 15666, 35328, 1, 'b', 'kivy\\graphics\\cgl_backend\\cgl_mock.cp312-win_amd64.pyd' - 79844053, 16864, 38400, 1, 'b', 'kivy\\graphics\\cgl_backend\\cgl_sdl2.cp312-win_amd64.pyd' - 79860917, 28102, 61952, 1, 'b', 'kivy\\graphics\\compiler.cp312-win_amd64.pyd' - 79889019, 56063, 128512, 1, 'b', 'kivy\\graphics\\context.cp312-win_amd64.pyd' - 79945082, 108332, 308224, 1, 'b', 'kivy\\graphics\\context_instructions.cp312-win_amd64.pyd' - 80053414, 54592, 121856, 1, 'b', 'kivy\\graphics\\fbo.cp312-win_amd64.pyd' - 80108006, 39459, 91136, 1, 'b', 'kivy\\graphics\\gl_instructions.cp312-win_amd64.pyd' - 80147465, 74629, 182272, 1, 'b', 'kivy\\graphics\\instructions.cp312-win_amd64.pyd' - 80222094, 119725, 363520, 1, 'b', 'kivy\\graphics\\opengl.cp312-win_amd64.pyd' - 80341819, 35344, 79872, 1, 'b', 'kivy\\graphics\\opengl_utils.cp312-win_amd64.pyd' - 80377163, 47174, 114688, 1, 'b', 'kivy\\graphics\\scissor_instructions.cp312-win_amd64.pyd' - 80424337, 63296, 143872, 1, 'b', 'kivy\\graphics\\shader.cp312-win_amd64.pyd' - 80487633, 50731, 122880, 1, 'b', 'kivy\\graphics\\stencil_instructions.cp312-win_amd64.pyd' - 80538364, 178240, 405504, 1, 'b', 'kivy\\graphics\\svg.cp312-win_amd64.pyd' - 80716604, 88938, 194048, 1, 'b', 'kivy\\graphics\\tesselator.cp312-win_amd64.pyd' - 80805542, 143998, 340992, 1, 'b', 'kivy\\graphics\\texture.cp312-win_amd64.pyd' - 80949540, 51826, 121856, 1, 'b', 'kivy\\graphics\\transformation.cp312-win_amd64.pyd' - 81001366, 40775, 90112, 1, 'b', 'kivy\\graphics\\vbo.cp312-win_amd64.pyd' - 81042141, 23396, 50176, 1, 'b', 'kivy\\graphics\\vertex.cp312-win_amd64.pyd' - 81065537, 253190, 651264, 1, 'b', 'kivy\\graphics\\vertex_instructions.cp312-win_amd64.pyd' - 81318727, 170074, 440320, 1, 'b', 'kivy\\properties.cp312-win_amd64.pyd' - 81488801, 45575, 121344, 1, 'b', 'kivy\\weakproxy.cp312-win_amd64.pyd' - 81534376, 372872, 741536, 1, 'b', 'kivy_install\\data\\fonts\\DejaVuSans.ttf' - 81907248, 86778, 162464, 1, 'b', 'kivy_install\\data\\fonts\\Roboto-Bold.ttf' - 81994026, 90614, 163644, 1, 'b', 'kivy_install\\data\\fonts\\Roboto-BoldItalic.ttf' - 82084640, 90243, 161484, 1, 'b', 'kivy_install\\data\\fonts\\Roboto-Italic.ttf' - 82174883, 86502, 162876, 1, 'b', 'kivy_install\\data\\fonts\\Roboto-Regular.ttf' - 82261385, 66934, 114624, 1, 'b', 'kivy_install\\data\\fonts\\RobotoMono-Regular.ttf' - 82328319, 89, 98, 1, 'b', 'kivy_install\\data\\glsl\\default.fs' - 82328408, 74, 74, 1, 'b', 'kivy_install\\data\\glsl\\default.png' - 82328482, 154, 196, 1, 'b', 'kivy_install\\data\\glsl\\default.vs' - 82328636, 169, 241, 1, 'b', 'kivy_install\\data\\glsl\\header.fs' - 82328805, 221, 387, 1, 'b', 'kivy_install\\data\\glsl\\header.vs' - 82329026, 4670, 8723, 1, 'b', 'kivy_install\\data\\images\\background.jpg' - 82333696, 136, 138, 1, 'b', 'kivy_install\\data\\images\\cursor.png' - 82333832, 3589, 4053, 1, 'b', 'kivy_install\\data\\images\\defaultshape.png' - 82337421, 52717, 54001, 1, 'b', 'kivy_install\\data\\images\\defaulttheme-0.png' - 82390138, 1059, 3519, 1, 'b', 'kivy_install\\data\\images\\defaulttheme.atlas' - 82391197, 2419, 2890, 1, 'b', 'kivy_install\\data\\images\\image-loading.gif' - 82393616, 3859, 5744, 1, 'b', 'kivy_install\\data\\images\\image-loading.zip' - 82397475, 73, 73, 1, 'b', 'kivy_install\\data\\images\\testpattern.png' - 82397548, 873, 3615, 1, 'b', 'kivy_install\\data\\keyboards\\azerty.json' - 82398421, 1168, 5408, 1, 'b', 'kivy_install\\data\\keyboards\\de.json' - 82399589, 986, 5120, 1, 'b', 'kivy_install\\data\\keyboards\\de_CH.json' - 82400575, 962, 5092, 1, 'b', 'kivy_install\\data\\keyboards\\en_US.json' - 82401537, 1082, 5199, 1, 'b', 'kivy_install\\data\\keyboards\\es_ES.json' - 82402619, 985, 5120, 1, 'b', 'kivy_install\\data\\keyboards\\fr_CH.json' - 82403604, 778, 3382, 1, 'b', 'kivy_install\\data\\keyboards\\qwerty.json' - 82404382, 800, 3396, 1, 'b', 'kivy_install\\data\\keyboards\\qwertz.json' - 82405182, 3197, 3186, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-128.png' - 82408379, 403, 392, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-16.png' - 82408782, 549, 538, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-24.png' - 82409331, 7202, 7329, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-256.png' - 82416533, 735, 724, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-32.png' - 82417268, 1057, 1046, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-48.png' - 82418325, 15737, 16577, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-512.png' - 82434062, 4074, 34494, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-64.ico' - 82438136, 1479, 1468, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-64.png' - 82439615, 742, 2720, 1, 'b', 'kivy_install\\data\\settings_kivy.json' - 82440357, 7411, 44878, 1, 'b', 'kivy_install\\data\\style.kv' - 82447768, 3007, 9798, 1, 'b', 'kivy_install\\modules\\__init__.py' - 82450775, 5757, 12502, 1, 'b', 'kivy_install\\modules\\__pycache__\\__init__.cpython-312.pyc' - 82456532, 73583, 206420, 1, 'b', 'kivy_install\\modules\\__pycache__\\_webdebugger.cpython-312.pyc' - 82530115, 18521, 47723, 1, 'b', 'kivy_install\\modules\\__pycache__\\console.cpython-312.pyc' - 82548636, 1939, 3280, 1, 'b', 'kivy_install\\modules\\__pycache__\\cursor.cpython-312.pyc' - 82550575, 13250, 32506, 1, 'b', 'kivy_install\\modules\\__pycache__\\inspector.cpython-312.pyc' - 82563825, 5839, 13495, 1, 'b', 'kivy_install\\modules\\__pycache__\\joycursor.cpython-312.pyc' - 82569664, 1330, 2283, 1, 'b', 'kivy_install\\modules\\__pycache__\\keybinding.cpython-312.pyc' - 82570994, 2684, 5325, 1, 'b', 'kivy_install\\modules\\__pycache__\\monitor.cpython-312.pyc' - 82573678, 1696, 3413, 1, 'b', 'kivy_install\\modules\\__pycache__\\recorder.cpython-312.pyc' - 82575374, 4402, 9129, 1, 'b', 'kivy_install\\modules\\__pycache__\\screen.cpython-312.pyc' - 82579776, 678, 1080, 1, 'b', 'kivy_install\\modules\\__pycache__\\showborder.cpython-312.pyc' - 82580454, 2148, 4194, 1, 'b', 'kivy_install\\modules\\__pycache__\\touchring.cpython-312.pyc' - 82582602, 626, 874, 1, 'b', 'kivy_install\\modules\\__pycache__\\webdebugger.cpython-312.pyc' - 82583228, 71299, 205887, 1, 'b', 'kivy_install\\modules\\_webdebugger.py' - 82654527, 8272, 35417, 1, 'b', 'kivy_install\\modules\\console.py' - 82662799, 911, 2142, 1, 'b', 'kivy_install\\modules\\cursor.py' - 82663710, 5948, 26047, 1, 'b', 'kivy_install\\modules\\inspector.py' - 82669658, 2768, 10332, 1, 'b', 'kivy_install\\modules\\joycursor.py' - 82672426, 793, 1764, 1, 'b', 'kivy_install\\modules\\keybinding.py' - 82673219, 940, 2637, 1, 'b', 'kivy_install\\modules\\monitor.py' - 82674159, 907, 2575, 1, 'b', 'kivy_install\\modules\\recorder.py' - 82675066, 2356, 7659, 1, 'b', 'kivy_install\\modules\\screen.py' - 82677422, 346, 612, 1, 'b', 'kivy_install\\modules\\showborder.py' - 82677768, 935, 2665, 1, 'b', 'kivy_install\\modules\\touchring.py' - 82678703, 394, 607, 1, 'b', 'kivy_install\\modules\\webdebugger.py' - 82679097, 204710, 454656, 1, 'b', 'libEGL.dll' - 82883807, 2774926, 6930432, 1, 'b', 'libGLESv2.dll' - 85658733, 913283, 2259456, 1, 'b', 'libavif-16.dll' - 86572016, 1856079, 5232408, 1, 'b', 'libcrypto-3.dll' - 88428095, 23195, 39696, 1, 'b', 'libffi-8.dll' - 88451290, 276147, 612864, 1, 'b', 'libgme.dll' - 88727437, 21224, 35328, 1, 'b', 'libogg-0.dll' - 88748661, 212946, 370176, 1, 'b', 'libopus-0.dll' - 88961607, 25354, 51200, 1, 'b', 'libopusfile-0.dll' - 88986961, 282155, 792856, 1, 'b', 'libssl-3.dll' - 89269116, 134415, 387584, 1, 'b', 'libtiff-5.dll' - 89403531, 84996, 175616, 1, 'b', 'libwavpack-1.dll' - 89488527, 217175, 444416, 1, 'b', 'libwebp-7.dll' - 89705702, 11110, 24064, 1, 'b', 'libwebpdemux-2.dll' - 89716812, 207217, 387584, 1, 'b', 'libxmp.dll' - 89924029, 29936, 135281, 1, 'b', 'main.py' - 89953965, 33321, 80896, 1, 'b', 'multidict\\_multidict.cp312-win_amd64.pyd' - 89987286, 2944, 12877, 1, 'b', 'network_monitor.py' - 89990230, 1041, 2071, 1, 'b', 'playback_trace.py' - 89991271, 163, 232, 1, 'b', 'player_auth.json' - 89991434, 3160, 15723, 1, 'b', 'player_auth.py' - 89994594, 34958, 76288, 1, 'b', 'postproc-57.dll' - 90029552, 27945, 62976, 1, 'b', 'propcache\\_helpers_c.cp312-win_amd64.pyd' - 90057497, 99152, 204136, 1, 'b', 'pyexpat.pyd' - 90156649, 24308, 70504, 1, 'b', 'python3.dll' - 90180957, 2542616, 6920936, 1, 'b', 'python312.dll' - 92723573, 54552, 136192, 1, 'b', 'pywin32_system32\\pywintypes312.dll' - 92778125, 18840, 33128, 1, 'b', 'select.pyd' - 92796965, 672, 1335, 1, 'b', 'setuptools\\_vendor\\jaraco\\text\\Lorem ipsum.txt' - 92797637, 3800, 34773, 1, 'b', 'signage_player.kv' - 92801437, 2421, 9330, 1, 'b', 'ssl_utils.py' - 92803858, 192880, 437760, 1, 'b', 'swresample-4.dll' - 92996738, 200713, 642560, 1, 'b', 'swscale-7.dll' - 93197451, 743, 1980, 1, 'b', 'test_network_monitor.py' - 93198194, 418808, 1139704, 1, 'b', 'unicodedata.pyd' - 93617002, 57022, 138752, 1, 'b', 'win32\\win32api.pyd' - 93674024, 57386, 142848, 1, 'b', 'win32\\win32file.pyd' - 93731410, 83574, 223232, 1, 'b', 'win32\\win32gui.pyd' - 93814984, 22145, 53760, 1, 'b', 'win32\\win32process.pyd' - 93837129, 38500, 82944, 1, 'b', 'yarl\\_quoting_c.cp312-win_amd64.pyd' - 93875629, 9308477, 9308477, 0, 'z', 'PYZ.pyz' diff --git a/windows/build.spec b/windows/build.spec deleted file mode 100644 index 41ba522..0000000 --- a/windows/build.spec +++ /dev/null @@ -1,369 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- -""" -PyInstaller spec file for Kiwy Signage Player (Windows .exe) - -Build command (from windows/ directory): - pyinstaller build.spec --clean --noconfirm - -OR use the build script: - build_win.bat -""" - -import os -import sys -from pathlib import Path - -# --- Paths ----------------------------------------------------------- -# This spec file is in windows/build.spec, so the project root is -# always two levels up from this file's real location. -# __file__ may not be available in PyInstaller spec context fallback to cwd. -try: - _spec_dir = Path(__file__).resolve().parent -except NameError: - _spec_dir = Path(os.getcwd()).resolve() -# _spec_dir is now the absolute path to the windows/ directory -BUILD_DIR = _spec_dir -ROOT_DIR = BUILD_DIR.parent -SRC_DIR = ROOT_DIR / 'src' -CONFIG_DIR = ROOT_DIR / 'config' -RESOURCES_DIR = CONFIG_DIR / 'resources' - -# --- Determine hidden imports that PyInstaller might miss ------------- -hidden_imports = [ - # Kivy core modules - 'kivy.core.window', - 'kivy.core.video', - 'kivy.core.audio', - 'kivy.core.text', - 'kivy.core.image', - 'kivy.core.gl', - 'kivy.core.camera', - 'kivy.core.clipboard', - 'kivy.core.spelling', - 'kivy.core.text.markup', - 'kivy.core.window.window_sdl2', - 'kivy.core.image.img_sdl2', - 'kivy.core.video.video_ffpyplayer', - 'kivy.core.audio.audio_ffpyplayer', - # Kivy modules - 'kivy.uix.video', - 'kivy.uix.vkeyboard', - 'kivy.uix.popup', - 'kivy.uix.image', - 'kivy.uix.button', - 'kivy.uix.label', - 'kivy.uix.textinput', - 'kivy.uix.boxlayout', - 'kivy.uix.floatlayout', - 'kivy.uix.slider', - 'kivy.uix.widget', - 'kivy.uix.checkbox', - 'kivy.graphics', - 'kivy.graphics.texture', - 'kivy.graphics.vertex_instructions', - 'kivy.graphics.context_instructions', - 'kivy.clock', - 'kivy.loader', - 'kivy.animation', - 'kivy.lang', - 'kivy.logger', - 'kivy.config', - 'kivy.properties', - 'kivy.metrics', - 'kivy.factory', - # Graphics providers - 'kivy.graphics.opengl', - 'kivy.graphics.opengl_utils', - 'kivy.graphics.fbo', - 'kivy.graphics.gl_instructions', - 'kivy.graphics.stencil_instructions', - 'kivy.graphics.scissor_instructions', - 'kivy.graphics.buffer', - 'kivy.graphics.vbo', - 'kivy.graphics.shader', - 'kivy.graphics.compiler', - # ffpyplayer - 'ffpyplayer', - 'ffpyplayer.player', - 'ffpyplayer.pic', - 'ffpyplayer.writer', - # Networking - 'requests', - 'aiohttp', - 'urllib3', - 'certifi', - 'bcrypt', - # Platform - 'ctypes', - 'ctypes.wintypes', - 'subprocess', - 'shutil', - 'glob', - 'selectors', - 'tempfile', - # Windows-specific - 'cef_browser', - 'webview2_browser', - 'webview2_runtime', - '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 -excluded_imports = [ - 'gi', # GTK introspection (Linux) - 'gi.repository', - 'evdev', # We inject a fake evdev module in run_win.py - # GStreamer — we use ffpyplayer, not GStreamer - 'kivy.lib.gstplayer', - # cefpython3: keep only Python 3.12 .pyd, exclude other version .pyd files - 'cefpython3.cefpython_py27', - 'cefpython3.cefpython_py34', - 'cefpython3.cefpython_py35', - 'cefpython3.cefpython_py36', - 'cefpython3.cefpython_py37', - 'cefpython3.cefpython_py38', - 'cefpython3.cefpython_py39', - 'cefpython3.cefpython_py310', - 'cefpython3.cefpython_py311', -] - -# --- Application data files to bundle -------------------------------- -# Resources (icons, intro video, etc.) -resources_data = [] -for item in RESOURCES_DIR.iterdir(): - if item.is_file(): - target_dir = 'config/resources' - resources_data.append((str(item), target_dir)) - -# Config directory. -# -# app_config.json is deliberately NOT bundled. Including it shipped the -# developer's own server_ip / screen_name inside the exe, so a fresh install -# silently connected to the wrong server (or to a placeholder) instead of -# asking the operator. The player now starts unconfigured, shows a notice after -# the splash video and opens Settings to collect the real values, which are -# then saved next to the .exe. -config_data = [] -config_file = CONFIG_DIR / 'app_config.json' -if config_file.exists(): - print("[spec] app_config.json is NOT bundled (first-run setup collects it)") - -# --- Bundled web engines --------------------------------------------- -# Embedded WebView2 (Edge) SDK: the managed assembly plus the native loader -# DLL. The *runtime* itself is a Microsoft-shipped evergreen component and is -# deliberately NOT bundled (that is the point of using WebView2 — no ~150 MB -# Chromium payload inside our exe). -webview2_data = [] -webview2_sdk = BUILD_DIR / 'webview2_sdk' -if webview2_sdk.is_dir(): - for item in webview2_sdk.iterdir(): - if item.is_file(): - webview2_data.append((str(item), 'webview2_sdk')) - print(f"[spec] Bundling {len(webview2_data)} WebView2 SDK file(s) from {webview2_sdk}") - -# WebView2 Runtime installer, so a machine that ships WITHOUT the Runtime can -# install it on first start (see windows/webview2_runtime.py). -# -# Only the small bootstrapper (~1.7 MB) is bundled by default; it downloads the -# Runtime from Microsoft. Dropping the ~203 MB offline standalone installer -# into windows/webview2_runtime/ bundles it too, which is what you want for -# machines with no internet — but it triples the exe size, so it is opt-in. -webview2_runtime = BUILD_DIR / 'webview2_runtime' -_bootstrap = webview2_runtime / 'MicrosoftEdgeWebview2Setup.exe' -_standalone = webview2_runtime / 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe' -if _bootstrap.is_file(): - webview2_data.append((str(_bootstrap), 'webview2_runtime')) - print(f"[spec] Bundling WebView2 Runtime bootstrapper ({_bootstrap.stat().st_size / 1024 / 1024:.1f} MB)") -if _standalone.is_file(): - webview2_data.append((str(_standalone), 'webview2_runtime')) - print(f"[spec] Bundling WebView2 offline standalone installer " - f"({_standalone.stat().st_size / 1024 / 1024:.0f} MB) — exe will be much larger") -if not _bootstrap.is_file() and not _standalone.is_file(): - print("=" * 70) - print("WARNING: no WebView2 Runtime installer in windows/webview2_runtime/.") - print("Machines without the Runtime cannot show web links (they fall back") - print("to the Chrome/Edge subprocess engine).") - print("=" * 70) -else: - print("=" * 70) - print("WARNING: windows/webview2_sdk/ not found.") - print("Web links will fall back to the Chrome/Edge subprocess engine.") - print("=" * 70) - -# Source files - .kv file -kv_file = SRC_DIR / 'signage_player.kv' -kv_data = [] -if kv_file.exists(): - kv_data.append((str(kv_file), '.')) - -# Bundle the entire src directory as a tree. -# -# EXCLUDE player_auth.json: it holds LIVE credentials (auth_code, player_id, -# server_url). Bundling it means the frozen app starts up in _internal/ and -# loads that snapshot as its auth state — so a freshly built exe boots -# "already authenticated" against whatever server the file happened to name, -# and plays a stale playlist. Auth must be created at runtime in the data dir -# next to the .exe (see run_win.py `_patch_auth_paths`). -source_tree = Tree( - str(SRC_DIR), - prefix='', - excludes=['*.pyc', '__pycache__', '*.ini', 'player_auth.json'], -) - -# --- Collect binary DLLs from kivy_deps and ffpyplayer ---------------- -import importlib.util -from pathlib import Path as _Path - - -def _site_packages_dir(package_path): - """Climb up from a package's __init__.py to its site-packages dir.""" - d = _Path(package_path).parent - while d.name != 'site-packages' and d.parent != d: - d = d.parent - return d - - -def _find_share_dlls(package_name, share_name=None): - """Find .dll files under venv_root/share//. - - kivy_deps.sdl2/angle/glew and ffpyplayer install their DLLs into - /share//... NOT inside the package dir. The share folder is - named after the *short* dep name (e.g. 'sdl2', 'angle', 'glew'), not the - dotted package name ('kivy_deps.sdl2'), so pass share_name explicitly. - """ - if share_name is None: - share_name = package_name - spec = importlib.util.find_spec(package_name) - if spec is None or not spec.origin: - return [] - sp = _site_packages_dir(spec.origin) - # Climb from site-packages up until we find a sibling 'share' dir - # (site-packages -> Lib -> venv, where venv/share lives). - d = sp - while d.parent != d: - if (d.parent / 'share').is_dir(): - share = d.parent / 'share' / share_name - break - d = d.parent - else: - return [] - if not share.is_dir(): - return [] - results = [] - for root, dirs, files in os.walk(share): - for f in files: - if f.endswith('.dll'): - results.append((os.path.join(root, f), '.')) - return results - - -def _find_ffpyplayer_bins(): - """Return ffpyplayer's own dependency DLL dirs (FFmpeg + bundled SDL). - - ffpyplayer ships a `dep_bins` list that already points at the correct - share/ffpyplayer/ffmpeg/bin and share/ffpyplayer/sdl/bin directories. - """ - try: - import ffpyplayer - bins = getattr(ffpyplayer, 'dep_bins', None) - if not bins: - return [] - results = [] - for b in bins: - bpath = _Path(b) - if bpath.is_dir(): - for f in bpath.glob('*.dll'): - results.append((str(f), '.')) - return results - except Exception: - return [] - - -# SDL2 / ANGLE / GLEW DLLs (kivy_deps share dirs) -_sdl2_dlls = _find_share_dlls('kivy_deps.sdl2', 'sdl2') -_angle_dlls = _find_share_dlls('kivy_deps.angle', 'angle') -_glew_dlls = _find_share_dlls('kivy_deps.glew', 'glew') - -# ffpyplayer FFmpeg + bundled SDL DLLs (via dep_bins) -_ffpy_dlls = _find_ffpyplayer_bins() - -_all_binaries = _sdl2_dlls + _angle_dlls + _glew_dlls + _ffpy_dlls - -if not _all_binaries: - print("=" * 70) - print("WARNING: No Kivy/ffpyplayer DLLs found via share/ directories.") - print("PyInstaller may still auto-detect them, but if the .exe") - print("fails with 'SDL2.dll not found' or similar, you will need") - print("to manually add the DLL paths to the spec file.") - print("=" * 70) -else: - print(f"[spec] Bundling {len(_all_binaries)} DLLs:") - for _p, _t in sorted(_all_binaries): - print(f" {_Path(_p).name} <- {_p}") - -# --- Build the .exe -------------------------------------------------- -a = Analysis( - ['run_win.py'], # Entry point (relative to this spec) - pathex=[str(BUILD_DIR), str(SRC_DIR)], # Where to find modules - binaries=_all_binaries, - datas=resources_data + config_data + kv_data + webview2_data, - hiddenimports=hidden_imports, - hookspath=[], - hooksconfig={}, - runtime_hooks=[str(BUILD_DIR / 'pyi_runtime_hook.py')], - excludes=excluded_imports, - noarchive=False, - module_collection_mode={ - 'kivy': 'pyz', - 'kivy.core': 'pyz', - 'kivy.uix': 'pyz', - 'kivy.graphics': 'pyz', - }, -) - -# Add the source tree (main.py, etc.) -a.datas += source_tree - -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='KiwySignagePlayer', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True, # Show console for debugging startup errors - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - icon=str(BUILD_DIR / 'app_icon.ico') if (BUILD_DIR / 'app_icon.ico').exists() else None, - version=str(BUILD_DIR / 'version_info.txt') if (BUILD_DIR / 'version_info.txt').exists() else None, -) - -# --- COLLECT everything into a single folder ------------------------- -coll = COLLECT( - exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name='KiwySignagePlayer', -) diff --git a/windows/build_last.txt b/windows/build_last.txt deleted file mode 100644 index 7eff9a5..0000000 --- a/windows/build_last.txt +++ /dev/null @@ -1,5 +0,0 @@ - libtiff-5.dll <- C:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\venv\share\sdl2\bin\libtiff-5.dll - libwavpack-1.dll <- C:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\venv\share\sdl2\bin\libwavpack-1.dll - libwebp-7.dll <- C:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\venv\share\sdl2\bin\libwebp-7.dll - libwebpdemux-2.dll <- C:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\venv\share\sdl2\bin\libwebpdemux-2.dll - libxmp.dll <- C:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\venv\share\sdl2\bin\libxmp.dll diff --git a/windows/build_win.bat b/windows/build_win.bat deleted file mode 100644 index 6914c79..0000000 --- a/windows/build_win.bat +++ /dev/null @@ -1,158 +0,0 @@ -@echo off -REM ===================================================================== -REM Kiwy Signage Player - Windows Build Script -REM ===================================================================== -REM This script builds a standalone Windows .exe using PyInstaller. -REM -REM Prerequisites: -REM 1. Python 3.10+ installed (with "Add to PATH" checked) -REM 2. Visual C++ Redistributable (for ffpyplayer) -REM 3. FFmpeg binaries in PATH (optional, for video codec support) -REM -REM Steps: -REM 1. Run this script from the project root or the windows\ folder -REM 2. The .exe will be created in windows\dist\KiwySignagePlayer\ -REM ===================================================================== - -setlocal enabledelayedexpansion - -cd /d "%~dp0" - -echo ============================================ -echo Kiwy Signage Player - Windows Build -echo ============================================ -echo. - -REM ---- Check Python ---- -where python >nul 2>&1 -if %ERRORLEVEL% neq 0 ( - echo [ERROR] Python not found! Please install Python 3.10+ and add it to PATH. - pause - exit /b 1 -) - -echo [INFO] Using Python: -python --version - -REM ---- Create virtual environment (if not exists) ---- -if not exist "venv\Scripts\python.exe" ( - echo. - echo [STEP] Creating virtual environment... - python -m venv venv - if %ERRORLEVEL% neq 0 ( - echo [ERROR] Failed to create virtual environment. - pause - exit /b 1 - ) -) else ( - echo [INFO] Virtual environment already exists. -) - -REM ---- Activate virtual environment ---- -call venv\Scripts\activate.bat - -REM ---- Install/upgrade pip ---- -echo. -echo [STEP] Upgrading pip... -python -m pip install --upgrade pip - -REM ---- Install dependencies ---- -echo. -echo [STEP] Installing Windows dependencies... -pip install -r requirements_win.txt -if %ERRORLEVEL% neq 0 ( - echo [ERROR] Failed to install dependencies. - pause - exit /b 1 -) - -REM ---- Verify Kivy installation ---- -echo. -echo [STEP] Verifying Kivy installation... -python -c "import kivy; print(f'Kivy {kivy.__version__}')" 2>&1 -if %ERRORLEVEL% neq 0 ( - echo [WARNING] Kivy check failed. Build may still work but test carefully. -) - -REM ---- Check PyInstaller ---- -echo. -echo [STEP] Verifying PyInstaller... -python -c "import PyInstaller; print(f'PyInstaller {PyInstaller.__version__}')" 2>&1 -if %ERRORLEVEL% neq 0 ( - echo [ERROR] PyInstaller not found. - pause - exit /b 1 -) - -REM ---- Create app icon (from PNG if possible) ---- -echo. -echo [STEP] Checking for app icon... -if not exist "..\config\resources\app_icon.ico" ( - echo [INFO] No .ico icon found. Will use default PyInstaller icon. - echo [INFO] To add a custom icon, place app_icon.ico in config\resources\ -) - -REM ---- Run PyInstaller ---- -echo. -echo [STEP] Building executable with PyInstaller... -echo This may take several minutes. Please wait... -echo. - -pyinstaller build.spec --clean --noconfirm -if %ERRORLEVEL% neq 0 ( - echo. - echo [ERROR] PyInstaller build failed! - echo Check the output above for error details. - pause - exit /b 1 -) - -REM ---- Optional code signing ---------------------------------------- -REM For production PCs with Smart App Control ON, the exe MUST be signed -REM by a cert from a reputable public CA. If you have a .pfx, put its path -REM in the env var KIWY_SIGN_PFX (and optionally KIWY_SIGN_PFX_PASSWORD), -REM or drop a pfx named "kiwy_signing.pfx" in this folder. The build will -REM then auto-sign via sign_exe.ps1. -echo. -echo [STEP] Checking for code-signing certificate... - -set "SIGN_PFX=%KIWY_SIGN_PFX%" -if not defined SIGN_PFX if exist "%~dp0kiwy_signing.pfx" set "SIGN_PFX=%~dp0kiwy_signing.pfx" - -if defined SIGN_PFX ( - echo [INFO ] Code-signing cert found: %SIGN_PFX% - if defined KIWY_SIGN_PFX_PASSWORD ( - powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0sign_exe.ps1" -CertPath "%SIGN_PFX%" -CertPassword "%KIWY_SIGN_PFX_PASSWORD%" - ) else ( - powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0sign_exe.ps1" -CertPath "%SIGN_PFX%" - ) - if not "!ERRORLEVEL!"=="0" ( - echo [WARNING] Signing failed or signtool missing - the exe is NOT signed. - echo Smart App Control machines will still block it. - ) else ( - echo [OK] Executable signed successfully. - ) -) else ( - echo [INFO ] No signing cert found - skipping signing. - echo [INFO ] To sign automatically, set KIWY_SIGN_PFX to your .pfx path - echo or place "kiwy_signing.pfx" in this folder. - echo [INFO ] NOTE: Unsigned exe will be BLOCKED on PCs with Smart App Control ON. -) - -REM ---- Success ---- -echo. -echo ============================================ -echo BUILD COMPLETE! -echo ============================================ -echo. -echo Output: %~dp0dist\KiwySignagePlayer\ -echo. -echo The executable is: -echo %~dp0dist\KiwySignagePlayer\KiwySignagePlayer.exe -echo. -echo To run: Double-click KiwySignagePlayer.exe -echo. -echo Note: The first run may take a while as Windows Defender -echo scans the executable. This is normal. -echo. -pause diff --git a/windows/cef_browser.py b/windows/cef_browser.py deleted file mode 100644 index d6a978b..0000000 --- a/windows/cef_browser.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -cef_browser.py v2 — Embedded Chromium INSIDE Kivy's SDL2 window - -v1 created a separate Win32 window (same as external Chrome). -v2 creates CEF as a **child window** of Kivy's SDL_app window: - - No separate taskbar entry - - No z-order fighting - - No desktop flash - - CEF message loop pumped via Kivy Clock (main thread) -""" - -import ctypes -import os -from pathlib import Path - -try: - from cefpython3 import cefpython as cef - CEF_AVAILABLE = True -except ImportError: - CEF_AVAILABLE = False - -WS_CHILD = 0x40000000 -WS_VISIBLE = 0x10000000 -WS_CLIPSIBLINGS = 0x04000000 -WS_CLIPCHILDREN = 0x02000000 -SW_HIDE = 0 -SW_SHOWNORMAL = 1 - - -class CefBrowser: - def __init__(self): - self._browser = None - self._cef_initialized = False - self._child_hwnd = None - self._kivy_hwnd = None - self._clock_event = None - self._showing = False - - # ── Public API ────────────────────────────────────────────────── - - def show(self, url): - if not CEF_AVAILABLE: - return False - if not self._cef_initialized: - self._init_cef() - if self._browser is not None: - self._browser.Navigate(url) - self._show_in_kivy() - return True - return self._create_embedded(url) - - def hide(self): - self._showing = False - if self._clock_event is not None: - try: - from kivy.clock import Clock - Clock.unschedule(self._clock_event) - except Exception: - pass - self._clock_event = None - if self._child_hwnd: - try: - ctypes.windll.user32.ShowWindow(self._child_hwnd, SW_HIDE) - except Exception: - pass - if self._browser is not None: - try: - self._browser.CloseBrowser(True) - except Exception: - pass - self._browser = None - if self._child_hwnd: - try: - ctypes.windll.user32.DestroyWindow(self._child_hwnd) - except Exception: - pass - self._child_hwnd = None - - def shutdown(self): - self.hide() - if self._cef_initialized: - try: - cef.Shutdown() - except Exception: - pass - self._cef_initialized = False - - def navigate(self, url): - if self._browser is not None: - self._browser.Navigate(url) - - def is_showing(self): - return self._showing - - def resize(self, width, height): - """Called when Kivy window resizes — repositions CEF child.""" - if self._child_hwnd: - ctypes.windll.user32.SetWindowPos( - self._child_hwnd, 0, 0, 0, width, height, 0x0004 - ) - if self._browser: - self._browser.SetBounds(0, 0, width, height) - - # ── Internal ──────────────────────────────────────────────────── - - def _init_cef(self): - settings = { - "multi_threaded_message_loop": False, - "single_process": True, - "log_severity": cef.LOGSEVERITY_WARNING, - "user_agent": "Mozilla/5.0 KiwySignage/1.0", - "cache_path": str( - Path(os.environ.get("KIWY_DATA_DIR", ".")) / ".cef_cache" - ), - } - cef.Initialize(settings=settings) - self._cef_initialized = True - - def _get_kivy_hwnd(self): - if self._kivy_hwnd is not None: - return self._kivy_hwnd - try: - import win32gui - hwnd = win32gui.FindWindow("SDL_app", None) - if hwnd: - self._kivy_hwnd = hwnd - return hwnd - except Exception: - pass - return None - - def _create_embedded(self, url): - kivy_hwnd = self._get_kivy_hwnd() - if not kivy_hwnd: - return False - - user32 = ctypes.windll.user32 - rect = (ctypes.c_long * 4)() - user32.GetClientRect(kivy_hwnd, ctypes.byref(rect)) - w, h = rect[2], rect[3] - - hinstance = ctypes.windll.kernel32.GetModuleHandleW(None) - self._child_hwnd = user32.CreateWindowExW( - 0, b'#32770', b'', - WS_CHILD | WS_CLIPSIBLINGS | WS_CLIPCHILDREN, - 0, 0, w, h, kivy_hwnd, 0, hinstance, 0, - ) - if not self._child_hwnd: - return False - - winfo = cef.WindowInfo() - winfo.SetAsChild(self._child_hwnd, [0, 0, w, h]) - self._browser = cef.CreateBrowserSync( - window_info=winfo, - settings={"background_color": 0x00000000}, - url=url, - ) - self._showing = True - self._show_in_kivy() - self._start_clock_pump() - return True - - def _show_in_kivy(self): - if not self._child_hwnd: - return - kivy_hwnd = self._get_kivy_hwnd() - if kivy_hwnd: - user32 = ctypes.windll.user32 - rect = (ctypes.c_long * 4)() - user32.GetClientRect(kivy_hwnd, ctypes.byref(rect)) - user32.SetWindowPos( - self._child_hwnd, 0, 0, 0, rect[2], rect[3], 0x0004 - ) - ctypes.windll.user32.ShowWindow(self._child_hwnd, SW_SHOWNORMAL) - self._showing = True - - def _start_clock_pump(self): - if self._clock_event is not None: - return - - def _pump(dt): - if self._cef_initialized: - try: - cef.MessageLoopWork() - except Exception: - pass - if self._showing: - from kivy.clock import Clock - self._clock_event = Clock.schedule_once(_pump, 0.01) - - from kivy.clock import Clock - self._clock_event = Clock.schedule_once(_pump, 0) diff --git a/windows/create_self_signed_cert.ps1 b/windows/create_self_signed_cert.ps1 deleted file mode 100644 index e202c90..0000000 --- a/windows/create_self_signed_cert.ps1 +++ /dev/null @@ -1,110 +0,0 @@ -<# -================================================================================ - Kiwy Signage Player - Self-Signed Certificate Creator -================================================================================ - Creates a self-signed code-signing certificate for DEV/TEST machines. - - IMPORTANT (please read before running): - A self-signed certificate, even when trusted locally, does NOT satisfy - Smart App Control (SAC). SAC only trusts reputable public CAs. This script - is therefore ONLY for development / test PCs where you have admin rights - and where SAC is either OFF or the app is run with SAC disabled. - - For production PCs (SAC ON, no admin), you MUST buy a code-signing cert - from a public CA (Sectigo/SSL.com/DigiCert/GlobalSign) and use - sign_exe.ps1 with that .pfx. - - What this does: - 1. Creates a self-signed code-signing cert in the Current User store - (never expires, exportable so you can move it to the build machine). - 2. Exports it to a .pfx (password-protected) so build_win.bat can sign. - 3. Asks if you want to trust it on THIS machine (installs to Root + Trusted - Publisher + Trusted People) so the player runs without SmartScreen/ - Defender prompts on this dev PC. - - Usage (run as Administrator): - .\create_self_signed_cert.ps1 - .\create_self_signed_cert.ps1 -CertName "Kiwy Signage Dev" -PfxPassword "ChangeMe!1" -ExportPath "C:\certs\kiwy_dev.pfx" -================================================================================ -#> -[CmdletBinding()] -param( - [string]$CertName = 'Kiwy Signage Player (Dev)', - [string]$Subject = 'CN=Kiwy Signage Player (Dev)', - [string]$PfxPassword = 'KiwySignage2026!', - [string]$ExportPath = (Join-Path $PSScriptRoot 'kiwy_dev_signing.pfx'), - [switch]$SkipTrust -) - -$ErrorActionPreference = 'Stop' -Set-Location $PSScriptRoot - -# ── 1. Create the self-signed code-signing certificate ────────────── -Write-Host "[STEP] Creating self-signed code-signing certificate..." -ForegroundColor Cyan - -$cert = New-SelfSignedCertificate ` - -Subject $Subject ` - -FriendlyName $CertName ` - -Type CodeSigningCert ` - -CertStoreLocation 'Cert:\CurrentUser\My' ` - -KeyExportPolicy Exportable ` - -KeyAlgorithm RSA ` - -KeyLength 2048 ` - -NotAfter (Get-Date).AddYears(10) - -if (-not $cert) { - Write-Host "[ERROR] Failed to create certificate." -ForegroundColor Red - exit 1 -} -Write-Host "[OK ] Created cert: $($cert.Subject) thumbprint=$($cert.Thumbprint)" -ForegroundColor Green - -# ── 2. Export to PFX ───────────────────────────────────────────────── -Write-Host "[STEP] Exporting to PFX: $ExportPath" -ForegroundColor Cyan -$securePwd = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText -try { - Export-PfxCertificate -Cert $cert -FilePath $ExportPath -Password $securePwd -Force | Out-Null - Write-Host "[OK ] PFX written: $ExportPath" -ForegroundColor Green -} catch { - Write-Host "[WARN ] Could not export PFX (still usable from cert store): $($_.Exception.Message)" -ForegroundColor Yellow -} - -# ── 3. Trust it on THIS machine (Root + Trusted Publisher) ─────────── -if (-not $SkipTrust) { - Write-Host "[STEP] Installing to Trusted Root + Trusted Publisher (requires admin)..." -ForegroundColor Cyan - try { - $rootStore = New-Object System.Security.Cryptography.X509Certificates.X509Store( - 'Root', 'CurrentUser') - $rootStore.Open('ReadWrite') - $rootStore.Add($cert) - $rootStore.Close() - - $pubStore = New-Object System.Security.Cryptography.X509Certificates.X509Store( - 'TrustedPublisher', 'CurrentUser') - $pubStore.Open('ReadWrite') - $pubStore.Add($cert) - $pubStore.Close() - - $peopleStore = New-Object System.Security.Cryptography.X509Certificates.X509Store( - 'TrustedPeople', 'CurrentUser') - $peopleStore.Open('ReadWrite') - $peopleStore.Add($cert) - $peopleStore.Close() - - Write-Host "[OK ] Certificate trusted on this machine." -ForegroundColor Green - } catch { - Write-Host "[WARN ] Trust install failed (run as Administrator): $($_.Exception.Message)" -ForegroundColor Yellow - } -} - -Write-Host "" -Write-Host "================ RESULT ================" -ForegroundColor Green -Write-Host "Cert : $($cert.Subject)" -Write-Host "Thumb : $($cert.Thumbprint)" -Write-Host "PFX : $ExportPath (password: $PfxPassword)" -Write-Host "" -Write-Host "To sign the exe with this cert:" -Write-Host " .\sign_exe.ps1 -CertPath `"$ExportPath`" -CertPassword `"$PfxPassword`"" -Write-Host "" -Write-Host "REMINDER: This self-signed cert is for DEV ONLY. Production PCs" -Write-Host "with Smart App Control ON need a cert from a public CA." -Write-Host "========================================" -ForegroundColor Green diff --git a/windows/development-track.md b/windows/development-track.md deleted file mode 100644 index e102338..0000000 --- a/windows/development-track.md +++ /dev/null @@ -1,317 +0,0 @@ -# 🧪 Development Track — Kiwy Signage Player (Windows Edition) - -> This file tracks every change, bug fix, tested solution, build info, and -> pending issues for the Windows port. Read this FIRST before starting any -> debugging or coding session. - ---- - -## 📅 Current Session — 2026-08-07 - -| Field | Value | -|-------|-------| -| **Branch** | `Windows-Player` | -| **Python** | 3.12.9 — `windows\venv\` (250 MB, all deps installed) | -| **Kivy** | 2.3.1 | -| **PyInstaller** | 6.21.0 | -| **Last .exe build** | 2026-08-07 08:23 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (98.8 MB) | -| **Build command** | `.\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` | - -### Overnight soak test findings (2026-08-07 morning) - -- **Symptom:** player froze on the last video widget (never advanced past - `video_loaded`); heartbeat stopped; 7 leaked `msedge.exe` processes left - running in the background. -- **Root cause (two compounding bugs in `run_win.py`):** - 1. **Leaked browser + instant-exit handoff.** A previously leaked Chrome/Edge - process held the `.kiosk-profile` lock. The next weblink launch handed - the URL to that leaked instance and **exited in ~2s** (`07:04:57` launch → - `07:04:59 next_media_called`). The watchdog advanced instantly, so the - weblink never showed AND the leaked browser window was never killed → - `msedge.exe` processes accumulated overnight. - 2. **Main-thread freeze.** The focus keeper ran heavy Win32 work - (`EnumWindows` + `AttachThreadInput` + `SetForegroundWindow` + `SendInput`) - synchronously on the Kivy thread every second. With leaked Edge windows - fighting back, this wedged the event loop → video never advanced, heartbeat - stopped (`08-07 07:09`). -- **Fix (in `run_win.py`, rebuilt 08:23):** - 1. New `_windows_kill_browsers_on_profile()` — scans `chrome/msedge/chromium` - command lines (WMIC, PowerShell fallback), taskkills any browser holding - the `.kiosk-profile` lock. Called **before every weblink launch**. - 2. Watchdog now has `MIN_ALIVE_BEFORE_EARLY_ADVANCE = 8s` — an instant - (~2s) handoff exit no longer advances/skips the weblink. - 3. `_bring_kivy_to_front(async_ok=True)` runs the heavy Win32 bring-to-front - on a **background worker thread** guarded by a lock, so the Kivy main - thread is never blocked. Synchronous `async_ok=False` still available for - explicit transitions. - -### 📋 Cross-platform audit — Linux commands → Windows handling - -Every Linux-only command in `src/` was cross-referenced against the patches -in `windows/run_win.py`. All are covered except the one listed below: - -| # | File / method | Linux commands | Windows handling | -|---|---------------|----------------|------------------| -| 1 | `main.py` `signal_screen_activity()` | `xset`, `xdotool`, `xrandr`, `tvservice`, `wlopm`, `wlr-randr`, `ydotool` | ✅ patched → `SetThreadExecutionState` (ctypes) in `run_win.py` | -| 2 | `main.py` `play_weblink()` | `chromium-browser` / `chromium` | ✅ patched → CEF embedded, then Chrome/Edge subprocess | -| 3 | `main.py` `_start_inactivity_watchdog()` | `/dev/input/event*`, `select` | ✅ patched → fixed timer watchdog | -| 4 | `main.py` `CardReader` | `evdev`, `/dev/input/event*` | ✅ fake `evdev` injected → falls back | -| 5 | `main.py` `SettingsPopup.test_connection` | `/tmp/temp_auth_test.json` | ✅ patched → `tempfile.gettempdir()` | -| 6 | `main.py` weblink kill/prewarm wrappers | `proc.terminate()` only | ✅ patched → `taskkill /F /T` + `_Win32Overlay` | -| 7 | `network_monitor.py` `_test_server_connection()` | `ping -c 3 -W 3` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** | -| 8 | `network_monitor.py` `_restart_wifi()` | `sudo rfkill`, `sudo ifconfig`, `sudo dhclient` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** | -| 9 | `get_playlists_v2.py`, `player_auth.py`, `ssl_utils.py`, `edit_popup.py`, `keyboard_widget.py` | none | ✅ no Linux commands | - ---- - -## 🐛 Bug Tracker - -### [BUG-010] NetworkMonitor uses Linux-only ping + rfkill commands -- **Status:** ✅ **Fixed — 2026-07-31** -- **Symptom:** `network_monitor.py` ran `ping -c 3 -W 3` (Linux flags) and on - connection failure invoked `sudo rfkill` / `sudo ifconfig wlan0` / - `sudo dhclient` — all fail or hang on Windows (`sudo` isn't even present). -- **Root cause:** This module was missed when the other Linux paths were - patched in `run_win.py`. -- **Fix:** Made `network_monitor.py` self-contained cross-platform: - 1. Added `IS_WINDOWS = platform.system() == 'Windows'` - 2. `_test_server_connection()` uses `ping -n 3 -w 3000` on Windows - 3. `_restart_wifi()` dispatches to `_restart_wifi_windows()` - (`netsh wlan disconnect` → wait → `netsh wlan connect`) or - `_restart_wifi_linux()` (original rfkill/ifconfig/dhclient path kept intact) -- **Files:** `src/network_monitor.py` -- **Test:** Windows `ping -n 3 -w 3000 localhost` returns 0; AST parse OK. - ---- - -### [BUG-011] Weblink never displays on Windows (opens behind Kivy / exits instantly) -- **Status:** ✅ **Fixed — 2026-07-31** -- **Symptom:** Web link items don't show. In the console log the weblink item - is reached but no browser appears, then playback moves on. -- **Root causes (two compounding):** - 1. **Chrome re-used an existing instance.** `subprocess.Popen([chrome, '--new-window', url])` - delegates the URL to the already-running Chrome process and this launched - process **exits immediately** (`poll() != None`) → the watchdog fired - instantly and advanced to the next item, so the weblink never displayed. - 2. **Overlay-hide raised Kivy over Chrome.** `_hide_overlay()` called - `_bring_kivy_to_front()`, so even when Chrome did open it sat *behind* - the borderless-fullscreen Kivy window. -- **Fix (in `windows/run_win.py`):** - 1. Launch Chrome/Edge with a **dedicated `--user-data-dir`** (`/.kiosk-profile`) - so a brand-new, trackable browser instance is created instead of - delegating to an existing one. Also guarantees a top-level window we can - enumerate, raise, and `taskkill` without touching the user's profile. - 2. `_hide_overlay()` now calls **`_bring_chrome_to_front(proc)`** (new helper - that enumerates `Chrome_WidgetWin_1/0` windows owned by the launched PID) - instead of raising Kivy. -- **Update (2026-07-31 15:42):** added **`--kiosk`** flag to the weblink launch - args so the browser opens in true kiosk mode (no UI/chrome, locks to screen). - Safe with the dedicated `--user-data-dir` — does not affect the user's normal - browser session. -- **Update (2026-07-31 16:04):** replaced the fixed 1.0s overlay-hide timer with - **adaptive polling** (`_hide_overlay_when_chrome_ready`). The black overlay now - stays up until Chrome's window is actually detected on screen - (`_find_chrome_hwnd`), so the host desktop is never exposed during cold - starts / slow disk / GPU init. Falls back to Kivy after a 6s timeout. -- **Update (2026-07-31 16:19):** added a **persistent `_Win32Backdrop`** — a - fullscreen black window created at player startup (`_Win32Backdrop.show()`) - placed at `HWND_BOTTOM` (below Kivy & the kiosk browser, above the desktop), - destroyed only on clean exit. Any browser load/unload gap now reveals clean - black instead of the host desktop. -- **Test:** exe rebuilt 2026-07-31 16:19; DLL set intact (28 DLLs incl. FFmpeg). - -### [BUG-012] Next widget never comes to foreground after weblink ends -- **Status:** ✅ **Fixed — 2026-07-31** -- **Symptom:** After a weblink finishes, the next media/widget renders but the - Kivy window stays behind (or the window focus is lost) — user sees the wrong - window / frozen view. -- **Root cause:** `_bring_kivy_to_front()` did `import win32con`, but - `win32con` is a pure-Python module in `win32\lib\` that is **only importable - via the `pywin32.pth` file**. `.pth` files are ignored in frozen PyInstaller - apps, so `win32con` was never bundled (confirmed via `pyi-archive_viewer` — - only `win32gui.pyd` / `win32api.pyd` / `win32process.pyd` present). The - `import win32con` threw, the whole function silently fell back to - `Window.raise_window()`, and the Kivy window was never reliably raised. -- **Fix (in `windows/run_win.py`):** - 1. Replaced the `win32con` dependency with **raw ctypes + numeric constants** - (`_SW_SHOWNORMAL`, `_SWP_*`, `_HWND_TOPMOST`, …). - 2. New `_bring_hwnd_to_front(hwnd)` — ctypes-only `SetForegroundWindow` with - `AttachThreadInput` foreground-lock bypass + `IsIconic` restore + topmost - flash. - 3. `_bring_kivy_to_front()` now uses `_find_kivy_hwnd()` (win32gui.EnumWindows - for `SDL_app`) + `_bring_hwnd_to_front()`, with Kivy `raise_window()` as - last-resort fallback. -- **Test:** exe rebuilt; no `win32con` import remains in `run_win.py`. - ---- - -### [BUG-001] RecursionError: play_current_media ↔ restart_playlist -- **Status:** ✅ Fixed 2026-07-24 -- **Symptom:** Pressing "Restart Player" in settings with empty playlist causes - infinite recursion: `play_current_media → restart_playlist → play_current_media → ...` -- **Fix:** Added empty-playlist guard in both `play_current_media()` and - `restart_playlist()` → they return early instead of calling each other. -- **Files:** `src/main.py` — lines ~1304 and ~2073 -- **Test:** Verified no Python syntax errors via `ast.parse`. - -### [BUG-002] Settings fields cut off on small screens -- **Status:** ✅ Fixed 2026-07-24 -- **Symptom:** "Screen Name", "Quickconnect" and other fields at the top of - the settings popup are invisible on smaller resolutions because content - overflows the popup. -- **Fix:** Wrapped settings content in a `ScrollView`. Moved "Save & Close" / - "Cancel" buttons outside the scroll (always visible). Reduced row heights. -- **Files:** `src/signage_player.kv` — `` block - -### [BUG-003] Chromium not fullscreen on Windows -- **Status:** ✅ Fixed 2026-07-24 -- **Symptom:** Web links open in a small window instead of fullscreen. -- **Fix:** Changed launch args from `--kiosk` to `--start-maximized --app=URL` - + explicit `--window-size=WxH`. `--kiosk` uses Wayland exclusive-fullscreen - protocol which doesn't work on Windows. -- **Tested rejected solutions:** - - ❌ `--kiosk` alone → small window, no fullscreen - - ❌ `--start-fullscreen` alone → not reliable - - ✅ `--start-maximized --app=URL --window-size=...` → works -- **Files:** `windows/run_win.py` — `_windows_play_weblink()` - -### [BUG-004] Desktop flash when switching between Chromium and Kivy -- **Status:** ✅ Fixed 2026-07-24 -- **Symptom:** When Chrome closes, the desktop is briefly visible before Kivy - reappears. Also when Chrome opens, there's a flash. -- **Fix:** Added `_Win32Overlay` class — a fullscreen black Win32 window that - covers the screen during transitions. Shown BEFORE closing Chrome / opening - Chrome, hidden AFTER Kivy is ready. -- **Tested rejected solutions:** - - ❌ `Window.raise_window()` alone → still shows flash - - ✅ Win32 black overlay → smooth masking -- **Files:** `windows/run_win.py` — `_Win32Overlay` class - -### [BUG-005] Chrome processes linger after closing weblink -- **Status:** ✅ Fixed 2026-07-24 -- **Symptom:** After a weblink item ends, Chrome child processes (GPU, - renderer) remain running → blank windows accumulate. -- **Fix:** Use `taskkill /F /T /PID ` to kill the entire process tree. -- **Tested rejected solutions:** - - ❌ `proc.terminate()` → leaves children running - - ❌ `proc.kill()` → same problem - - ✅ `taskkill /F /T` → kills everything -- **Files:** `windows/run_win.py` — `_windows_kill_process_tree()` - -### [BUG-007] Video plays behind Chromium on weblink→media transition -- **Status:** ✅ **Fixed — 2026-07-26 (final)** -- **Symptom:** When a weblink ends and the next media starts, the media plays - *behind* Chromium. Audio is heard but user sees Chrome. -- **Root cause (Windows):** Linux renders Kivy widget UNDER Chromium → closes - Chrome → widget visible. On Windows Chrome stays ON TOP. - `Window.raise_window()` is unreliable. Three compounding issues: - 1. `KivyWindow.minimize()` made Kivy impossible to bring back reliably - 2. `_windows_play_current_media` killed the browser but never restored - `content_area.opacity = 1`, so next widget rendered invisible - 3. `_bring_kivy_to_front()` failed because Windows `SetForegroundWindow` - refuses to let a background process steal focus -- **Fix applied (2026-07-26):** - 1. **Removed `KivyWindow.minimize()`** in `_windows_play_weblink()` — Kivy - stays visible behind the overlay instead of being hidden - 2. **Restored `content_area.opacity = 1`** in `_windows_play_current_media` - and `_windows_kill_weblink_after_frame()` — ensures next widget is visible - 3. **`_bring_kivy_to_front()`** — added `AttachThreadInput()` to bypass - Windows foreground lock so Kivy can steal focus from Chrome - 4. **Overlay hide** now calls `_bring_kivy_to_front()` instead of - `Window.raise_window()` - 5. **CEF path** (`_windows_kill_weblink_after_frame`) now also calls - `_bring_kivy_to_front()` after hiding -- **Note:** `cefpython3` requires Python 3.10 — falls back to subprocess - Chrome/Edge on 3.12.9. Transition now works reliably with subprocess path. -- **Files:** `windows/run_win.py` - -### [BUG-008] Intro video and media files not found at runtime -- **Status:** ✅ **Fixed** 2026-07-24 -- **Symptom:** `[ERROR] [Image] Error loading <...intro1.mp4>` — intro - broken. Also `❌ Media file not found` for playlist items. -- **Root cause:** Media download only ran when `server_version > local_version`. - When versions matched (v16 == v16), `download_media_files` was never called - → media folder stayed empty. -- **Fix:** Added download check in the "up to date" branch — now downloads - missing media files even when playlist version hasn't changed. - -### [BUG-009] Video never advances to next item (EOS handler empty) -- **Status:** ✅ **Fixed** 2026-07-24 -- **Symptom:** Video plays but never advances to the next playlist item. -- **Root cause:** `_on_video_eos()` callback was a stub — just logged - "Video finished playing (EOS)" but never called `next_media()`. -- **Fix:** Added `Clock.unschedule(self.next_media)` + `Clock.schedule_once` - to advance after 0.5s when a video reaches end of stream. - ---- - -## 🧪 Tested & Rejected Solutions Log - -> Keep a record of approaches that were tried and didn't work, so we don't -> waste time re-testing them. - -| Date | What was tested | Result | Reason it failed | -|------|----------------|--------|-----------------| -| 2026-07-24 | Python 3.14 with Kivy | ❌ | `kivy_deps.sdl2_dev~=0.8.0` has no cp314 wheel | -| 2026-07-24 | `--kiosk` Chrome flag on Windows | ❌ | Not fullscreen, Wayland exclusive-fullscreen not available | -| 2026-07-24 | `--start-fullscreen` alone | ❌ | Inconsistent, sometimes not full | -| 2026-07-24 | `proc.terminate()` for Chrome | ❌ | Leaves child processes running | -| 2026-07-24 | `proc.kill()` for Chrome | ❌ | Same as terminate — children survive | -| 2026-07-24 | `Window.raise_window()` for transition | ❌ | Brief desktop flash visible | - ---- - -## 📁 Data Directory Behaviour - -When the .exe runs: -1. Runtime hook (`pyi_runtime_hook.py`) sets `KIWY_DATA_DIR = exe_dir` -2. `run_win.py` patches `SignagePlayer.__init__` to use `KIWY_DATA_DIR` -3. Local folders created next to the .exe: - ``` - KiwySignagePlayer.exe - config/ - app_config.json - resources/ (icons, intro video) - certs/ (SSL certificates) - media/ - edited_media/ - playlists/ - logs/ - .kivy/ (Kivy home) - .player_heartbeat - ``` - ---- - -## 🔧 Build Cheatsheet - -```powershell -# Build the .exe (from windows/ directory) -Set-Location windows -& .\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm - -# Run in dev mode (no build needed) -& .\venv\Scripts\python.exe run_win.py - -# Test imports only -& .\venv\Scripts\python.exe test_import_fix.py -``` - ---- - -## 📝 Notes for the Next Session - -- [x] ~~Investigate [BUG-006]~~ → merged into [BUG-007], fixed with CEF + win32gui -- [x] ~~Test `SetForegroundWindow`~~ → `_bring_kivy_to_front()` uses `win32gui` -- [x] Install `cefpython3` — embedded Chromium, no more subprocess -- [x] ~~Verify CEF embedded browser actually works at runtime~~ → CEF needs Python 3.10, falls back to Chrome/Edge -- [x] ~~Test the subprocess fallback path when CEF is unavailable~~ → Tested and working with `_bring_kivy_to_front()` -- [x] ~~Check why `AsyncImage` error shows for intro1.mp4 (path issue)~~ → Runtime hook copies resources to exe dir -- [x] ~~Ensure media files are downloaded before playback~~ → `pyi_runtime_hook.py` copies config/resources on first run -- [x] ~~Add `cef_browser.py` to PyInstaller hidden imports~~ → Already in `build.spec` -- [x] ~~Make `network_monitor.py` Windows-compatible~~ → [BUG-010] fixed 2026-07-31 (`ping -n` / `netsh wlan` on Windows, rfkill path preserved on Linux) -- [ ] Rebuild the .exe to pick up the `network_monitor.py` fix -- [ ] Clean `cefpython3` from `venv/` (Python 3.12 won't use it anyway) -- [ ] Verify the .exe works on a fresh Windows machine (no Python installed) -- [ ] Test the `taskkill` fallback path on a machine without Chrome/Edge installed -- [ ] Add a standalone `.bat` launcher for development mode diff --git a/windows/launch_player.bat b/windows/launch_player.bat deleted file mode 100644 index 846c358..0000000 --- a/windows/launch_player.bat +++ /dev/null @@ -1,27 +0,0 @@ -@echo off -REM ============================================================ -REM Kiwy Signage Player - Windows Launcher -REM ============================================================ -REM This batch file launches the Kiwy Signage Player executable. -REM It creates local folders for playlist, media, config, and logs -REM next to the executable. -REM ============================================================ - -cd /d "%~dp0dist\KiwySignagePlayer" - -echo ============================================ -echo Kiwy Signage Player - Windows Edition -echo ============================================ -echo. -echo Launching player... -echo. - -start "" "KiwySignagePlayer.exe" - -echo Player started. -echo. -echo If the player window does not appear, check: -echo dist\KiwySignagePlayer\logs\crash.log -echo dist\KiwySignagePlayer\logs\fatal_crash.log -echo. -pause diff --git a/windows/pyi_runtime_hook.py b/windows/pyi_runtime_hook.py deleted file mode 100644 index cbce93c..0000000 --- a/windows/pyi_runtime_hook.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -PyInstaller Runtime Hook for Kiwy Signage Player ------------------------------------------------- -Runs at startup of the packaged .exe to fix paths and environment. -Creates all necessary folders LOCAL to the executable's directory. -""" - -import os -import sys -import platform -from pathlib import Path - - -def _set_process_dpi_awareness(): - """Declare per-monitor DPI awareness BEFORE SDL/Kivy initialize. - - On a display scaled above 100% (e.g. 1920x1080 @ 125%), Windows - virtualizes a non-DPI-aware app to the scaled-down size (1536x864). - Kivy then sizes its content area to the virtualized resolution, leaving a - black strip on one side and making images/videos render at the wrong size. - Must run before any SDL window is created, so this lives in the runtime - hook (the first Python code that runs in the frozen app). - """ - try: - try: - aware = ctypes.c_int(2) # PROCESS_PER_MONITOR_DPI_AWARE_V2 - ctypes.windll.shcore.SetProcessDpiAwareness(aware) - return - except Exception: - pass - try: - ctypes.windll.user32.SetProcessDPIAware() - return - except Exception: - pass - except Exception: - pass - - -if platform.system() == 'Windows': - import ctypes - _set_process_dpi_awareness() - -# ── IMPORTANT: Set Windows environment BEFORE any Kivy code runs ── -# This must happen before main.py's top-level code executes, because -# main.py sets SDL_VIDEODRIVER=wayland,x11,dummy which would crash on Windows. -os.environ['SDL_VIDEODRIVER'] = 'windows' -os.environ['SDL_AUDIODRIVER'] = 'directsound' -os.environ['KIVY_WINDOW'] = 'sdl2' -os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2' -os.environ['KIVY_VIDEO'] = 'ffpyplayer' -os.environ['KIVY_AUDIO'] = 'ffpyplayer' -os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8' -os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0' -os.environ['KIVY_NO_FILELOG'] = '1' -os.environ['KIVY_INPUTPROVIDERS'] = '' # Let Kivy auto-detect on Windows -# Use native physical pixels (fixes black strip on DPI-scaled displays). -os.environ.setdefault('SDL_VIDEO_HIGHDPI', '1') - -# ── Capture ALL early output to a crash log ───────────────────────── -# Ensure we catch any exception that happens before Logger is available. -_startup_log_path = None -try: - _exe_dir = Path(sys.executable).parent - _startup_log_path = _exe_dir / 'logs' / 'startup_crash.log' - (_startup_log_path.parent).mkdir(parents=True, exist_ok=True) - with open(_startup_log_path, 'w') as _f: - _f.write("pyi_runtime_hook.py started\n") -except Exception: - pass - - -def _setup_paths(): - """Ensure the app can find its bundled files at runtime. - - All data folders (config, media, playlists, logs) are created - LOCAL to the executable's directory — NOT in %%APPDATA%%. - """ - # In PyInstaller, sys.executable is the .exe path. - # sys._MEIPASS is the extraction directory (i.e. _internal/ folder). - exe_dir = Path(sys.executable).parent - internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir)) - - # ── Change cwd to _internal so Builder.load_file('signage_player.kv') - # and other relative file references from main.py resolve ───── - os.chdir(str(internal_dir)) - - # Add bundled src directory to Python path - src_dir = str(internal_dir / 'src') - if os.path.isdir(src_dir) and src_dir not in sys.path: - sys.path.insert(0, src_dir) - - # Add internal directory for config/media/playlists access - if str(internal_dir) not in sys.path: - sys.path.insert(0, str(internal_dir)) - - # ── Local folders next to the .exe ────────────────────────────── - # All data lives in the SAME folder as the executable so the user - # can copy/move the whole directory and everything still works. - os.environ['KIWY_DATA_DIR'] = str(exe_dir) - - # Set KIVY_HOME to a local .kivy folder next to the .exe - kivy_home = exe_dir / '.kivy' - os.environ.setdefault('KIVY_HOME', str(kivy_home)) - kivy_home.mkdir(parents=True, exist_ok=True) - - # Create local data folders next to the .exe - for sub in ['config', 'config/resources', 'media', 'playlists', 'logs']: - (exe_dir / sub).mkdir(parents=True, exist_ok=True) - - -def _copy_bundled_resources(): - """Copy bundled resource files to the local folders on first run. - - NOTE: app_config.json is intentionally absent. It is not bundled (see - build.spec) because shipping it would plant the build machine's server - settings into a fresh install. The player instead starts unconfigured and - runs its first-run setup, writing a real config next to the .exe. - """ - exe_dir = Path(sys.executable).parent - internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir)) - - # Files to copy (source in bundle -> destination next to .exe) - files_to_copy = [ - ('config/resources/access-card.png', 'config/resources/access-card.png'), - ('config/resources/arrow.png', 'config/resources/arrow.png'), - ('config/resources/backward.png', 'config/resources/backward.png'), - ('config/resources/card-checked.png', 'config/resources/card-checked.png'), - ('config/resources/edit-pen.png', 'config/resources/edit-pen.png'), - ('config/resources/exit.png', 'config/resources/exit.png'), - ('config/resources/forward.png', 'config/resources/forward.png'), - ('config/resources/intro1.mp4', 'config/resources/intro1.mp4'), - ('config/resources/pause.png', 'config/resources/pause.png'), - ('config/resources/pencil.png', 'config/resources/pencil.png'), - ('config/resources/play.png', 'config/resources/play.png'), - ('config/resources/settings.png', 'config/resources/settings.png'), - ] - - for src_rel, dest_rel in files_to_copy: - src_path = internal_dir / src_rel - dest_path = exe_dir / dest_rel - if src_path.is_file() and not dest_path.exists(): - try: - dest_path.parent.mkdir(parents=True, exist_ok=True) - import shutil - shutil.copy2(str(src_path), str(dest_path)) - except Exception: - pass # Non-critical; app can still run - - -# ── Wrap everything in try/except to capture early crashes ────────── -try: - _setup_paths() - _copy_bundled_resources() - # If we reach here, the hook finished successfully - try: - with open(_startup_log_path, 'a') as _f: - _f.write("pyi_runtime_hook.py completed successfully\n") - except Exception: - pass -except Exception as _hook_exc: - import traceback as _tb - try: - with open(_startup_log_path, 'a') as _f: - _f.write(f"pyi_runtime_hook.py CRASHED: {_hook_exc}\n") - _tb.print_exc(file=_f) - except Exception: - pass - raise # Re-raise so the .exe still fails visibly diff --git a/windows/requirements_win.txt b/windows/requirements_win.txt deleted file mode 100644 index b0145f6..0000000 --- a/windows/requirements_win.txt +++ /dev/null @@ -1,51 +0,0 @@ -# ===================================================================== -# Kiwy Signage Player - Windows Dependencies -# ===================================================================== -# Install with: pip install -r requirements_win.txt - -# --- Core GUI Framework --- -# Kivy 2.3+ with SDL2 backend (best for Windows) -kivy[base]>=2.3.0 - -# --- Video Playback --- -# ffpyplayer for video decoding -ffpyplayer>=4.5 - -# --- HTTP / Networking --- -requests>=2.32.0,<3.0.0 -aiohttp>=3.9.0,<4.0.0 -certifi>=2024.0.0 - -# --- Password / Auth --- -bcrypt>=4.2.0,<5.0.0 - -# --- Packaging --- -# PyInstaller for building the .exe -pyinstaller>=6.0 - -# --- Embedded web engine (web links) --- -# pythonnet lets Python drive the WebView2 .NET SDK. WebView2 renders INSIDE -# the Kivy window as a child window, which is what removed the old subprocess -# browser bugs (window opening behind the player, instant hand-off exit, -# z-order/focus fights, leaked chrome.exe/msedge.exe processes). -# The WebView2 *runtime* is a free, Microsoft-shipped evergreen component and -# is intentionally NOT bundled; the small SDK DLLs live in windows/webview2_sdk/ -# and are added to the exe by build.spec. -# Without pythonnet the player silently falls back to the Chrome/Edge -# subprocess engine, so weblinks still work but with the old drawbacks. -pythonnet>=3.0.3 - -# --- Windows-specific Libraries --- -# cefpython3: Embedded Chromium browser (replaces subprocess Chrome/Edge) -# Installed separately because it's a large package (69 MB): -# pip install cefpython3 -# cefpython3>=66.1 -# Note: Uncomment above line to bundle cefpython3 in the .exe. -# Without it, weblinks fall back to subprocess Chrome/Edge. - -# pywin32: Windows API bindings (win32gui for SetForegroundWindow etc.) -# Already installed as a dependency of kivy[base] - -# --- Optional: DirectShow filters for better video on Windows --- -# ffmpeg (install via chocolatey or manual download) -# https://ffmpeg.org/download.html diff --git a/windows/run_win.py b/windows/run_win.py deleted file mode 100644 index 77af6ed..0000000 --- a/windows/run_win.py +++ /dev/null @@ -1,2306 +0,0 @@ -""" -Kiwy Signage Player - Windows Entry Point ------------------------------------------- -Patches platform-specific code and environment for Windows before launching -the original Kivy-based signage player application. - -Usage: - python run_win.py (for development/testing) - run_win.exe (after PyInstaller build) -""" - -import ctypes -import os -import sys -import platform -import tempfile -import subprocess -import shutil -import logging -from pathlib import Path - - -def _set_process_dpi_awareness(): - """Declare per-monitor DPI awareness so Kivy/SDL2 see the TRUE resolution. - - Without this, on a display scaled above 100% (e.g. 1920x1080 @ 125%), - Windows virtualizes the app to the scaled-down size (1536x864). Kivy then - sizes the content area to the virtualized resolution, leaving a black strip - on one side and making images/videos render at the wrong size. - - Prefer PROCESS_PER_MONITOR_DPI_AWARE_V2 (2); fall back to - PROCESS_PER_MONITOR_DPI_AWARE (1) and PROCESS_SYSTEM_DPI_AWARE (0). - """ - try: - try: - # Windows 10 1703+ - aware = ctypes.c_int(2) # PROCESS_PER_MONITOR_DPI_AWARE_V2 - ctypes.windll.shcore.SetProcessDpiAwareness(aware) - return - except Exception: - pass - try: - # Windows 8.1 / fallback - ctypes.windll.user32.SetProcessDPIAware() - return - except Exception: - pass - except Exception: - pass - - -# ── Declare DPI awareness BEFORE any Kivy/SDL import ──────────────── -_set_process_dpi_awareness() - -# Make SDL2 use the native (physical) pixel size instead of the DPI-scaled -# virtual size. Without this, on a 125%-scaled display the window and the -# Kivy content area are sized to the virtualized resolution (1536x864) even -# when the monitor is 1920x1080, leaving a black strip and seeing the -# desktop through the gap. -os.environ.setdefault('SDL_VIDEO_HIGHDPI', '1') - -# ── Windows-native card reader (Raw Input API + LL-hook fallback) ─── -# Imported here (not lazily) so PyInstaller bundles it via run_win.py's -# module graph. It replaces the Linux-only evdev-based CardReader. -from win_card_reader import WindowsCardReader - - -def _show_error_box(title, message): - """Show a Windows message box with the error (visible even without console).""" - try: - ctypes.windll.user32.MessageBoxW(0, message, title, 0x10) # MB_ICONERROR - except Exception: - pass - -# --- Ensure we are on Windows; warn if not --- -if platform.system() != 'Windows': - print(f"WARNING: This entry point is designed for Windows. Detected: {platform.system()}") - -# ===================================================================== -# 1. Set Windows-compatible environment variables BEFORE Kivy imports -# ===================================================================== - -# Video driver: Use 'windib' or 'angle' (DirectX via ANGLE) for Windows -os.environ.setdefault('SDL_VIDEODRIVER', 'windows') -# Audio driver: DirectSound for Windows -os.environ.setdefault('SDL_AUDIODRIVER', 'directsound') -# Prevent screensaver -os.environ.setdefault('SDL_VIDEO_ALLOW_SCREENSAVER', '0') -# Video backend via ffpyplayer -os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer') -os.environ.setdefault('KIVY_AUDIO', 'ffpyplayer') -os.environ.setdefault('FFPYPLAYER_CODECS', 'h264,h265,vp9,vp8') -# Kivy window backend: prefer SDL2 on Windows -os.environ.setdefault('KIVY_WINDOW', 'sdl2') -# OpenGL -os.environ.setdefault('KIVY_GL_BACKEND', 'angle_sdl2') - -# ===================================================================== -# 2. Patch the evdev import — it is Linux-only. We provide a dummy -# module so that `from evdev import ...` will not crash on Windows. -# ===================================================================== -class _DummyEvdev: - """Fake evdev module that raises ImportError for all meaningful uses.""" - - class InputDevice: - def __init__(self, *a, **kw): - raise ImportError("evdev is not available on Windows") - - class ecodes: - EV_KEY = 1 - EV_ABS = 3 - - def categorize(self, *a, **kw): - raise ImportError("evdev is not available on Windows") - - def list_devices(self): - return [] - - -class _FakeEvdevInputDevice: - pass - - -# Inject the fake evdev module into sys.modules so that main.py's -# `try: import evdev` succeeds but EVDEV_AVAILABLE stays False. -_evdev_dummy = _DummyEvdev() -sys.modules['evdev'] = _evdev_dummy -sys.modules['evdev.InputDevice'] = _FakeEvdevInputDevice - -# ===================================================================== -# 3. Provide a Windows implementation of screen activity signaling -# We monkey-patch the SignagePlayer.signal_screen_activity method -# after the class is defined but before it's used, by hooking into -# the import machinery. -# ===================================================================== - -# We'll store a reference to the original module's signal_screen_activity -# so we can replace it after import. This is done inside _patch_main(). - -# Keep-awake state so we can restore the screensaver on exit. -_SAVED_SCREENSAVER_ACTIVE = None # True/False once read; None = unknown - - -def _disable_windows_screensaver(): - """Disable the Windows screensaver so the lock screen never appears. - - On Windows the lock screen is tied to the screensaver: when the screen - 'turns off' or the screensaver runs with 'On resume, display logon - screen', Windows shows the lock. Disabling the screensaver and keeping - the display awake (SetThreadExecutionState ES_DISPLAY_REQUIRED) prevents - both the blank screen and the lock screen. - """ - global _SAVED_SCREENSAVER_ACTIVE - try: - user32 = ctypes.windll.user32 - SPI_GETSCREENSAVEACTIVE = 0x0010 - SPI_SETSCREENSAVEACTIVE = 0x0011 - SPI_SETSCREENSAVERUNSAFE = 0x0013 - SPIF_SENDCHANGE = 0x2 - - user32.SystemParametersInfoW.argtypes = [ - ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_uint - ] - user32.SystemParametersInfoW.restype = ctypes.c_int - - # Remember the original screensaver state once, so we can restore it - # when the app exits. - if _SAVED_SCREENSAVER_ACTIVE is None: - pval = ctypes.c_int(0) - if user32.SystemParametersInfoW(SPI_GETSCREENSAVEACTIVE, 0, - ctypes.byref(pval), 0): - _SAVED_SCREENSAVER_ACTIVE = bool(pval.value) - - # Disable the screensaver (uiParam=0) and mark it safe to toggle - # without a password prompt (SPI_SETSCREENSAVERUNSAFE). - user32.SystemParametersInfoW(SPI_SETSCREENSAVEACTIVE, 0, 0, - SPIF_SENDCHANGE) - user32.SystemParametersInfoW(SPI_SETSCREENSAVERUNSAFE, 0, 0, - SPIF_SENDCHANGE) - except Exception: - pass # non-critical - - -def _restore_windows_screensaver(): - """Restore the screensaver state the app found at startup.""" - global _SAVED_SCREENSAVER_ACTIVE - if _SAVED_SCREENSAVER_ACTIVE is None: - return - try: - user32 = ctypes.windll.user32 - SPI_SETSCREENSAVEACTIVE = 0x0011 - SPIF_SENDCHANGE = 0x2 - user32.SystemParametersInfoW.argtypes = [ - ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_uint - ] - user32.SystemParametersInfoW( - SPI_SETSCREENSAVEACTIVE, - 1 if _SAVED_SCREENSAVER_ACTIVE else 0, 0, SPIF_SENDCHANGE) - _SAVED_SCREENSAVER_ACTIVE = None - except Exception: - pass - - -def _windows_screen_activity(self, dt): - """Windows keep-awake: prevent display-off, sleep AND lock screen. - - SetThreadExecutionState(ES_CONTINUOUS|ES_SYSTEM_REQUIRED|ES_DISPLAY_REQUIRED) - tells Windows the system and display must stay on. Combined with disabling - the screensaver (SystemParametersInfo), this prevents: - - the display turning off, - - the machine sleeping, - - the lock screen (which appears when the screen 'turns off' or the - screensaver runs with logon-on-resume). - Called every ~20s by the existing Clock.schedule_interval. - """ - try: - # ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED - ES_CONTINUOUS = 0x80000000 - ES_SYSTEM_REQUIRED = 0x00000001 - ES_DISPLAY_REQUIRED = 0x00000002 - ctypes.windll.kernel32.SetThreadExecutionState( - ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED - ) - except Exception: - pass # non-critical - # Disable the screensaver / lock screen (re-asserted every tick in case - # the OS or another process re-enabled it). - _disable_windows_screensaver() - - -# ── Try to import the embedded CEF browser ────────────────────────── -_CEF_BROWSER = None - -def _get_cef_browser(): - """Return the shared CefBrowser singleton, or None if unavailable. - - v2 embeds CEF as a CHILD WINDOW inside Kivy's SDL_app window. - This means: no separate taskbar entry, no z-order fighting, - no desktop flash, no taskkill needed. - """ - global _CEF_BROWSER - if _CEF_BROWSER is None: - try: - from cef_browser import CefBrowser, CEF_AVAILABLE - if CEF_AVAILABLE: - _CEF_BROWSER = CefBrowser() - else: - return None - except Exception: - return None - return _CEF_BROWSER - - -# ── Embedded WebView2 (Edge) browser ──────────────────────────────── -_WEBVIEW2_BROWSER = None - -#: How long a weblink will wait for a Runtime that is still installing. -_WEBVIEW2_INSTALL_WAIT = 300.0 - - -def _ensure_webview2_runtime_async(): - """Start installing the WebView2 Runtime if it is missing. - - Called once at start-up. The install runs on a background thread so a - machine without the Runtime can install it while the player is still - syncing its playlist, rather than freezing the UI on the first weblink. - """ - try: - import webview2_runtime - except Exception as exc: - _log(f"WebView2 runtime module unavailable: {exc}") - return - try: - _log(webview2_runtime.describe()) - webview2_runtime.ensure_runtime_async() - except Exception as exc: - _log(f"WebView2 runtime check failed (non-fatal): {exc}") - - -def _get_webview2_browser(force_new=False): - """Return the shared WebView2Browser singleton, or None if unavailable. - - WebView2 renders as a CHILD HWND of Kivy's SDL window, which is what makes - it immune to the subprocess weblink bugs (background window, instant - hand-off exit, z-order fights, leaked browsers). - """ - global _WEBVIEW2_BROWSER - if force_new: - old, _WEBVIEW2_BROWSER = _WEBVIEW2_BROWSER, None - if old is not None: - try: - old.shutdown() - except Exception: - pass - if _WEBVIEW2_BROWSER is None: - try: - from webview2_browser import WebView2Browser - except Exception as exc: - _log(f"WebView2 module unavailable: {exc}") - return None - - if not WebView2Browser.is_available(): - # The SDK is bundled but the Runtime may still be installing - # (a machine that shipped without it). Wait here rather than - # silently downgrading to the leaky Chrome/Edge engine — this runs - # on the watcher thread, not the Kivy main thread. - if _wait_for_webview2_runtime(): - pass # installed in the meantime; retry below - if not WebView2Browser.is_available(): - reason = getattr(WebView2Browser, '_import_error', None) or 'unavailable' - _log(f"WebView2 not usable: {reason}") - return None - - try: - data_dir = os.environ.get('KIWY_DATA_DIR', os.getcwd()) - _WEBVIEW2_BROWSER = WebView2Browser( - hwnd_provider=_find_kivy_hwnd, - user_data_dir=os.path.join(data_dir, '.webview2-profile'), - ) - except Exception as exc: - _log(f"WebView2 init failed: {exc}") - return None - return _WEBVIEW2_BROWSER - - -def _wait_for_webview2_runtime(timeout=_WEBVIEW2_INSTALL_WAIT): - """Block while a Runtime install is in flight. True if it became available.""" - try: - import webview2_runtime - except Exception: - return False - state = webview2_runtime.get_state() - if not state.get('installing'): - return False - _log("WebView2: waiting for the Runtime install to finish...") - ok = webview2_runtime.wait_for_install(timeout) - _log(f"WebView2: Runtime install wait finished (available={ok})") - return ok - - -def _windows_find_browser(): - """Find Chrome or Edge executable on Windows for weblink support. - - Returns the path to the browser or None. - """ - # Common install locations - candidates = [ - # Chrome - os.path.expandvars(r'%PROGRAMFILES%\Google\Chrome\Application\chrome.exe'), - os.path.expandvars(r'%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe'), - os.path.expandvars(r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe'), - # Edge - os.path.expandvars(r'%PROGRAMFILES%\Microsoft\Edge\Application\msedge.exe'), - os.path.expandvars(r'%PROGRAMFILES(X86)%\Microsoft\Edge\Application\msedge.exe'), - os.path.expandvars(r'%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe'), - ] - for path in candidates: - if os.path.isfile(path): - return path - - # Fallback: try PATH - which = shutil.which('chrome') or shutil.which('msedge') or shutil.which('google-chrome') - if which: - return which - - return None - - -# ── Win32 API helpers via ctypes ───────────────────────────────────── -class _Win32Overlay: - """Fullscreen black overlay window to mask desktop during transitions. - - When switching to/away from Chromium, the browser window appears/disappears - and there is a brief moment where the desktop is visible. This overlay - covers that flash with a pure-black borderless always-on-top Win32 window. - - For weblink open, the overlay is kept up by the adapter's - `on_before_launch` and only hidden once the browser window is confirmed - visible (see `_WinChromeAdapter.wait_visible`), so the desktop is never - exposed while the browser is still starting. - """ - - _hwnd = None - _class_atom = None - - @classmethod - def show(cls): - """Create a fullscreen black overlay on top of everything.""" - if cls._hwnd is not None: - return # already showing - try: - user32 = ctypes.windll.user32 - kernel32 = ctypes.windll.kernel32 - - # Register a simple window class - WNDPROC = ctypes.WINFUNCTYPE( - ctypes.c_int64, ctypes.c_int64, ctypes.c_uint, - ctypes.c_uint64, ctypes.c_int64 - ) - - @WNDPROC - def wnd_proc(hwnd, msg, wparam, lparam): - if msg == 0x0002: # WM_DESTROY - user32.PostQuitMessage(0) - if msg == 0x0014: # WM_ERASEBKGND - return 1 # tell Windows we erased it - return user32.DefWindowProcW(hwnd, msg, wparam, lparam) - - hinstance = kernel32.GetModuleHandleW(None) - - # Register class - class_name = 'KiwyOverlay_' + str(ctypes.c_uint64(int(kernel32.GetTickCount64())).value) - wc = ctypes.create_unicode_buffer(256) - - _WNDCLASS = ctypes.c_byte * (6 * 8) # rough size - buf = _WNDCLASS() - # Simple approach: use RegisterClassExW - user32.RegisterClassExW.restype = ctypes.c_uint16 - user32.RegisterClassExW.argtypes = [ctypes.c_void_p] - - # We'll use a simpler method: just create a MessageBox-style window - # Actually, let's use the simplest possible approach: - - # Get screen dimensions - screen_w = user32.GetSystemMetrics(0) # SM_CXSCREEN - screen_h = user32.GetSystemMetrics(1) # SM_CYSCREEN - - # Create a borderless always-on-top window - cls._hwnd = user32.CreateWindowExW( - 0x00000008, # WS_EX_TOPMOST | WS_EX_TOOLWINDOW - b'#32770', # Dialog class - always available - b'', # no title - 0x80000000 | 0x10000000, # WS_POPUP | WS_VISIBLE - 0, 0, screen_w, screen_h, - 0, 0, hinstance, 0 - ) - - if cls._hwnd: - # Make it black - from ctypes import wintypes - gdi32 = ctypes.windll.gdi32 - hdc = user32.GetDC(cls._hwnd) - rect = (ctypes.c_long * 4)(0, 0, screen_w, screen_h) - brush = gdi32.CreateSolidBrush(0x00000000) # black brush - gdi32.FillRect(hdc, ctypes.byref(rect), brush) - gdi32.DeleteObject(brush) - user32.ReleaseDC(cls._hwnd, hdc) - - # Force it to the top - user32.SetWindowPos(cls._hwnd, -1, 0, 0, screen_w, screen_h, 0x0002 | 0x0040) - user32.ShowWindow(cls._hwnd, 1) # SW_SHOWNORMAL - user32.UpdateWindow(cls._hwnd) - except Exception: - cls._hwnd = None # failed gracefully - - @classmethod - def hide(cls): - """Destroy the overlay window.""" - if cls._hwnd is None: - return - try: - user32 = ctypes.windll.user32 - user32.DestroyWindow(cls._hwnd) - except Exception: - pass - cls._hwnd = None - - -class _Win32Backdrop: - """Persistent fullscreen black window shown at player startup. - - Sits just above the host desktop but BELOW the Kivy window and the kiosk - browser (placed at HWND_BOTTOM). Because it stays up for the whole session, - any gap while the weblink browser loads or unloads reveals this clean black - screen instead of the host desktop — no more desktop flash during the - browser load/unload transitions. - """ - - _hwnd = None - - @classmethod - def show(cls): - """Create (once) the fullscreen black backdrop above the desktop.""" - if cls._hwnd is not None: - return # already showing - try: - user32 = ctypes.windll.user32 - kernel32 = ctypes.windll.kernel32 - hinstance = kernel32.GetModuleHandleW(None) - screen_w = user32.GetSystemMetrics(0) # SM_CXSCREEN - screen_h = user32.GetSystemMetrics(1) # SM_CYSCREEN - - hwnd = user32.CreateWindowExW( - 0x00000080, # WS_EX_TOOLWINDOW (no taskbar entry) - b'#32770', # dialog class (always available) - b'KiwyBackdrop', - 0x80000000 | 0x10000000, # WS_POPUP | WS_VISIBLE - 0, 0, screen_w, screen_h, - 0, 0, hinstance, 0, - ) - if not hwnd: - return - - # Paint it black - gdi32 = ctypes.windll.gdi32 - hdc = user32.GetDC(hwnd) - rect = (ctypes.c_long * 4)(0, 0, screen_w, screen_h) - brush = gdi32.CreateSolidBrush(0x00000000) # black brush - gdi32.FillRect(hdc, ctypes.byref(rect), brush) - gdi32.DeleteObject(brush) - user32.ReleaseDC(hwnd, hdc) - - # Keep it BELOW the app windows (HWND_BOTTOM = 1) so Kivy and the - # kiosk browser draw on top, but still above the desktop. - user32.SetWindowPos( - hwnd, 1, 0, 0, screen_w, screen_h, - 0x0002 | 0x0040, # SWP_NOMOVE | SWP_SHOWWINDOW - ) - user32.ShowWindow(hwnd, 1) - user32.UpdateWindow(hwnd) - cls._hwnd = hwnd - except Exception: - cls._hwnd = None # failed gracefully - - @classmethod - def hide(cls): - """Destroy the backdrop (only at application exit).""" - if cls._hwnd is None: - return - try: - ctypes.windll.user32.DestroyWindow(cls._hwnd) - except Exception: - pass - cls._hwnd = None - - -# Win32 constants used directly (avoid `import win32con` — win32con is a -# pure-Python module in win32\\lib\\ that PyInstaller does NOT bundle because -# it is only reachable through the pywin32.pth file, which frozen apps ignore). -_SW_SHOWNORMAL = 1 -_SW_MINIMIZE = 6 -_SW_RESTORE = 9 -_SWP_NOSIZE = 0x0001 -_SWP_NOMOVE = 0x0002 -_SWP_NOACTIVATE = 0x0010 -_SWP_SHOWWINDOW = 0x0040 -_HWND_TOPMOST = -1 -_HWND_NOTOPMOST = -2 -_GWL_EXSTYLE = -20 -_WS_EX_TOPMOST = 0x00000008 - -# ── Low-level keyboard lockdown (production / kiosk mode) ─────────── -# WH_KEYBOARD_LL constants and virtual-key codes used to swallow host -# shortcuts (Alt+F4, Alt+Tab, Win, Ctrl+Esc) while the player is the -# only thing the operator should interact with. -_WH_KEYBOARD_LL = 13 -_WM_KEYDOWN = 0x0100 -_WM_KEYUP = 0x0101 -_WM_SYSKEYDOWN = 0x0104 -_WM_SYSKEYUP = 0x0105 -_HC_ACTION = 0 -_VK_TAB = 0x09 -_VK_ESCAPE = 0x1B -_VK_LWIN = 0x5B -_VK_RWIN = 0x5C -_VK_F4 = 0x73 -_VK_LCONTROL = 0xA2 -_VK_RCONTROL = 0xA3 -_VK_LMENU = 0xA4 # left Alt -_VK_RMENU = 0xA5 # right Alt - -# Holds the Win32 state for the active keyboard hook (installed while -# production mode is ON). Kept at module scope so the hook proc can be -# referenced without being garbage collected. -_KB_HOOK = { - 'proc': None, - 'handle': None, - 'active': False, -} - - -def _kb_hook_callback(nCode, wParam, lParam): - """Low-level keyboard hook callback. - - Called on the thread that installed the hook for every keyboard event. - We swallow the host-level shortcuts that would let the operator escape - the kiosk player: - - Alt+F4 (close the player / focus-steal) - - Alt+Tab (switch to another app) - - Ctrl+Esc (open Start menu) - - Windows key (open Start menu) - - Alt+Escape (cycle windows) - Returns 1 (consume) for those keys, otherwise passes the event through. - - NOTE: this runs inside a ctypes callback. If it raises, the exception - crosses the native boundary and can crash the process, so every path is - guarded and the hook always forwards with CallNextHookEx. - """ - try: - if nCode == _HC_ACTION: - vk_code = ctypes.cast( - lParam, ctypes.POINTER(ctypes.c_ulong) - ).contents.value & 0xFFFF - # Full key state so we can detect modifier combos reliably. - keys = { - 'lctrl': _is_key_down(_VK_LCONTROL), - 'rctrl': _is_key_down(_VK_RCONTROL), - 'lalt': _is_key_down(_VK_LMENU), - 'ralt': _is_key_down(_VK_RMENU), - 'lwin': _is_key_down(_VK_LWIN), - 'rwin': _is_key_down(_VK_RWIN), - } - ctrl = keys['lctrl'] or keys['rctrl'] - alt = keys['lalt'] or keys['ralt'] - win = keys['lwin'] or keys['rwin'] - - # Block the dangerous host shortcuts. - if vk_code == _VK_F4 and alt: - return 1 # Alt+F4 - if vk_code == _VK_TAB and alt: - return 1 # Alt+Tab - if vk_code == _VK_ESCAPE and ctrl: - return 1 # Ctrl+Esc - if vk_code == _VK_ESCAPE and alt: - return 1 # Alt+Esc - if win: - return 1 # Windows key (left or right) - - # NOTE: Ctrl+Alt+Delete (SAS) is handled by the OS before any - # user-mode hook can see it — it cannot be blocked from here. - except Exception: - # Never let a callback exception cross the native boundary. - pass - try: - return ctypes.windll.user32.CallNextHookEx( - _KB_HOOK['handle'], nCode, wParam, lParam - ) - except Exception: - return 1 # last resort: consume rather than crash - - -def _is_key_down(vk): - """Return True if the given virtual-key is currently pressed.""" - try: - state = ctypes.windll.user32.GetAsyncKeyState(vk) - # 0x8000 = most significant bit set (key is down) - return bool(state & 0x8000) - except Exception: - return False - - -def _install_kb_lockdown(): - """Install the low-level keyboard hook for kiosk mode.""" - global _KB_HOOK - if _KB_HOOK['active']: - return - try: - user32 = ctypes.windll.user32 - HOOKPROC = ctypes.WINFUNCTYPE( - ctypes.c_long, ctypes.c_int, ctypes.c_uint, ctypes.c_ulong - ) - proc = HOOKPROC(_kb_hook_callback) - hmodule = ctypes.windll.kernel32.GetModuleHandleW(None) - handle = user32.SetWindowsHookExW( - _WH_KEYBOARD_LL, proc, hmodule, 0 - ) - if not handle: - return False - _KB_HOOK['proc'] = proc - _KB_HOOK['handle'] = handle - _KB_HOOK['active'] = True - return True - except Exception: - return False - - -def _uninstall_kb_lockdown(): - """Remove the low-level keyboard hook (dev mode).""" - global _KB_HOOK - if not _KB_HOOK['active']: - return - try: - if _KB_HOOK['handle']: - ctypes.windll.user32.UnhookWindowsHookEx(_KB_HOOK['handle']) - except Exception: - pass - _KB_HOOK['handle'] = None - _KB_HOOK['proc'] = None - _KB_HOOK['active'] = False - - -def _windows_apply_kiosk_mode(self, enabled): - """Windows-specific kiosk lockdown in addition to the base logic. - - Installs/uninstalls the low-level keyboard hook that swallows - Alt+F4, Alt+Tab, Win, Ctrl+Esc while the player is in production - mode. Also calls the base implementation for the cross-platform - pieces (exit_on_escape, on_request_close guard, Ctrl+C ignore). - """ - # Call the base (cross-platform) kiosk logic first. - base_apply = getattr( - _patch_main, '_base_apply_kiosk_mode', None - ) or _base_apply_kiosk_mode - base_apply(self, enabled) - - if enabled: - _install_kb_lockdown() - Logger.info( - "run_win: Windows keyboard lockdown ACTIVE " - "(Alt+F4/Alt+Tab/Win/Ctrl+Esc swallowed)" - ) - else: - _uninstall_kb_lockdown() - Logger.info("run_win: Windows keyboard lockdown DISABLED") - - -# Default cross-platform kiosk applier (kept here so the main-module patch -# can reference it; the real implementation lives in main.py, and we simply -# forward to it when the patched method is not available). -def _base_apply_kiosk_mode(self, enabled): - self.config['production_mode'] = bool(enabled) - if enabled: - try: - from kivy.config import Config - Config.set('kivy', 'exit_on_escape', '0') - except Exception: - pass - try: - import signal - signal.signal(signal.SIGINT, signal.SIG_IGN) - except Exception: - pass - else: - try: - import signal - signal.signal(signal.SIGINT, signal.default_int_handler) - except Exception: - pass - - -def _force_foreground_sendinput(hwnd): - """Bypass the Windows foreground lock using the SendInput trick. - - Windows only lets a process call SetForegroundWindow() if it is the "last - input process" — i.e. it processed the most recent keyboard/mouse input. - A background signage player that never gets user input can therefore be - denied foreground forever once another window (like a cycling kiosk - Chrome) owns the input queue. That is exactly the "focus lost after many - weblink cycles" bug. - - The classic workaround: synthesize a real input event (an invisible - Alt-key press) via SendInput. This makes the *current* process the last - input process, so the subsequent SetForegroundWindow() is allowed. - - NOTE: This is the same technique used by AutoHotkey and countless kiosk - apps. It briefly fakes a keypress, but since we send only a modifier key - (Alt) that is immediately released, the user never sees it. - """ - try: - # 1) Send a harmless Alt keydown + keyup so THIS process becomes the - # last-input process. - user32 = ctypes.windll.user32 - - # NOTE: The Win32 INPUT struct is a 32-byte union (mouse/keyboard/ - # hardware) preceded by a 4-byte type and 4 bytes of padding on x64, - # for a total of 40 bytes. The previous implementation defined INPUT - # as just type+KEYBDINPUT (32 bytes) — SendInput() rejected the - # undersized buffer (cbSize mismatch) so the fake Alt keypress was - # NEVER delivered, and the foreground lock was never defeated. That is - # why focus was permanently lost after enough weblink cycles. - if ctypes.sizeof(ctypes.c_void_p) == 8: # 64-bit - class KEYBDINPUT(ctypes.Structure): - _fields_ = [ - ('wVk', ctypes.c_ushort), - ('wScan', ctypes.c_ushort), - ('dwFlags', ctypes.c_ulong), - ('time', ctypes.c_ulong), - ('dwExtraInfo', ctypes.c_ulonglong), # ULONG_PTR - ] - - class MOUSEINPUT(ctypes.Structure): - _fields_ = [ - ('dx', ctypes.c_long), - ('dy', ctypes.c_long), - ('mouseData', ctypes.c_ulong), - ('dwFlags', ctypes.c_ulong), - ('time', ctypes.c_ulong), - ('dwExtraInfo', ctypes.c_ulonglong), # ULONG_PTR - ] - - class HARDWAREINPUT(ctypes.Structure): - _fields_ = [ - ('uMsg', ctypes.c_ulong), - ('wParamL', ctypes.c_ushort), - ('wParamH', ctypes.c_ushort), - ] - - class INPUTUNION(ctypes.Union): - _fields_ = [ - ('mi', MOUSEINPUT), - ('ki', KEYBDINPUT), - ('hi', HARDWAREINPUT), - ] - - class INPUT(ctypes.Structure): - _fields_ = [ - ('type', ctypes.c_ulong), - ('u', INPUTUNION), - ] - else: # 32-bit fallback - class KEYBDINPUT(ctypes.Structure): - _fields_ = [ - ('wVk', ctypes.c_ushort), - ('wScan', ctypes.c_ushort), - ('dwFlags', ctypes.c_ulong), - ('time', ctypes.c_ulong), - ('dwExtraInfo', ctypes.c_ulong), # ULONG_PTR - ] - - class MOUSEINPUT(ctypes.Structure): - _fields_ = [ - ('dx', ctypes.c_long), - ('dy', ctypes.c_long), - ('mouseData', ctypes.c_ulong), - ('dwFlags', ctypes.c_ulong), - ('time', ctypes.c_ulong), - ('dwExtraInfo', ctypes.c_ulong), # ULONG_PTR - ] - - class HARDWAREINPUT(ctypes.Structure): - _fields_ = [ - ('uMsg', ctypes.c_ulong), - ('wParamL', ctypes.c_ushort), - ('wParamH', ctypes.c_ushort), - ] - - class INPUTUNION(ctypes.Union): - _fields_ = [ - ('mi', MOUSEINPUT), - ('ki', KEYBDINPUT), - ('hi', HARDWAREINPUT), - ] - - class INPUT(ctypes.Structure): - _fields_ = [ - ('type', ctypes.c_ulong), - ('u', INPUTUNION), - ] - - INPUT_KEYBOARD = 1 - KEYEVENTF_KEYUP = 0x0002 - VK_MENU = 0x12 # Alt - - # Send Alt down - inp_down = INPUT() - inp_down.type = INPUT_KEYBOARD - inp_down.u.ki.wVk = VK_MENU - # Send Alt up - inp_up = INPUT() - inp_up.type = INPUT_KEYBOARD - inp_up.u.ki.wVk = VK_MENU - inp_up.u.ki.dwFlags = KEYEVENTF_KEYUP - - arr = (INPUT * 2)(inp_down, inp_up) - user32.SendInput(2, ctypes.byref(arr), ctypes.sizeof(INPUT)) - except Exception: - pass - - -def _bring_hwnd_to_front(hwnd, use_topmost_flash=True): - """Force a Win32 window to the foreground using only ctypes. - - IMPORTANT: Windows restricts SetForegroundWindow() — a process can only - set the foreground window if it was the *last input process* or the - current foreground window is the same thread. To work around this, we - escalate through several methods: - - Method 1 — AttachThreadInput bypass: attach our calling thread (and the - target window's thread) to the current foreground window's - input thread before calling SetForegroundWindow. - Method 2 — SendInput unlock: fake an Alt keypress so our process - becomes the last-input process, then SetForegroundWindow. - This defeats the foreground lock even when another process - (e.g. a repeatedly cycling kiosk Chrome) owns the input. - Method 3 — Z-order flash: BringWindowToTop + SetWindowPos(TOPMOST then - NOTOPMOST) which reorders Z-order and works even when the - process is backgrounded. - - Returns True if the window is the foreground window afterwards, False - otherwise (so callers can retry). - """ - if not hwnd: - return False - user32 = ctypes.windll.user32 - kernel32 = ctypes.windll.kernel32 - - # If minimized, restore first so the window can actually be shown. - if user32.IsIconic(hwnd): - user32.ShowWindowAsync(hwnd, _SW_RESTORE) - user32.ShowWindow(hwnd, _SW_RESTORE) - - # ── Method 1: SetForegroundWindow with the input-thread bypass ── - try: - fore_hwnd = user32.GetForegroundWindow() - if fore_hwnd and fore_hwnd != hwnd: - fore_tid = user32.GetWindowThreadProcessId(fore_hwnd, None) - target_tid = user32.GetWindowThreadProcessId(hwnd, None) - our_tid = kernel32.GetCurrentThreadId() - if fore_tid != our_tid: - user32.AttachThreadInput(our_tid, fore_tid, True) - user32.AttachThreadInput(target_tid, fore_tid, True) - user32.SetForegroundWindow(hwnd) - user32.AttachThreadInput(target_tid, fore_tid, False) - user32.AttachThreadInput(our_tid, fore_tid, False) - else: - user32.SetForegroundWindow(hwnd) - else: - user32.SetForegroundWindow(hwnd) - except Exception: - pass - - # ── Method 2: SendInput unlock (beats the foreground lock) ── - # Only bother if we still don't have foreground after Method 1. - try: - if user32.GetForegroundWindow() != hwnd: - _force_foreground_sendinput(hwnd) - user32.SetForegroundWindow(hwnd) - user32.BringWindowToTop(hwnd) - except Exception: - pass - - # ── Method 3: Z-order + restore (reliable from a background process) ── - user32.ShowWindowAsync(hwnd, _SW_SHOWNORMAL) - user32.ShowWindow(hwnd, _SW_SHOWNORMAL) - user32.BringWindowToTop(hwnd) - if use_topmost_flash: - user32.SetWindowPos(hwnd, _HWND_TOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) - user32.SetWindowPos(hwnd, _HWND_NOTOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) - - # ── Verify ── - try: - return user32.GetForegroundWindow() == hwnd - except Exception: - return False - - -def _force_kivy_fullscreen_bounds(): - """Force the Kivy/SDL window to cover the ENTIRE physical monitor. - - Kivy's 'fullscreen' config can leave the SDL window at the DPI-virtualized - size (e.g. 1536x864 on a 1920x1080 display at 125% scaling), which shows a - black strip and lets the desktop bleed through. This resizes + repositions - the SDL window to the physical monitor bounds using Win32 directly, which - works regardless of how SDL interpreted the DPI config. - - Returns True on success, False otherwise (so callers can retry after the - window is created). - """ - try: - user32 = ctypes.windll.user32 - # SM_CXSCREEN/SM_CYSCREEN return PHYSICAL pixels once the process is - # DPI-aware (we set that at startup), so these are the true bounds. - w = user32.GetSystemMetrics(0) # SM_CXSCREEN - h = user32.GetSystemMetrics(1) # SM_CYSCREEN - if w <= 0 or h <= 0: - return False - - hwnd = _find_kivy_hwnd() - if hwnd is None: - return False - - # Cheap check: skip SetWindowPos if the window already covers the full - # monitor at 0,0 (the 2s sizing guardian calls this repeatedly, so we - # must not churn the window when the size is already correct). - try: - import win32gui - cur = win32gui.GetWindowRect(hwnd) # (left, top, right, bottom) - if (cur[0] == 0 and cur[1] == 0 - and (cur[2] - cur[0]) == w and (cur[3] - cur[1]) == h): - return True - except Exception: - pass - - # Remove any maximized flag first, then size + position at 0,0 to the - # full monitor size. SWP_NOZORDER keeps z-order unchanged. - SWP_NOZORDER = 0x0004 - SWP_FRAMECHANGED = 0x0020 - user32.SetWindowPos( - hwnd, 0, 0, 0, int(w), int(h), - SWP_NOZORDER | SWP_FRAMECHANGED, - ) - # Ensure it's visible + restored (not minimized). - user32.ShowWindow(hwnd, _SW_RESTORE) - user32.ShowWindow(hwnd, _SW_SHOWNORMAL) - return True - except Exception: - return False - - -def _reassert_kivy_fullscreen(self=None): - """Restore the Kivy window to full physical-monitor bounds AND re-sync the - Kivy content area after a weblink browser closes. - - WHY: Chrome/Edge opens its own fullscreen kiosk window over Kivy. When that - browser window is destroyed, SDL can leave the Kivy window at a wrong / - DPI-virtualized size (e.g. 1536x864 on a 1920x1080 display), and the Kivy - content_area + screen_width/height stay at the stale size -> black strip + - desktop bleed-through + wrong image/video scaling. This forcibly resizes - the SDL window to the monitor bounds and re-syncs Kivy's layout. - - self: the SignagePlayer instance (optional; used to re-sync its ids). - """ - ok = False - try: - ok = _force_kivy_fullscreen_bounds() - except Exception: - ok = False - - # Re-sync the Kivy content layout to the true monitor bounds. The native - # SetWindowPos above drives SDL's WM_SIZE -> Kivy Window.size -> _update_size - # automatically; we also set the properties directly here as immediate - # insurance so the content_area never renders at a stale size in the frame - # right after a weblink closes. - try: - user32 = ctypes.windll.user32 - w = user32.GetSystemMetrics(0) - h = user32.GetSystemMetrics(1) - if w > 0 and h > 0: - if self is not None: - try: - self.screen_width = w - self.screen_height = h - except Exception: - pass - try: - self.size = (w, h) - except Exception: - pass - try: - self.ids.content_area.size = (w, h) - except Exception: - pass - ok = True - except Exception: - pass - return ok - - -def _find_kivy_hwnd(): - """Return the HWND of the Kivy/SDL window, or None. - - IMPORTANT: The previous implementation matched ANY window whose class is - 'SDL_app' OR whose title contains 'Kiwy'/'Signage'. Because this build runs - with console=True, the exe's own console window has the title - '...\\KiwySignagePlayer\\KiwySignagePlayer.exe' — which CONTAINS both - 'Kiwy' and 'Signage'. EnumWindows lists top-level windows in Z-order, so - the console window could be returned as hwnd_list[-1], and - _bring_hwnd_to_front() would then raise the CONSOLE window instead of the - media window. That is the "app focuses the console instead of the widget" - symptom. - - Fix: only ever return a real SDL window (class 'SDL_app' or - 'SDL_app_arm'/'SDL_app_x11' variants). Never match on the title, and - explicitly exclude the console window class ('ConsoleWindowClass'). - """ - try: - import win32gui - except Exception: - return None - - # Class names of Kivy's SDL2 window on Windows. - SDL_CLASSES = ('SDL_app', 'SDL_app_x11', 'SDL_app_arm') - - sdl_windows = [] - # Fallback: in case the SDL class name differs, remember any non-console - # window owned by this process whose title mentions the app. - our_pid = None - try: - import os as _os - our_pid = _os.getpid() - except Exception: - our_pid = None - - def _enum_cb(hwnd, _): - try: - cls = win32gui.GetClassName(hwnd) - title = win32gui.GetWindowText(hwnd) - except Exception: - return - # Skip the console host window outright — it must never be the target. - if cls in ('ConsoleWindowClass', 'CASCADIA_HOSTING_WINDOW_CLASS'): - return - if cls.startswith('SDL_app') or cls in SDL_CLASSES: - sdl_windows.append(hwnd) - return - # Last-resort fallback: a visible, non-tool window of OUR process whose - # title contains the app name. This catches renamed/ALT-styled SDL - # windows without ever matching the console. - if our_pid is not None: - try: - if win32gui.GetWindowThreadProcessId(hwnd, None)[1] != our_pid: - return - except Exception: - return - if "Kiwy" in title or "Signage" in title: - sdl_windows.append(hwnd) - - try: - win32gui.EnumWindows(_enum_cb, None) - except Exception: - pass - - # Prefer the LAST enumerated SDL window (Kivy's window is typically the - # newest/topmost SDL window); the console is already excluded above. - return sdl_windows[-1] if sdl_windows else None - - -def _is_kivy_foreground(): - """Return True if the Kivy/SDL window is the foreground window. - - Cheap check (single GetForegroundWindow + class compare) so callers can - skip the expensive bring-to-front work when the window is already focused. - """ - try: - import win32gui - fg = win32gui.GetForegroundWindow() - if not fg: - return False - return win32gui.GetClassName(fg) == 'SDL_app' - except Exception: - # If win32gui is unavailable, conservatively say "not foreground" so - # the keeper will call the fallback raise (harmless). - return False - - -_BRING_FRONT_LOCK = None # guards concurrent worker-thread bring-to-front calls - - -def _bring_kivy_to_front(async_ok=True): - """Bring the Kivy/SDL window to the foreground. - - Uses win32gui.EnumWindows to find the SDL_app window, then _bring_hwnd_to_front - (ctypes-only) to force it forward — no dependency on the un-bundled - `win32con` module. Falls back to Kivy's built-in raise_window(). - - IMPORTANT (freeze fix): the heavy Win32 work (EnumWindows + - AttachThreadInput + SetForegroundWindow + SendInput) can block for a long - time when leaked Chrome/Edge windows fight back, and running it on the - Kivy main thread wedged the event loop overnight (video stuck, heartbeat - frozen). When async_ok=True (default, used from the focus keeper), the - heavy work runs on a background thread so the main thread is never - blocked; only the cheap fallback raise runs inline. - - Returns True if the Kivy window is (or is now) the foreground window, - False otherwise. - """ - global _BRING_FRONT_LOCK - - if not async_ok: - # Synchronous path: used by explicit transitions (weblink -> media) - # where the caller has already hidden the overlay and really needs the - # result now. Still guarded by a lock + timeout-safe call. - hwnd = None - try: - hwnd = _find_kivy_hwnd() - except Exception: - hwnd = None - if hwnd is not None: - try: - ok = _bring_hwnd_to_front(hwnd) - if ok: - return True - except Exception: - pass - try: - from kivy.core.window import Window - Window.show() - Window.raise_window() - except Exception: - pass - try: - return _is_kivy_foreground() - except Exception: - return False - - # ── Async path (never blocks the Kivy thread) ────────────────── - try: - if _BRING_FRONT_LOCK is None: - _BRING_FRONT_LOCK = __import__('threading').Lock() - except Exception: - _BRING_FRONT_LOCK = None - - if _BRING_FRONT_LOCK is not None and not _BRING_FRONT_LOCK.acquire(blocking=False): - # A previous bring-to-front is still running on a worker thread — - # don't pile up more work on the main thread. - return False - - def _work(): - try: - hwnd = None - try: - hwnd = _find_kivy_hwnd() - except Exception: - hwnd = None - if hwnd is not None: - try: - _bring_hwnd_to_front(hwnd) - except Exception: - pass - else: - # No SDL window found yet — cheap Kivy raise instead. - try: - from kivy.core.window import Window - Window.raise_window() - except Exception: - pass - finally: - if _BRING_FRONT_LOCK is not None: - try: - _BRING_FRONT_LOCK.release() - except Exception: - pass - - try: - t = __import__('threading').Thread(target=_work, daemon=True, - name='bring-kivy-front-win') - t.start() - except Exception: - if _BRING_FRONT_LOCK is not None: - try: - _BRING_FRONT_LOCK.release() - except Exception: - pass - return True # optimistically report; the worker does the work - - -def _find_chrome_hwnd(proc): - """Find the visible top-level HWND of a launched Chrome/Edge process. - - Returns the HWND if found, otherwise None. Enumerates top-level windows - owned by the given process and matches Chrome/Edge window classes. - """ - if proc is None: - return None - try: - import win32gui - import win32process - except Exception: - return None - - target_pid = proc.pid - chrome_hwnd = None - - def _enum_cb(hwnd, _): - nonlocal chrome_hwnd - if chrome_hwnd is not None: - return - try: - _, pid = win32process.GetWindowThreadProcessId(hwnd) - except Exception: - return - if pid != target_pid: - return - try: - cls = win32gui.GetClassName(hwnd) - except Exception: - return - # Chrome/Edge top-level window classes - if cls in ('Chrome_WidgetWin_1', 'Chrome_WidgetWin_0', 'ApplicationFrameWindow'): - if win32gui.IsWindowVisible(hwnd): - chrome_hwnd = hwnd - - try: - win32gui.EnumWindows(_enum_cb, None) - except Exception: - pass - return chrome_hwnd - - -def _windows_kill_process_tree(proc): - """Kill a process AND all its children using taskkill. - - Chrome/Edge spawns many child processes (GPU, renderer, network, - etc.). A simple proc.terminate() leaves children running, causing - lingering browser windows or zombie processes. - """ - if proc is None or proc.poll() is not None: - return - try: - subprocess.run( - ['taskkill', '/F', '/T', '/PID', str(proc.pid)], - capture_output=True, timeout=5 - ) - except Exception: - # Fallback: try terminate + kill - try: - proc.terminate() - try: - proc.wait(timeout=3) - except Exception: - proc.kill() - except Exception: - pass - - -def _log(msg): - """Module-level logger helper (Kivy Logger when available, else print).""" - try: - from kivy.logger import Logger - Logger.info(f"run_win: {msg}") - except Exception: - try: - print(f"[run_win] {msg}") - except Exception: - pass - - -def _windows_kill_browsers_on_profile(profile_dir): - """Kill every Chrome/Edge process using the given kiosk profile dir. - - WHY: Chrome/Edge hands off to an existing process when the same - --user-data-dir is already in use. If a previous weblink leaked a browser - (e.g. the app was killed while Chrome was up, or the process tree kill - missed a child), that leaked process keeps the profile lock AND owns the - visible URL window. The next weblink launch then: - 1. hands the URL to the leaked process, - 2. exits immediately -> the watchdog advances instantly, - 3. and the real browser window is never closed -> windows accumulate - in the background (observed: 7 leaked msedge.exe processes). - - This scans running browser processes, matches their command line against - the profile dir, and taskkills the whole tree so a fresh launch always - creates (and owns) its own window. - """ - if not profile_dir: - return - try: - profile_norm = os.path.normcase(os.path.normpath(profile_dir)) - # Enumerate processes with command lines via WMIC (Windows 8.1+). - # WMIC is deprecated on Win11 24H2+ but still works; fall back to - # PowerShell if it is missing. - rows = [] - try: - out = subprocess.run( - ['wmic', 'process', 'where', - "name='chrome.exe' or name='msedge.exe' or name='chromium.exe'", - 'get', 'ProcessId,CommandLine', '/format:csv'], - capture_output=True, text=True, timeout=15 - ) - for line in out.stdout.splitlines(): - if line.strip() and ',' in line: - rows.append(line) - except Exception: - rows = [] - if not rows: - # Fallback: PowerShell Get-CimInstance (Win11 24H2+ / no WMIC) - try: - ps = ( - "Get-CimInstance Win32_Process -Filter " - "\"Name='chrome.exe' or Name='msedge.exe' or Name='chromium.exe'\" | " - "ForEach-Object { \"$($_.ProcessId),$($_.CommandLine)\" }" - ) - out = subprocess.run( - ['powershell', '-NoProfile', '-Command', ps], - capture_output=True, text=True, timeout=20 - ) - for line in out.stdout.splitlines(): - if line.strip(): - rows.append(line) - except Exception: - rows = [] - - killed = 0 - for row in rows: - try: - # CSV: "Node,ProcessId,CommandLine" - parts = row.split(',', 2) - if len(parts) < 2: - continue - pid_str = parts[1].strip() - cmd = parts[2] if len(parts) > 2 else '' - if not pid_str.isdigit(): - continue - pid = int(pid_str) - if pid <= 0 or pid == os.getpid(): - continue - if profile_norm in os.path.normcase(cmd or ''): - # This browser is using our kiosk profile -> kill its tree. - try: - subprocess.run( - ['taskkill', '/F', '/T', '/PID', str(pid)], - capture_output=True, timeout=5 - ) - killed += 1 - except Exception: - pass - except Exception: - continue - if killed: - _log(f"Killed {killed} leaked browser process(es) using {profile_dir}") - return killed - except Exception as e: - _log(f"_windows_kill_browsers_on_profile error: {e}") - return 0 - - -def _patch_main(): - """Patch the main module after import for Windows compatibility.""" - # Logger is only imported lazily inside individual functions; make it - # available for the patch code at this scope as well. If it cannot be - # imported (very early startup), fall back to a no-op so the patch logic - # never crashes on a logging call. - try: - from kivy.logger import Logger # noqa: F401 - except Exception: - class _NullLogger: - @staticmethod - def _noop(*args, **kwargs): - pass - info = debug = warning = error = critical = exception = staticmethod(_noop) - Logger = _NullLogger() - - # ── CRITICAL: Override Linux env vars BEFORE importing main ───── - # main.py's top-level code sets SDL_VIDEODRIVER=wayland,x11,dummy - # and other Linux values. We MUST override these before main.py - # gets imported, otherwise Kivy will initialize with the wrong - # window provider and crash with SystemExit(1). - os.environ['SDL_VIDEODRIVER'] = 'windows' - os.environ['SDL_AUDIODRIVER'] = 'directsound' - os.environ['KIVY_WINDOW'] = 'sdl2' - os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2' - os.environ['KIVY_INPUTPROVIDERS'] = '' # Let Kivy auto-detect - os.environ['KIVY_VIDEO'] = 'ffpyplayer' - os.environ['KIVY_AUDIO'] = 'ffpyplayer' - - # Now safe to import main.py — env vars are already Windows-correct - import main as signage_main - - # ── Re-apply the fullscreen/window config AFTER main.py is imported ── - # main.py's top-level code calls Config.set('graphics','fullscreen','0') - # and window_state='maximized', which OVERRIDES the values run_win.py set - # before the import. On a DPI-scaled display that left the window at the - # virtualized size (e.g. 1536x864 on a 1920x1080 monitor), so the Kivy - # content only covered part of the screen and the desktop showed through - # the black strip. Re-asserting the config here (after main.py ran, but - # before App.run() creates the window) makes the SDL window come up at the - # true fullscreen resolution. - try: - from kivy.config import Config as _Config - _Config.set('graphics', 'fullscreen', '1') - _Config.set('graphics', 'window_state', 'maximized') - _Config.set('graphics', 'borderless', '1') - _Config.set('graphics', 'resizable', '0') - except Exception: - pass - - # Replace signal_screen_activity - # IMPORTANT: Assign under BOTH the attribute name AND the function's own name. - # Kivy's WeakMethod stores self.__func__.__name__ (= '_windows_screen_activity') - # and later does getattr(instance, '_windows_screen_activity'). If we only - # assign under 'signal_screen_activity', the weakref lookup fails with - # AttributeError: 'SignagePlayer' object has no attribute '_windows_screen_activity' - signage_main.SignagePlayer.signal_screen_activity = _windows_screen_activity - signage_main.SignagePlayer._windows_screen_activity = _windows_screen_activity - - # ── Windows web-link engines ──────────────────────────────────── - # The player's play_weblink() delegates to WeblinkSession, which owns - # launch, verified visibility, the interaction watcher and teardown. - # Windows therefore injects *adapters* (one per browser flavour) instead of - # overriding play_weblink — that is what removed the old z-order/focus - # fighting and the leaked-browser accumulation. - from weblink_session import ChromiumSubprocessAdapter - - class _WinCefAdapter(ChromiumSubprocessAdapter): - """Embedded CEF: renders inside Kivy's window, so no subprocess and - no z-order battles. Visibility cannot be 'not found' — it either shows - or raises — so the window check is skipped.""" - - name = 'cef-embedded' - embedded = True - - def __init__(self): - super().__init__(kiosk=False) - self._browser = None - self._resize_bound = False - - @property - def process(self): - return None # nothing to kill: CEF lives in-process - - def launch(self, url, width, height): - self._browser = _get_cef_browser() - if self._browser is None: - return False - self._bind_resize_once() - # The page is pumped through the Kivy Clock, so this is safe to - # call from the main thread. - return bool(self._browser.show(url)) - - def is_alive(self): - return self._browser is not None and self._browser.is_showing() - - def wait_visible(self, timeout): - """CEF is embedded: treat 'showing' as visible after a short settle.""" - import time - deadline = time.monotonic() + min(2.0, max(0.2, timeout)) - while time.monotonic() < deadline: - if self._browser is not None and self._browser.is_showing(): - # Give the compositor a moment to paint the first frame. - time.sleep(0.3) - return True, 'cef-showing' - time.sleep(0.1) - return False, 'cef did not show' - - def on_visible(self): - trace('win_weblink_CEF_shown') - - def _bind_resize_once(self): - """Bind the resize handler exactly once. - - The previous implementation rebound a NEW closure on every weblink - cycle, so Kivy's callback list grew without bound until the app - slowed down. Binding once removes that leak. - """ - if self._resize_bound: - return - try: - from kivy.core.window import Window as KivyWindow - - def _cef_resize(*args): - try: - w, h = KivyWindow.size - _orig_on_resize = getattr(KivyWindow, '_on_resize', None) - if _orig_on_resize and getattr(_orig_on_resize, '__name__', '') != '_cef_resize': - _orig_on_resize(*args) - except Exception: - pass - try: - browser = _get_cef_browser() - if browser is not None: - browser.resize(int(KivyWindow.size[0]), int(KivyWindow.size[1])) - except Exception: - pass - - KivyWindow.bind(size=_cef_resize) - self._resize_bound = True - except Exception as exc: - _log(f"CEF resize bind failed (non-fatal): {exc}") - - def teardown(self): - self.cancel_prewarm() - try: - if self._browser is not None: - self._browser.hide() - except Exception as exc: - _log(f"CEF hide failed (non-fatal): {exc}") - - def prewarm(self, url): - # CEF keeps one browser instance alive; there is nothing to warm. - pass - - class _WinWebView2Adapter(ChromiumSubprocessAdapter): - """Embedded WebView2 (Edge) — renders INSIDE the Kivy window. - - This is the preferred Windows engine. Because the page is a child HWND - of Kivy's own SDL window there is no separate browser process, so none - of the subprocess problems apply: it cannot open behind the player, it - cannot be handed off to an existing instance and exit instantly, it - does not fight for foreground/z-order, and teardown leaves no leaked - browser behind. - - ``launch`` returns as soon as the (asynchronous) start-up is requested; - the session's watcher thread then polls :meth:`wait_visible` until the - page is actually showing. - """ - - name = 'webview2-embedded' - embedded = True - # WebView2 renders in-window, but unlike raw CEF we CAN prove the page - # appeared (the controller reports when it is visible). That matters on - # a closed network: if the weblink host is unreachable the page never - # paints and the item is skipped instead of showing a blank screen. - can_verify_visibility = True - - def __init__(self): - super().__init__(kiosk=False) - self._browser = None - self._resize_bound = False - - @property - def process(self): - return None # in-process: nothing to kill - - def target_size(self): - """Use the real Kivy window size (already DPI-aware).""" - try: - from kivy.core.window import Window as KivyWindow - - width, height = (int(KivyWindow.size[0]), int(KivyWindow.size[1])) - if width > 0 and height > 0: - return width, height - except Exception: - pass - return 1920, 1080 - - def launch(self, url, width, height): - self._browser = _get_webview2_browser() - if self._browser is None: - return False - # A child HWND has no z-order to fight over, but the Kivy window - # itself must own the foreground or the page looks inert. - _bring_kivy_to_front(async_ok=False) - self._bind_resize_once() - self._browser.resize(width, height) - return bool(self._browser.show(url)) - - def is_alive(self): - browser = self._browser - if browser is None: - return False - # NOTE: `is_alive` must be True while the page is still coming up. - # WebView2 creates its environment and controller asynchronously, so - # a controller that does not exist yet is NOT a dead browser — - # treating it as one made the first weblink be skipped instantly. - return browser.is_alive() - - def wait_visible(self, timeout): - """Wait for the page to actually load, on the caller's (watcher) thread. - - Two things must hold: the controller must be showing, AND the - navigation must have completed successfully. The second condition is - what makes an unreachable host (normal on a closed network) skip the - item instead of displaying Chromium's error page for the full slot. - """ - import time - - browser = self._browser - if browser is None: - return False, 'no browser' - deadline = time.monotonic() + max(1.0, float(timeout)) - while time.monotonic() < deadline: - if browser.failed_reason: - return False, browser.failed_reason - nav = browser.navigation_succeeded() - if browser.is_showing() and nav is not None: - if not nav: - detail = browser.navigation_status() or 'navigation failed' - return False, f'page failed to load ({detail})' - # Let the compositor paint the first frame before any - # masking overlay is hidden / Kivy is restored. - time.sleep(0.3) - return True, 'webview2-loaded' - time.sleep(0.1) - if browser.is_showing() and browser.navigation_succeeded() is None: - # Controller is up but navigation never reported within the - # timeout. Treat as visible rather than skipping a page that is - # merely slow to confirm. - return True, 'webview2-showing-unconfirmed' - reason = browser.failed_reason or 'webview2 did not show' - return False, reason - - def on_visible(self): - trace('win_weblink_webview2_shown') - - def on_launch_failed(self): - # Never leave a half-built controller (or a hidden child window) - # behind when start-up fails. - browser, self._browser = self._browser, None - if browser is not None: - try: - browser.shutdown() - except Exception: - pass - _get_webview2_browser(force_new=True) - - def _bind_resize_once(self): - """Keep the page matched to the window, bound exactly once. - - Rebinding per cycle was the unbounded-callback leak fixed for CEF; - the same discipline applies here. - """ - if self._resize_bound: - return - try: - from kivy.core.window import Window as KivyWindow - - def _wv2_resize(*_args): - try: - browser = self._browser - if browser is not None and browser.is_showing(): - browser.resize( - int(KivyWindow.size[0]), int(KivyWindow.size[1]) - ) - except Exception: - pass - - KivyWindow.bind(size=_wv2_resize) - self._resize_bound = True - except Exception as exc: - _log(f"WebView2 resize bind failed (non-fatal): {exc}") - - def teardown(self): - """Hide the page (keep the controller for a fast next show). - - Hiding — not disposing — is deliberate: the controller stays alive - so the next weblink paints immediately, and because it is a child - window there is no leaked process to reap. - """ - browser = self._browser - if browser is not None: - try: - browser.hide() - except Exception as exc: - _log(f"WebView2 hide failed (non-fatal): {exc}") - # Restore Kivy as the visible surface again. - _bring_kivy_to_front(async_ok=False) - - def prewarm(self, url): - # The environment/controller are already warm after the first use; - # pre-navigating would claim the page before its slot. - pass - - class _WinChromeAdapter(ChromiumSubprocessAdapter): - """Chrome/Edge kiosk subprocess with the Windows visibility check. - - Verifying the real HWND (rather than just the process) is what stops a - blank screen when the page never paints. - """ - - name = 'chrome-subprocess' - embedded = False - - def __init__(self, browser_path): - super().__init__(browser_path=browser_path, kiosk=True) - self._profile_dir = None - - def on_before_launch(self, url, width, height): - _Win32Overlay.show() - trace('win_overlay_shown') - - def on_launch_failed(self): - _Win32Overlay.hide() - - def extra_launch_args(self): - """Dedicated profile + kiosk flags for the Windows browser. - - ``--user-data-dir`` is mandatory: without it Chrome/Edge hands the - URL to an already-running instance, our launched process exits in - ~2s and the weblink never displays (the adapter's `wait_visible` - then sees the process die and the item is skipped). It also gives - us a top-level window we can enumerate, raise and taskkill without - touching the operator's own browser profile. - """ - args = [] - if self._profile_dir: - args.append('--user-data-dir=' + self._profile_dir) - if self._kiosk: - # The base launch() already adds --start-fullscreen / - # --start-maximized; --kiosk upgrades that to a true kiosk - # window (no browser UI, locks to the screen). - args.append('--kiosk') - return args - - def launch(self, url, width, height): - # A dedicated --user-data-dir is mandatory: without it Chrome hands - # the URL to an existing process, the launched process exits - # immediately and the weblink never displays. - self._profile_dir = os.path.join( - os.environ.get('KIWY_DATA_DIR', os.getcwd()), '.kiosk-profile' - ) - try: - os.makedirs(self._profile_dir, exist_ok=True) - except Exception: - pass - - # Kill any leaked browser still holding this profile's lock BEFORE - # launching, otherwise the new launch hands off and exits. - _windows_kill_browsers_on_profile(self._profile_dir) - - return super().launch(url, width, height) - - def wait_visible(self, timeout): - """Poll for the real Chrome/Edge window, then hide the overlay.""" - import time - proc = self.process - if proc is None: - return False, 'no process' - deadline = time.monotonic() + max(1.0, float(timeout)) - while time.monotonic() < deadline: - if proc.poll() is not None: - # Exited early: either a hand-off or a crash. The process - # tree kill on the next cycle cleans up any leaked window. - return False, f'browser exited early (rc={proc.returncode})' - hwnd = _find_chrome_hwnd(proc) - if hwnd is not None: - _Win32Overlay.hide() - _bring_hwnd_to_front(hwnd) - return True, f'hwnd={hwnd}' - time.sleep(0.1) - return False, 'browser window never appeared' - - def teardown(self): - """Kill the process tree, then restore Kivy — in the safe order.""" - proc, self._proc = self._proc, None - if proc is not None and proc.poll() is None: - trace('win_killing_chrome', pid=proc.pid) - _windows_kill_process_tree(proc) - # ORDER MATTERS: hide the fullscreen overlay BEFORE raising Kivy. - # If the topmost overlay is destroyed after Kivy is raised, Windows - # hands foreground to Explorer instead of our window — the - # "player runs but stays in the background" bug. - _Win32Overlay.hide() - _bring_kivy_to_front() - try: - _reassert_kivy_fullscreen(signage_main.SignagePlayer) - except Exception: - pass - - def prewarm(self, url): - # Disabled on Windows: an off-screen Chrome claims the audio device, - # spawns GPU processes and adds a duplicate taskbar entry. - pass - - def _windows_weblink_adapter_factory(player): - """Choose the Windows web-link engines, best first. - - Order matters: - - 1. **WebView2 (embedded)** — a child HWND of the Kivy window. It cannot - open behind the player, cannot be handed off to an existing browser - and exit instantly, does not fight for z-order/foreground, and - leaves no leaked process behind. This is the preferred engine. - 2. **CEF (embedded)** — same in-window model, but cefpython3 has no - wheels past Python 3.9 so it is dormant on this build. - 3. **Chrome/Edge subprocess** — last resort only. Kept so a machine - without the WebView2 runtime still shows weblinks. - """ - adapters = [] - if _get_webview2_browser() is not None: - adapters.append(_WinWebView2Adapter()) - if _get_cef_browser() is not None: - adapters.append(_WinCefAdapter()) - browser = _windows_find_browser() - if browser: - adapters.append(_WinChromeAdapter(browser)) - if not adapters: - _log("WebView2/CEF unavailable and no Chrome/Edge found — " - "web links will be skipped") - return adapters - - signage_main.SignagePlayer.weblink_adapter_factory = staticmethod( - _windows_weblink_adapter_factory - ) - - # Replace weblink handling - # ── Give play_video a hook to re-assert the Kivy window to the front ── - # The main module calls `self._bring_kivy_to_front_win` (if present) right - # after adding a video widget, so the image -> video transition never lets - # the host desktop steal the foreground. It also exposes a CHEAP foreground - # check so the focus keeper can skip the expensive bring-to-front work - # whenever the window is already focused. - signage_main.SignagePlayer._bring_kivy_to_front_win = staticmethod( - lambda: _bring_kivy_to_front() - ) - signage_main.SignagePlayer._is_foreground_win = staticmethod( - lambda: _is_kivy_foreground() - ) - - # NOTE: the old Windows overrides of play_weblink / _start_inactivity_watchdog - # / _kill_weblink_after_frame / play_current_media / _prewarm_weblink have - # been REMOVED. WeblinkSession (src/weblink_session.py) is now the single - # owner of launch, verified visibility, the interaction watcher and - # teardown; the Windows-specific behaviour lives in the adapters injected - # by `_windows_weblink_adapter_factory` above. - - - # Patch cleanup of temp auth file (was using /tmp/) - _original_connection_test = signage_main.SettingsPopup.test_connection - - def _windows_test_connection(self): - """Test connection with Windows-safe temp file path.""" - import tempfile as _tf - import os as _os - from player_auth import PlayerAuth - import re - import threading - from kivy.clock import Clock - - self.ids.connection_status.text = 'Testing connection...' - self.ids.connection_status.color = (1, 0.7, 0, 1) - - def run_test(): - try: - server_ip = self.ids.server_input.text.strip() - screen_name = self.ids.screen_input.text.strip() - quickconnect = self.ids.quickconnect_input.text.strip() - port = self.ids.port_input.text.strip() or self.player.config.get('port', '') - use_https = self.player.config.get('use_https', True) - verify_ssl = self.player.config.get('verify_ssl', True) - - if not all([server_ip, screen_name, quickconnect]): - Clock.schedule_once(lambda dt: self.update_connection_status('Error: Fill all fields', False)) - return - - if server_ip.startswith('http://') or server_ip.startswith('https://'): - server_url = server_ip - if ':' not in server_ip.replace('https://', '').replace('http://', ''): - if port and port not in ('443', '80'): - server_url = f"{server_ip}:{port}" - else: - protocol = "https" if use_https else "http" - if ':' in server_ip: - server_url = f"{protocol}://{server_ip}" - else: - server_url = f"{protocol}://{server_ip}:{port}" if port else f"{protocol}://{server_ip}" - - # Use Windows temp path - temp_file = _os.path.join(_tf.gettempdir(), 'temp_auth_test.json') - auth = PlayerAuth(temp_file, use_https=use_https, verify_ssl=verify_ssl) - success, error = auth.authenticate(server_url=server_url, hostname=screen_name, quickconnect_code=quickconnect) - - try: - if _os.path.exists(temp_file): - _os.remove(temp_file) - except Exception: - pass - - if success: - player_name = auth.get_player_name() - Clock.schedule_once(lambda dt: self.update_connection_status(f'✓ Connected: {player_name}', True)) - else: - Clock.schedule_once(lambda dt: self.update_connection_status(f'✗ Failed: {error}', False)) - - except Exception as e: - Clock.schedule_once(lambda dt: self.update_connection_status(f'✗ Error: {str(e)}', False)) - - threading.Thread(target=run_test, daemon=True).start() - - signage_main.SettingsPopup.test_connection = _windows_test_connection - - # ── Keep player auth OUT of the bundle ─────────────────────────── - # `player_auth.json` used to be a tracked file inside src/, so PyInstaller - # bundled it and, because the frozen app runs with cwd = _internal/, the - # player loaded it as its live auth state. A stale snapshot therefore made - # a fresh build boot "already authenticated" against an old server and play - # an outdated playlist. - # - # Fix: never read a relative auth path from the bundle. Any relative - # 'player_auth.json' is redirected to the data dir next to the .exe, which - # is the single source of truth for this install. - import player_auth as _player_auth_module - - _bundled_auth = os.path.join( - getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))), - 'player_auth.json', - ) - _local_auth = os.path.join(DATA_DIR, 'player_auth.json') - - # If an older build left the bundled snapshot next to the exe, drop it: - # it names another server and would be trusted on the next start-up. - try: - if os.path.isfile(_local_auth): - import json as _json - - with open(_local_auth, 'r') as _f: - _existing = _json.load(_f) - _existing_url = str(_existing.get('server_url') or '') - _wanted_ip = str(os.environ.get('KIWY_SERVER_IP') or '') - if _wanted_ip and _wanted_ip not in _existing_url: - Logger.warning( - "SignagePlayer: stored auth points at %s but the configured " - "server is %s - clearing it so the player re-authenticates", - _existing_url or '(none)', _wanted_ip, - ) - os.remove(_local_auth) - except Exception as _exc: - Logger.debug(f"SignagePlayer: auth pre-check skipped: {_exc}") - - _original_auth_init = _player_auth_module.PlayerAuth.__init__ - - def _windows_auth_init(self, config_file='player_auth.json', - use_https=True, verify_ssl=True): - """Force the auth file to live in the data dir next to the .exe.""" - try: - if not os.path.isabs(config_file): - config_file = os.path.join(DATA_DIR, os.path.basename(config_file)) - except Exception: - config_file = _local_auth - _original_auth_init(self, config_file, use_https=use_https, verify_ssl=verify_ssl) - - _player_auth_module.PlayerAuth.__init__ = _windows_auth_init - Logger.info(f"SignagePlayer: player auth file -> {_local_auth}") - - # `get_playlists_v2` caches a global auth instance created with the default - # relative path; make sure its cache is empty so the redirect above applies. - try: - import get_playlists_v2 as _gp - - if _gp._auth_instance is not None: - _gp._auth_instance = None - except Exception as _exc: - Logger.debug(f"SignagePlayer: could not reset auth cache: {_exc}") - - # `reset_player_auth` deleted the file next to main.py (i.e. inside the - # bundle, which is read-only and not what we load). Point it at the real - # auth file so the "Reset auth" button actually works. - def _windows_reset_player_auth(self): - try: - if os.path.exists(_local_auth): - os.remove(_local_auth) - Logger.info(f"SettingsPopup: Deleted authentication file: {_local_auth}") - self._show_temp_message( - '✓ Authentication reset - will reauthenticate on restart', (0, 1, 0, 1) - ) - except Exception as exc: - Logger.error(f"SettingsPopup: Failed to reset auth: {exc}") - - signage_main.SettingsPopup.reset_player_auth = _windows_reset_player_auth - - # ── Patch apply_kiosk_mode for Windows ────────────────────────── - # Wrap the base implementation (exit_on_escape + close guard + Ctrl+C) - # and add the low-level keyboard hook that swallows Alt+F4 / Alt+Tab / - # Win / Ctrl+Esc while production mode is active. - _base_apply = signage_main.SignagePlayer.apply_kiosk_mode - - def _windows_apply_kiosk_mode_patch(self, enabled): - """Windows kiosk lockdown = base logic + keyboard hook.""" - _base_apply(self, enabled) - if enabled: - _install_kb_lockdown() - Logger.info( - "SignagePlayer: Windows keyboard lockdown ACTIVE " - "(Alt+F4/Alt+Tab/Win/Ctrl+Esc swallowed)" - ) - else: - _uninstall_kb_lockdown() - Logger.info("SignagePlayer: Windows keyboard lockdown DISABLED") - - signage_main.SignagePlayer.apply_kiosk_mode = _windows_apply_kiosk_mode_patch - - # ── Patch CardReader for Windows ──────────────────────────────── - # The Linux CardReader uses evdev (/dev/input/event*), which does not - # exist on Windows. Replace the class reference so that - # SignagePlayer.show_edit_interface() uses the Windows implementation - # (Raw Input API + LL-hook fallback) instead. - signage_main.CardReader = WindowsCardReader - Logger.info( - "SignagePlayer: CardReader patched -> WindowsCardReader " - "(Raw Input API)" - ) - - # ── Shut the card reader pump down on app exit ───────────────── - _original_on_stop = signage_main.SignagePlayerApp.on_stop - - def _windows_on_stop(self): - try: - root = getattr(self, 'root', None) - if root is not None: - cr = getattr(root, 'card_reader', None) - if cr is not None and hasattr(cr, 'shutdown'): - cr.shutdown() - Logger.info("SignagePlayer: Windows card reader shut down") - except Exception as e: - Logger.debug(f"SignagePlayer: card reader shutdown error: {e}") - # Tear down the embedded web engine. Because it is a child HWND (not a - # subprocess) this is what stops it surviving the player on exit. - try: - _get_webview2_browser(force_new=True) - Logger.info("SignagePlayer: WebView2 browser shut down") - except Exception as e: - Logger.debug(f"SignagePlayer: WebView2 shutdown error: {e}") - _original_on_stop(self) - - signage_main.SignagePlayerApp.on_stop = _windows_on_stop - - # ── Force the Kivy window to cover the full physical monitor ── - # Kivy's fullscreen config can leave the SDL window at the DPI-virtualized - # size, showing a black strip + desktop bleed-through. Once the app starts - # we resize the window to the true monitor bounds and keep re-asserting it - # for the first few seconds (the SDL window may not exist immediately). - _original_on_start = signage_main.SignagePlayerApp.on_start - - def _windows_on_start(self): - _original_on_start(self) - try: - from kivy.clock import Clock - - # Continuous sizing guardian: every 2s, if the SDL window is not at - # the true monitor bounds (e.g. Chrome left it DPI-virtualized after - # a weblink), resize it back and re-sync the Kivy layout. This - # self-heals the "black strip + wrong size after a weblink" bug - # without a heavy constant SetWindowPos loop (the helper no-ops - # when the size already matches). - def _guard(dt): - try: - _reassert_kivy_fullscreen(self.root) - except Exception: - pass - - Clock.schedule_interval(_guard, 2.0) - except Exception as e: - _log(f"fullscreen guard start error: {e}") - - signage_main.SignagePlayerApp.on_start = _windows_on_start - - return signage_main - - -# ===================================================================== -# 4. Adjust path so we can import the src modules -# ===================================================================== -# When run from PyInstaller .exe: the runtime hook inserts paths. -# When run as plain python, we add src/ relative to this file. -_script_dir = Path(__file__).resolve().parent -_project_root = _script_dir.parent -_src_dir = _project_root / 'src' - -for p in [str(_src_dir), str(_project_root)]: - if p not in sys.path: - sys.path.insert(0, p) - -# ===================================================================== -# 5. Determine the local data directory (next to the executable) -# ===================================================================== -# The pyi_runtime_hook.py (when packaged) sets KIWY_DATA_DIR. -# When running in dev mode from python, use the project root. -# The executable will create its own local folders for playlist, -# media, config, and logs where the executable is launched. -DATA_DIR = os.environ.get('KIWY_DATA_DIR', str(_project_root)) - -# Create local data folders NEXT TO the executable -os.makedirs(os.path.join(DATA_DIR, 'config', 'resources'), exist_ok=True) -os.makedirs(os.path.join(DATA_DIR, 'media'), exist_ok=True) -os.makedirs(os.path.join(DATA_DIR, 'media', 'edited_media'), exist_ok=True) -os.makedirs(os.path.join(DATA_DIR, 'playlists'), exist_ok=True) -os.makedirs(os.path.join(DATA_DIR, 'logs'), exist_ok=True) -os.makedirs(os.path.join(DATA_DIR, 'config', 'certs'), exist_ok=True) - -# ===================================================================== -# 6. Set Kivy config BEFORE importing Kivy -# ===================================================================== -os.environ['KIVY_NO_FILELOG'] = '1' # Avoid file logging issues on Windows -os.environ['KIVY_HOME'] = os.path.join(DATA_DIR, '.kivy') - -from kivy.config import Config -Config.set('kivy', 'keyboard_mode', '') # Disable default virtual keyboard -Config.set('graphics', 'fullscreen', '0') -Config.set('graphics', 'window_state', 'maximized') -Config.set('graphics', 'multisampling', '0') -Config.set('graphics', 'fast_rgba', '1') -Config.set('kivy', 'log_level', 'warning') - -# ===================================================================== -# 7. Patch the main module, then run the app -# ===================================================================== -if __name__ == '__main__': - try: - # Write a startup marker so we know the .exe at least launched - try: - os.makedirs(os.path.join(DATA_DIR, 'logs'), exist_ok=True) - marker = os.path.join(DATA_DIR, 'logs', 'startup_marker.txt') - with open(marker, 'w') as f: - f.write(f"run_win.py started at {__import__('time').time()}\n") - except Exception: - pass - - # Show the persistent black backdrop BEFORE Kivy initializes so the - # host desktop is never visible during startup or browser transitions. - _Win32Backdrop.show() - - # Keep the display and system awake and disable the screensaver/lock - # screen from the very start (before Kivy even initializes), so the - # host never blanks, sleeps or locks while the player is up. - _disable_windows_screensaver() - - # Apply all Windows patches before launching - try: - patched_main = _patch_main() - except Exception as e: - # Catch early import errors (pre-Logger) to a file - import traceback - try: - err_log = os.path.join(DATA_DIR, 'logs', 'startup_error.log') - with open(err_log, 'w') as f: - f.write(f"Error in _patch_main(): {e}\n") - traceback.print_exc(file=f) - except Exception: - pass - raise # Re-raise so console shows it too - - from kivy.logger import Logger - Logger.info("=" * 80) - Logger.info("Kiwy Signage Player - Windows Edition") - Logger.info(f"Python: {sys.version}") - Logger.info(f"Platform: {platform.platform()}") - Logger.info(f"Data directory: {DATA_DIR}") - Logger.info("=" * 80) - - # ── Ensure the WebView2 Runtime is present ─────────────────── - # The bundled SDK is only an API surface; the Runtime holds the actual - # engine. Windows 11 and most Windows 10 machines already have it, but - # when it is missing we install it silently (per-user, so no UAC prompt - # appears on the signage display). Runs in the background so start-up - # is not blocked. - _ensure_webview2_runtime_async() - - # Patch base_dir in SignagePlayer instances to point to local data folder - _original_init = patched_main.SignagePlayer.__init__ - - def _patched_init(self, **kwargs): - """Override SignagePlayer.__init__ to use local data folders. - - Creates all necessary folders (config, media, playlists, logs) - in the same directory where the executable is launched. - """ - # Call parent Widget.__init__ - _original_init(self, **kwargs) - # Now override ALL paths to point to local data directory - # (where the .exe is located) - self.base_dir = DATA_DIR - self.config_dir = os.path.join(DATA_DIR, 'config') - self.media_dir = os.path.join(DATA_DIR, 'media') - self.playlists_dir = os.path.join(DATA_DIR, 'playlists') - self.config_file = os.path.join(self.config_dir, 'app_config.json') - self.resources_path = os.path.join(self.config_dir, 'resources') - self.heartbeat_file = os.path.join(DATA_DIR, '.player_heartbeat') - # Ensure all required folders exist locally - for directory in [ - self.config_dir, - self.resources_path, - os.path.join(self.media_dir, 'edited_media'), - self.playlists_dir, - os.path.join(DATA_DIR, 'logs'), - os.path.join(DATA_DIR, 'config', 'certs'), - ]: - os.makedirs(directory, exist_ok=True) - - patched_main.SignagePlayer.__init__ = _patched_init - - # Patch SSLManager cert directory to use local data folder - # ssl_utils is imported by player_auth.py and get_playlists_v2.py, not main.py - import ssl_utils - ssl_utils.SSLManager.CERT_DIR = os.path.join(DATA_DIR, 'config', 'certs') - ssl_utils.SSLManager.CERT_FILE = os.path.join( - ssl_utils.SSLManager.CERT_DIR, 'server_cert.pem' - ) - ssl_utils.SSLManager.CERT_INFO_FILE = os.path.join( - ssl_utils.SSLManager.CERT_DIR, 'cert_info.json' - ) - - # Run the app - try: - app = patched_main.SignagePlayerApp() - app.run() - except KeyboardInterrupt: - Logger.info("Application stopped by user (Ctrl+C)") - except SystemExit as _se: - Logger.critical(f"Kivy SystemExit (likely window provider missing): {_se}") - try: - crash_log = os.path.join(DATA_DIR, 'logs', 'crash.log') - with open(crash_log, 'w') as f: - f.write(f"Kivy SystemExit: {_se}\n") - f.write("This usually means Kivy could not find a window provider on this system.\n") - except Exception: - pass - _show_error_box( - "Kiwy Signage Player - Kivy Error", - f"Kivy exited: {_se}\n\n" - "This usually means Kivy could not create a window.\n" - "Check your GPU drivers and DirectX installation.\n\n" - "See logs/crash.log for details." - ) - sys.exit(1) - except Exception as e: - Logger.critical(f"Fatal error: {e}") - Logger.exception("Full traceback:") - # Also write to a crash log next to the executable - try: - import traceback - crash_log = os.path.join(DATA_DIR, 'logs', 'crash.log') - with open(crash_log, 'w') as f: - f.write(f"Fatal error: {e}\n") - traceback.print_exc(file=f) - except Exception: - pass - sys.exit(1) - finally: - Logger.info("Application shutdown complete") - _restore_windows_screensaver() # restore original screensaver state - _Win32Backdrop.hide() # remove backdrop on clean exit - except BaseException as _top_e: - # Catch any error BEFORE Logger is available (including SystemExit) - import traceback as _tb - _trace = _tb.format_exc() - try: - _crash_log = os.path.join(DATA_DIR, 'logs', 'fatal_crash.log') - with open(_crash_log, 'w') as _f: - _f.write(f"FATAL (pre-Logger): {_top_e}\n") - _f.write(_trace) - except Exception: - pass - _show_error_box( - "Kiwy Signage Player - Startup Error", - f"{_top_e}\n\nSee logs/fatal_crash.log for details." - ) - raise # Re-raise so .exe still shows the error diff --git a/windows/sign_exe.ps1 b/windows/sign_exe.ps1 deleted file mode 100644 index 831fe3b..0000000 --- a/windows/sign_exe.ps1 +++ /dev/null @@ -1,156 +0,0 @@ -<# -================================================================================ - Kiwy Signage Player - Code Signing Script -================================================================================ - Signs the built KiwySignagePlayer.exe with an Authenticode certificate. - - For PRODUCTION PCs that have Smart App Control (SAC) ENABLED: - - The cert MUST be issued by a reputable public CA (e.g. Sectigo, SSL.com, - DigiCert, GlobalSign). Self-signed certs will NOT satisfy SAC. - - You must use this script with -CertPath pointing at your .pfx/.p12. - - For DEV/TEST machines where you have admin rights: - - A self-signed cert trusted in the local Root store + Trusted Publisher - works (see create_self_signed_cert.ps1), but it does NOT satisfy SAC. - - Usage: - .\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -CertPassword "secret" - .\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" # prompt for pwd - .\sign_exe.ps1 -CertThumbprint "A1B2..." # from cert store - .\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -SkipTimestamp $false - - Optional: - -TimestampUrl RFC3161 timestamp server (default DigiCert). - Timestamping is REQUIRED for the signature to stay valid - after the cert expires and to satisfy Smart App Control. - -ExePath Path to the exe to sign (default dist\KiwySignagePlayer\KiwySignagePlayer.exe) - -Force Re-sign even if already signed -================================================================================ -#> -[CmdletBinding()] -param( - [string]$CertPath, - [string]$CertPassword, - [string]$CertThumbprint, - [string]$ExePath = (Join-Path $PSScriptRoot 'dist\KiwySignagePlayer\KiwySignagePlayer.exe'), - [string]$TimestampUrl = 'http://timestamp.digicert.com', - [switch]$SkipTimestamp, - [switch]$Force -) - -$ErrorActionPreference = 'Stop' -Set-Location $PSScriptRoot - -function Find-Signtool { - $candidates = @( - (Get-Command signtool.exe -ErrorAction SilentlyContinue).Source, - "$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe", - "$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe", - "$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.22000.0\x64\signtool.exe", - "$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.19041.0\x64\signtool.exe" - ) - foreach ($c in $candidates) { - if ($c -and (Test-Path $c)) { return $c } - } - # Fallback: newest SDK on disk - $sdkBin = "$env:ProgramFiles(x86)\Windows Kits\10\bin" - if (Test-Path $sdkBin) { - $found = Get-ChildItem $sdkBin -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | - Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName - if ($found) { return $found } - } - return $null -} - -$signtool = Find-Signtool -if ($signtool) { - Write-Host "[INFO ] signtool: $signtool" -ForegroundColor Green -} else { - Write-Host "[WARN ] signtool.exe not found - will use PowerShell Set-AuthenticodeSignature fallback." -ForegroundColor Yellow - Write-Host "[WARN ] NOTE: the fallback cannot apply an RFC3161 timestamp. For production (SAC)," - Write-Host "[WARN ] install the Windows SDK signtool: winget install Microsoft.WindowsSDK.10.0.26100" -} - -if (-not (Test-Path $ExePath)) { - Write-Host "[ERROR] Exe not found: $ExePath" -ForegroundColor Red - Write-Host "Run build_win.bat first, or pass -ExePath." - exit 1 -} - -# Already signed? -$sig = Get-AuthenticodeSignature -FilePath $ExePath -if ($sig.Status -eq 'Valid' -and -not $Force) { - Write-Host "[INFO ] Exe is already validly signed by: $($sig.SignerCertificate.Subject)" -ForegroundColor Green - exit 0 -} - -# ── Load the certificate ──────────────────────────────────────────── -$cert = $null -if ($CertThumbprint) { - $cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -Recurse -ErrorAction SilentlyContinue | - Where-Object { $_.Thumbprint -eq $CertThumbprint } | Select-Object -First 1 - if (-not $cert) { - Write-Host "[ERROR] No certificate with thumbprint $CertThumbprint in My store." -ForegroundColor Red - exit 1 - } -} elseif ($CertPath) { - if (-not (Test-Path $CertPath)) { - Write-Host "[ERROR] Cert file not found: $CertPath" -ForegroundColor Red - exit 1 - } - if (-not $CertPassword) { - $secure = Read-Host "Certificate password for $CertPath" -AsSecureString - $CertPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto( - [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)) - } - $securePwd = ConvertTo-SecureString -String $CertPassword -Force -AsPlainText - $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertPath, $securePwd) -} else { - Write-Host "[ERROR] Provide -CertPath or -CertThumbprint." -ForegroundColor Red - exit 1 -} - -# ── Sign (signtool preferred, PowerShell fallback) ────────────────── -if ($signtool) { - $args = @() - if ($CertThumbprint) { - $args = @('sign', '/sha1', $CertThumbprint, '/fd', 'SHA256') - } else { - $args = @('sign', '/f', $CertPath, '/p', $CertPassword, '/fd', 'SHA256') - } - if (-not $SkipTimestamp) { - $args += @('/tr', $TimestampUrl, '/td', 'SHA256') - } - $args += @('"' + $ExePath + '"') - $cmd = "& `"$signtool`" " + ($args -join ' ') - Write-Host "[INFO ] Signing with signtool..." -ForegroundColor Cyan - Write-Host "[CMD ] $cmd" - Invoke-Expression $cmd - if ($LASTEXITCODE -ne 0) { - Write-Host "[ERROR] signtool failed with exit code $LASTEXITCODE" -ForegroundColor Red - exit $LASTEXITCODE - } -} else { - Write-Host "[INFO ] Signing with PowerShell Set-AuthenticodeSignature (no timestamp)..." -ForegroundColor Cyan - if (-not $cert.HasPrivateKey) { - Write-Host "[ERROR] Certificate has no private key - cannot sign." -ForegroundColor Red - exit 1 - } - $sig = Set-AuthenticodeSignature -FilePath $ExePath -Certificate $cert -HashAlgorithm SHA256 - if ($sig.Status -notin @('Valid','UnknownError')) { - Write-Host "[ERROR] Signing failed: $($sig.StatusMessage)" -ForegroundColor Red - exit 1 - } -} - -# Verify -$sig = Get-AuthenticodeSignature -FilePath $ExePath -Write-Host "" -Write-Host "[INFO ] Signature status: $($sig.Status)" -ForegroundColor Green -Write-Host "[INFO ] Signer: $($sig.SignerCertificate.Subject)" -ForegroundColor Green -if ($sig.Status -eq 'Valid') { - Write-Host "[OK ] KiwySignagePlayer.exe is now digitally signed." -ForegroundColor Green -} else { - Write-Host "[WARN ] Signature status is '$($sig.Status)' - inspect above." -ForegroundColor Yellow - Write-Host "[WARN ] If no timestamp was applied, SAC may still block after cert expiry." -ForegroundColor Yellow -} diff --git a/windows/start_player_watchdog.bat b/windows/start_player_watchdog.bat deleted file mode 100644 index 63b7f64..0000000 --- a/windows/start_player_watchdog.bat +++ /dev/null @@ -1,48 +0,0 @@ -@echo off -REM ============================================================ -REM Kiwy Signage Player - Watchdog Launcher (Windows) -REM ============================================================ -REM Starts watchdog.ps1, which keeps the player running 24/7: -REM * restarts it if it crashes -REM * restarts it if it hangs (stale heartbeat) -REM * backs off if it cannot stay up (crash-loop breaker) -REM -REM The WATCHDOG runs in this window. Closing it stops supervision -REM (it does NOT stop the player). -REM -REM The ONLY supported way to stop the player is the exit screen -REM password. That writes .player_stop_requested next to the .exe, -REM which tells the watchdog not to restart it. Launching again -REM starts a fresh session and clears that flag. -REM -REM Usage: -REM start_player_watchdog.bat start supervising -REM start_player_watchdog.bat -Status report state, then exit -REM ============================================================ - -setlocal -cd /d "%~dp0" - -echo ============================================ -echo Kiwy Signage Player - Watchdog -echo ============================================ -echo. -echo Stop the WATCHDOG with Ctrl+C. -echo Stop the PLAYER via the exit password. -echo. - -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0watchdog.ps1" %* - -set "RC=%ERRORLEVEL%" - -if not "%RC%"=="0" ( - echo. - echo [ERROR] Watchdog exited with code %RC%. - echo Check logs\watchdog.log next to the player executable. - echo. -) - -REM Keep the window open so a crash is readable when double-clicked. -if "%~1"=="" pause -endlocal -exit /b %RC% diff --git a/windows/test_first_run_setup.py b/windows/test_first_run_setup.py deleted file mode 100644 index 6c7bb1c..0000000 --- a/windows/test_first_run_setup.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Tests the first-run setup decision logic in src/main.py. - -The rule that matters: a player is "configured" only when it has REAL server -settings. A missing file, an empty file, unparseable JSON, missing keys, and -leftover placeholder values must ALL count as unconfigured, so the app shows -the setup notice instead of silently trying to reach "localhost". - -Run: windows\\venv\\Scripts\\python.exe windows\\test_first_run_setup.py -Exit code 0 = PASS. -""" - -import sys -from pathlib import Path - -SRC = Path(__file__).resolve().parent.parent / 'src' -sys.path.insert(0, str(SRC)) - -import main as app # noqa: E402 - - -def main(): - print('=' * 68) - print(' First-run setup detection test') - print('=' * 68) - - must_be_unconfigured = { - 'None': None, - 'empty dict': {}, - 'empty config file': {}, - 'placeholder defaults': { - 'server_ip': 'localhost', - 'screen_name': 'kivy-player', - 'quickconnect_key': '1234567', - }, - 'all blank': { - 'server_ip': '', 'screen_name': '', 'quickconnect_key': '', - }, - 'missing keys': {'server_ip': '192.168.0.110'}, - 'whitespace only': { - 'server_ip': ' ', 'screen_name': '\t', 'quickconnect_key': ' ', - }, - 'loopback': { - 'server_ip': '127.0.0.1', 'screen_name': 'PC', 'quickconnect_key': '9', - }, - 'placeholder screen name': { - 'server_ip': '192.168.0.110', - 'screen_name': 'kivy-player', - 'quickconnect_key': '8887779', - }, - } - - must_be_configured = { - 'real settings': { - 'server_ip': '192.168.0.110', - 'screen_name': 'DESKTOP-NJLBQKH', - 'quickconnect_key': '8887779', - }, - 'hostname as ip': { - 'server_ip': 'digi-signage.local', - 'screen_name': 'Player1', - 'quickconnect_key': '0123456', - }, - 'extra keys ignored': { - 'server_ip': '10.0.0.5', 'screen_name': 'Sign1', - 'quickconnect_key': '424242', 'weblink': {'engine': 'auto'}, - }, - } - - ok = True - - for label, value in must_be_unconfigured.items(): - got = app.config_is_configured(value) - flag = 'ok ' if got is False else 'FAIL' - if got is not False: - ok = False - print(f' [{flag}] unconfigured: {label:24} -> {got}') - - print() - for label, value in must_be_configured.items(): - got = app.config_is_configured(value) - flag = 'ok ' if got is True else 'FAIL' - if got is not True: - ok = False - print(f' [{flag}] configured: {label:24} -> {got}') - - # The defaults the app starts from must themselves be "unconfigured", - # otherwise a fresh install would look ready to sync. - print() - default_ok = app.config_is_configured(app.DEFAULT_CONFIG) is False - print(f' [{"ok " if default_ok else "FAIL"}] DEFAULT_CONFIG is unconfigured ' - f'-> {app.config_is_configured(app.DEFAULT_CONFIG)}') - if not default_ok: - ok = False - - # Required keys must actually be the ones enforced. - print(f'\n required keys: {app.CONFIG_REQUIRED_KEYS}') - print(f' notice delay : {app.SETUP_NOTICE_SECONDS}s before Settings opens') - - # The notice must wait a few seconds (the user asked for 5). - if app.SETUP_NOTICE_SECONDS != 5: - print(f' FAIL: expected a 5s notice, got {app.SETUP_NOTICE_SECONDS}') - ok = False - - print('=' * 68) - print(' RESULT:', 'PASS' if ok else 'FAIL') - print('=' * 68) - return 0 if ok else 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/windows/test_import_fix.py b/windows/test_import_fix.py deleted file mode 100644 index f4d0277..0000000 --- a/windows/test_import_fix.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Test that setting env vars before importing main.py fixes the crash.""" -import os -import sys - -# This is the KEY fix: set Windows env vars BEFORE main.py is imported -os.environ['SDL_VIDEODRIVER'] = 'windows' -os.environ['SDL_AUDIODRIVER'] = 'directsound' -os.environ['KIVY_WINDOW'] = 'sdl2' -# Use 'angle_sdl2' on Windows for better DirectX compatibility -os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2' -# Let Kivy auto-detect input providers on Windows -os.environ['KIVY_INPUTPROVIDERS'] = '' -os.environ['KIVY_VIDEO'] = 'ffpyplayer' -os.environ['KIVY_AUDIO'] = 'ffpyplayer' -os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8' -os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0' - -# Add src to path -sys.path.insert(0, r'C:\Users\Dell-PC\Desktop\Kiwy-Signage\src') - -print("=" * 60) -print("Testing main.py import with Windows env vars...") -print("=" * 60) - -try: - import main - print("SUCCESS: main.py imported without crashing!") - print(f" SDL_VIDEODRIVER = {os.environ.get('SDL_VIDEODRIVER')}") - print(f" KIVY_WINDOW = {os.environ.get('KIVY_WINDOW')}") - print(f" KIVY_GL_BACKEND = {os.environ.get('KIVY_GL_BACKEND')}") - print(f" KIVY_INPUTPROVIDERS = {os.environ.get('KIVY_INPUTPROVIDERS')}") -except SystemExit as e: - print(f"FAILED: SystemExit({e}) - Kivy window provider still not loading") - sys.exit(1) -except Exception as e: - print(f"FAILED with exception: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/windows/test_video_hang.py b/windows/test_video_hang.py deleted file mode 100644 index b580cf3..0000000 --- a/windows/test_video_hang.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Proves src/video_safety.py stops the hang at the end of a video. - -Reproduces the exact failure: - Kivy's VideoFFPy.unload() does `self._thread.join()` with NO timeout. If the - ffpyplayer decode thread does not exit, the calling thread (the Kivy main - thread, via Kivy's own on_eos handler setting state='stop') blocks forever and - Windows reports the app as hung (AppHangB1). - -Two checks: - 1. The guard installs on the REAL Kivy provider (VideoFFPy.play wrapped). - 2. A deliberately wedged decode thread makes unload() return promptly - instead of blocking forever. - -Run: windows\\venv\\Scripts\\python.exe windows\\test_video_hang.py -Exit code 0 = PASS (the hang is prevented). -""" - -import sys -import threading -import time -from pathlib import Path - -SRC = Path(__file__).resolve().parent.parent / 'src' -sys.path.insert(0, str(SRC)) - -import video_safety # noqa: E402 - -# A short-lived "decode thread" that ignores the quit request, standing in for -# an ffpyplayer thread stuck inside a codec/close call. -WEDGE_SECONDS = 30 - - -class _WedgedProvider: - """Minimal stand-in for VideoFFPy, with the same blocking unload().""" - - def __init__(self): - self._thread = threading.Thread(target=self._decode_loop, daemon=True) - self._thread.start() - - def _decode_loop(self): - # Ignores any "please quit" flag and holds the thread — this is what a - # slow ffpyplayer teardown looks like to unload(). - time.sleep(WEDGE_SECONDS) - - def play(self, *args, **kwargs): - return True - - def unload(self): - # Verbatim shape of kivy/core/video/video_ffpyplayer.py unload(): - # if self._thread: - # self._thread.join() # <-- no timeout: hangs forever - if self._thread: - self._thread.join() - self._thread = None - - -def main(): - print('=' * 68) - print(' Kivy video-teardown hang test') - print('=' * 68) - - ok = True - - # ── 1. Does the guard install on the real provider? ────────────── - print('\n[1] guard installation') - try: - from kivy.core.video import video_ffpyplayer as vfp - - provider = vfp.VideoFFPy - print(f' provider: {provider.__module__}.{provider.__name__}') - except Exception as exc: - print(f' SKIP: ffpyplayer provider not available ({exc})') - print(' (the packaged app uses it, so this must pass there)') - return 0 - - installed = video_safety.suppress_kivy_video_blocking_unload(timeout=2.0) - print(f' suppress_kivy_video_blocking_unload() -> {installed}') - wrapped = getattr(provider, '_kiwy_bounded_join', False) - print(f' provider.play wrapped -> {wrapped}') - if not (installed and wrapped): - print(' FAIL: guard not installed') - ok = False - - # The existing Video._do_video_load path must be untouched (no source change). - try: - from kivy.uix.video import Video - - print(f' kivy.uix.video.Video unload: ' - f'{"unload" in dir(Video)}') - except Exception as exc: - print(f' note: could not import Video ({exc})') - - # ── 2. Does a wedged thread still block? ───────────────────────── - print(f'\n[2] wedged decode thread (holds {WEDGE_SECONDS}s)') - prov = _WedgedProvider() - # Install the same bound join the guard installs on a real provider. - video_safety._bound_thread_join(prov, 2.0) - patched = getattr(prov._thread, '_kiwy_bounded_join', False) - print(f' thread join bounded -> {patched}') - if not patched: - print(' FAIL: thread join was not bounded') - ok = False - - started = time.monotonic() - prov.unload() # would hang forever without the fix - elapsed = time.monotonic() - started - print(f' unload() returned after {elapsed:.2f}s') - if elapsed > 5.0: - print(f' FAIL: unload() blocked for {elapsed:.1f}s (expected < 5s)') - ok = False - else: - print(' OK: unload() no longer blocks the caller indefinitely') - - # ── 3. Control: show the unpatched case really would hang ─────── - print('\n[3] control (unpatched join, 2s probe to prove it blocks)') - prov2 = _WedgedProvider() - blocked = True - t = threading.Thread(target=prov2.unload, daemon=True) - t.start() - t.join(timeout=2.0) - blocked = t.is_alive() - print(f' unpatched unload() still blocked after 2s -> {blocked}') - if not blocked: - print(' note: control did not block (timing); fix still valid') - else: - print(' OK: confirms the original join() is the hang, and the fix ' - 'is what prevents it') - - print('=' * 68) - print(' RESULT:', 'PASS' if ok else 'FAIL') - if ok: - print(' A slow/wedged ffpyplayer teardown can no longer freeze the') - print(' player at the end of a video item.') - print('=' * 68) - return 0 if ok else 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/windows/test_watchdog.py b/windows/test_watchdog.py deleted file mode 100644 index 2534e01..0000000 --- a/windows/test_watchdog.py +++ /dev/null @@ -1,438 +0,0 @@ -"""Verifies windows/watchdog.ps1 actually recovers the player. - -Three scenarios, each with a real player process: - - 1. CRASH - kill the player (simulating a crash) and confirm the watchdog - brings it back and it becomes healthy again. - 2. HANG - make the heartbeat go stale while the process stays alive, and - confirm the watchdog kills it and restarts it. - 3. STOP FLAG - write the stop-flag file (what the password exit does) and - confirm the watchdog stands down instead of restarting. - Then confirm a fresh watchdog run CLEARS the flag (new session). - -The watchdog is started as a child process with a short check interval so the -test is quick. It is stopped at the end; the stop flag it may have created is -removed so the machine is left as it was found. - -Run: windows\\venv\\Scripts\\python.exe windows\\test_watchdog.py -Exit code 0 = PASS. -""" - -import subprocess -import sys -import time -from pathlib import Path - -WIN = Path(__file__).resolve().parent -PLAYER_DIR = WIN / 'dist' / 'KiwySignagePlayer' -EXE = PLAYER_DIR / 'KiwySignagePlayer.exe' -HEARTBEAT = PLAYER_DIR / '.player_heartbeat' -STOP_FLAG = PLAYER_DIR / '.player_stop_requested' -WATCHDOG = WIN / 'watchdog.ps1' -LOG = PLAYER_DIR / 'logs' / 'watchdog.log' - -PS = ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File'] - -# Fast timings so the test finishes in a reasonable time. -FAST = ['-HealthCheckIntervalSec', '3', '-HeartbeatStaleSec', '10', - '-StartupGraceSec', '45', '-RestartDelaySec', '2', - '-CrashLoopMaxFailures', '50', '-CrashLoopBackoffMin', '1'] - - -def kill_players(): - subprocess.run(['taskkill', '/F', '/T', '/IM', 'KiwySignagePlayer.exe'], - capture_output=True, text=True) - - -def player_count(): - out = subprocess.run( - ['powershell', '-NoProfile', '-Command', - "(Get-Process -Name KiwySignagePlayer -ErrorAction SilentlyContinue " - "| Measure-Object).Count"], - capture_output=True, text=True) - try: - return int((out.stdout or '0').strip() or 0) - except ValueError: - return 0 - - -def hb_age(): - if not HEARTBEAT.is_file(): - return -1 - return time.time() - HEARTBEAT.stat().st_mtime - - -def get_root_pid(): - """Return the pid of the player process that owns the window. - - The packaged player is TWO processes (PyInstaller bootloader + child). - """ - out = subprocess.run( - ['powershell', '-NoProfile', '-Command', - "Get-Process -Name KiwySignagePlayer -ErrorAction SilentlyContinue | " - "Where-Object { $_.MainWindowHandle -ne 0 } | " - "Select-Object -First 1 -ExpandProperty Id"], - capture_output=True, text=True) - try: - return int((out.stdout or '').strip()) - except ValueError: - # Fall back to any player process. - out = subprocess.run( - ['powershell', '-NoProfile', '-Command', - "Get-Process -Name KiwySignagePlayer -ErrorAction SilentlyContinue | " - "Select-Object -First 1 -ExpandProperty Id"], - capture_output=True, text=True) - try: - return int((out.stdout or '').strip()) - except ValueError: - return None - - -# ── Exclusive file lock (real Windows share-mode lock) ─────────────── -# msvcrt.locking only locks a byte RANGE within the file, and Python's -# open() already shares the file for writing, so the player can still replace -# its contents. Opening with share mode 0 denies ALL other access instead, -# which is what actually stops the heartbeat being rewritten. -import ctypes # noqa: E402 -import ctypes.wintypes as wintypes # noqa: E402 - -GENERIC_READ = 0x80000000 -GENERIC_WRITE = 0x40000000 -OPEN_EXISTING = 3 -FILE_ATTRIBUTE_NORMAL = 0x80 -INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value - -_kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) -_kernel32.CreateFileW.restype = ctypes.c_void_p -_kernel32.CreateFileW.argtypes = [ - ctypes.c_wchar_p, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, - wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, -] -_kernel32.CloseHandle.argtypes = [ctypes.c_void_p] - - -def lock_file_exclusive(path): - """Open ``path`` denying all sharing. Returns the handle, or None.""" - handle = _kernel32.CreateFileW( - str(path), - GENERIC_READ | GENERIC_WRITE, - 0, # share mode 0: nobody else may touch it - None, - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - None, - ) - if handle in (None, INVALID_HANDLE_VALUE): - err = ctypes.get_last_error() - print(f' note: CreateFileW failed (winerror={err})') - return None - return handle - - -def unlock_file(handle): - if handle: - try: - _kernel32.CloseHandle(handle) - except Exception: - pass - - -def tail_log(n=14): - if not LOG.is_file(): - return [] - lines = LOG.read_text(encoding='utf-8', errors='replace').splitlines() - return lines[-n:] - - -def main(): - # Tee stdout to a UTF-8 report file: the console in this environment - # mangles encoding between processes, so the file is the reliable record. - report_path = WIN / 'watchdog_test_report.txt' - - class _Tee: - def __init__(self, stream, path): - self._stream = stream - self._path = path - - def write(self, text): - self._stream.write(text) - try: - with open(self._path, 'a', encoding='utf-8') as fh: - fh.write(text) - except Exception: - pass - - def flush(self): - try: - self._stream.flush() - except Exception: - pass - - try: - report_path.unlink() - except Exception: - pass - sys.stdout = _Tee(sys.__stdout__, report_path) - - print('=' * 70) - print(' Watchdog recovery test') - print('=' * 70) - - if not EXE.is_file(): - print(f'FAIL: player exe not found at {EXE}') - return 1 - if not WATCHDOG.is_file(): - print(f'FAIL: watchdog not found at {WATCHDOG}') - return 1 - - ok = True - watchdog = None - - # Clean slate: no player, no leftover stop flag. - kill_players() - if STOP_FLAG.exists(): - STOP_FLAG.unlink() - time.sleep(2) - - try: - # --------------------------------------------------------------- - # Start the watchdog; it should launch the player itself. - # --------------------------------------------------------------- - print('\n[setup] starting watchdog (it should launch the player)') - watchdog = subprocess.Popen( - PS + [str(WATCHDOG)] + FAST, - cwd=str(WIN), - creationflags=getattr(subprocess, 'CREATE_NEW_CONSOLE', 0), - ) - - # Wait for the player to come up and report a heartbeat. - deadline = time.time() + 90 - healthy = False - while time.time() < deadline: - time.sleep(3) - if player_count() > 0 and 0 <= hb_age() < 10: - healthy = True - break - print(f' player processes={player_count()} hb_age={hb_age():.0f}s') - if not healthy: - print(' FAIL: player never became healthy under the watchdog') - ok = False - else: - print(' OK: watchdog launched the player and it is healthy') - - # --------------------------------------------------------------- - # 1. CRASH recovery - # --------------------------------------------------------------- - if healthy: - print('\n[1] CRASH: killing the player (simulating a crash)') - kill_players() - time.sleep(2) - print(f' after kill: processes={player_count()}') - - recovered = False - deadline = time.time() + 150 - while time.time() < deadline: - time.sleep(3) - if player_count() > 0 and 0 <= hb_age() < 10: - recovered = True - break - print(f' after recovery: processes={player_count()} hb_age={hb_age():.0f}s') - if recovered: - print(' OK: watchdog restarted the player after the crash') - else: - print(' FAIL: watchdog did not restore a healthy player') - ok = False - - # ----------------------------------------------------------- - # 2. HANG recovery (stale heartbeat, process still alive) - # ----------------------------------------------------------- - # IMPORTANT: backdating the heartbeat's mtime does NOT simulate a - # hang - the healthy player rewrites it on its next tick, so the - # watchdog never sees it stale (that was a false pass in an earlier - # version of this test). - # - # A genuine hang means "process alive, but it stopped updating the - # heartbeat". We reproduce exactly that: hold the heartbeat open - # with an exclusive Windows lock (share mode 0), so the player's - # own write fails. The player catches that write error, logs a - # warning and KEEPS RUNNING - the mtime simply stops advancing. - # That is precisely the condition the watchdog tests for. - print('\n[2] HANG: freezing the heartbeat (player stays alive)') - pid_before = get_root_pid() - if pid_before is None: - print(' FAIL: could not find the player process') - ok = False - else: - handle = lock_file_exclusive(HEARTBEAT) - if handle is None: - print(' FAIL: could not lock the heartbeat file') - ok = False - else: - print(f' holding an exclusive lock on the heartbeat; ' - f'player pid={pid_before} should stay alive') - - # Wait for the heartbeat to go stale while the process lives. - stale_seen = False - deadline = time.time() + 90 - while time.time() < deadline: - time.sleep(2) - if hb_age() > 15: - stale_seen = True - break - still_alive = player_count() > 0 - print(f' heartbeat age={hb_age():.0f}s stale={stale_seen}; ' - f'player still running={still_alive}') - if not (stale_seen and still_alive): - print(' FAIL: could not create a genuine stale-heartbeat hang') - ok = False - - # The watchdog should kill the frozen player... - old_killed = False - deadline = time.time() + 90 - while time.time() < deadline: - time.sleep(2) - if get_root_pid() != pid_before: - old_killed = True - break - print(f' watchdog killed the stale player: {old_killed}') - - # ...release the lock so the replacement can write... - unlock_file(handle) - - # ...and confirm a fresh, healthy player is now running. - restarted = False - deadline = time.time() + 150 - while time.time() < deadline: - time.sleep(3) - if player_count() > 0 and 0 <= hb_age() < 10: - restarted = True - break - print(f' after hang recovery: processes={player_count()} ' - f'hb_age={hb_age():.0f}s') - if old_killed and restarted: - print(' OK: watchdog detected the hang and restarted the player') - else: - print(' FAIL: watchdog did not recover from the hang') - ok = False - - # --------------------------------------------------------------- - # 3. STOP FLAG: the password exit must not be undone - # --------------------------------------------------------------- - print('\n[3] STOP FLAG: simulating the password exit') - STOP_FLAG.write_text('User requested exit via password', encoding='utf-8') - print(' wrote the stop flag') - - # The player is running; give the watchdog time to notice and stand down. - stood_down = False - deadline = time.time() + 90 - while time.time() < deadline: - time.sleep(3) - if watchdog.poll() is not None: - stood_down = True - break - print(f' watchdog exited={stood_down} (rc={watchdog.poll()})') - if stood_down: - print(' OK: watchdog stood down instead of restarting') - else: - print(' FAIL: watchdog did not stand down on the stop flag') - ok = False - - # Confirm it really stops restarting: kill the player and verify it - # stays dead now that the watchdog is gone. - kill_players() - time.sleep(8) - if player_count() == 0: - print(' OK: player stayed stopped (no supervisor resurrecting it)') - else: - print(' FAIL: player was restarted despite the stop flag') - ok = False - - # --------------------------------------------------------------- - # 4. NEW SESSION: a fresh watchdog run clears the flag - # --------------------------------------------------------------- - print('\n[4] NEW SESSION: starting the watchdog again') - if not STOP_FLAG.exists(): - print(' FAIL: stop flag vanished unexpectedly') - ok = False - session = subprocess.Popen( - PS + [str(WATCHDOG), '-Status'], - cwd=str(WIN), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True) - # -Status must NOT clear the flag (it is read-only). - try: - session.wait(timeout=60) - except subprocess.TimeoutExpired: - session.kill() - if STOP_FLAG.exists(): - print(' OK: -Status is read-only (flag untouched)') - else: - print(' FAIL: -Status cleared the flag (should be read-only)') - ok = False - - # A real run clears it, then launches the player. - fresh = subprocess.Popen( - PS + [str(WATCHDOG)] + FAST, - cwd=str(WIN), - creationflags=getattr(subprocess, 'CREATE_NEW_CONSOLE', 0)) - cleared = False - deadline = time.time() + 60 - while time.time() < deadline: - time.sleep(2) - if not STOP_FLAG.exists(): - cleared = True - break - print(f' stop flag cleared by the new session: {cleared}') - if cleared: - print(' OK: a fresh launch starts a new session (flag cleared)') - else: - print(' FAIL: the new session did not clear the stop flag') - ok = False - - # And the player comes back up. - back_up = False - deadline = time.time() + 120 - while time.time() < deadline: - time.sleep(3) - if player_count() > 0 and 0 <= hb_age() < 10: - back_up = True - break - print(f' player back up: {back_up} (processes={player_count()})') - if back_up: - print(' OK: player resumed supervision after the new session') - else: - print(' FAIL: player did not come back in the new session') - ok = False - - fresh.terminate() - - finally: - # --------------------------------------------------------------- - # Leave the machine as we found it. - # --------------------------------------------------------------- - for p in (watchdog,): - if p is not None and p.poll() is None: - p.terminate() - subprocess.run( - ['powershell', '-NoProfile', '-Command', - "Get-CimInstance Win32_Process -Filter \"Name='powershell.exe'\" | " - "Where-Object { $_.CommandLine -like '*watchdog.ps1*' } | " - "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], - capture_output=True, text=True) - kill_players() - if STOP_FLAG.exists(): - STOP_FLAG.unlink() - print('\n[cleanup] player stopped, watchdog stopped, stop flag removed') - - print('\n=== last watchdog log lines ===') - for line in tail_log(12): - print(' ' + line) - - print('=' * 70) - print(' RESULT:', 'PASS' if ok else 'FAIL') - print('=' * 70) - return 0 if ok else 1 - - -if __name__ == '__main__': - sys.exit(main()) - diff --git a/windows/test_webview2_embed.py b/windows/test_webview2_embed.py deleted file mode 100644 index b06bea8..0000000 --- a/windows/test_webview2_embed.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Standalone harness for windows/webview2_browser.py — no Kivy, no player. - -Creates a plain Win32 window, embeds WebView2 in it via the same -WebView2Browser class the player uses, navigates to a page, checks that the -page actually becomes visible, then resizes and tears down. - -Run: windows\\venv\\Scripts\\python.exe windows\\test_webview2_embed.py -Exit code 0 = embedded engine works. -""" - -import ctypes -import os -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -URL = os.environ.get('KIWY_TEST_URL', 'https://example.com/') - -user32 = ctypes.windll.user32 -kernel32 = ctypes.windll.kernel32 - -WNDPROC = ctypes.WINFUNCTYPE( - ctypes.c_int64, - ctypes.c_void_p, # HWND - ctypes.c_uint, # UINT msg - ctypes.c_void_p, # WPARAM - ctypes.c_void_p, # LPARAM -) - -_messages = [] - - -@WNDPROC -def _wnd_proc(hwnd, msg, wparam, lparam): - _messages.append(msg) - if msg == 0x0002: # WM_DESTROY - user32.PostQuitMessage(0) - return 0 - user32.DefWindowProcW.restype = ctypes.c_int64 - user32.DefWindowProcW.argtypes = [ - ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p, - ] - return user32.DefWindowProcW(hwnd, msg, wparam, lparam) - - -def _make_window(width=1280, height=720): - """Register a class and create a visible top-level window.""" - hinstance = kernel32.GetModuleHandleW(None) - class_name = 'KiwyWebView2Test' - - class WNDCLASSEX(ctypes.Structure): - _fields_ = [ - ('cbSize', ctypes.c_uint), - ('style', ctypes.c_uint), - ('lpfnWndProc', WNDPROC), - ('cbClsExtra', ctypes.c_int), - ('cbWndExtra', ctypes.c_int), - ('hInstance', ctypes.c_void_p), - ('hIcon', ctypes.c_void_p), - ('hCursor', ctypes.c_void_p), - ('hbrBackground', ctypes.c_void_p), - ('lpszMenuName', ctypes.c_wchar_p), - ('lpszClassName', ctypes.c_wchar_p), - ('hIconSm', ctypes.c_void_p), - ] - - wc = WNDCLASSEX() - wc.cbSize = ctypes.sizeof(WNDCLASSEX) - wc.style = 0x0002 | 0x0001 # CS_HREDRAW | CS_VREDRAW - wc.lpfnWndProc = _wnd_proc - wc.hInstance = hinstance - wc.hbrBackground = ctypes.c_void_p(6) # COLOR_WINDOW+1 - wc.lpszClassName = class_name - user32.RegisterClassExW(ctypes.byref(wc)) - - hwnd = user32.CreateWindowExW( - 0, - class_name, - 'Kiwy WebView2 Embed Test', - 0x00CF0000 | 0x10000000, # WS_OVERLAPPEDWINDOW | WS_VISIBLE - 100, 100, width, height, - 0, 0, hinstance, 0, - ) - if not hwnd: - raise RuntimeError(f'CreateWindowExW failed (err={kernel32.GetLastError()})') - user32.UpdateWindow(hwnd) - return hwnd - - -def _pump(seconds): - """Pump Win32 messages — WebView2 needs this to deliver its callbacks.""" - deadline = time.monotonic() + seconds - while time.monotonic() < deadline: - msg = ctypes.wintypes.MSG() if hasattr(ctypes, 'wintypes') else None - import ctypes.wintypes as wt - - msg = wt.MSG() - while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 1): - user32.TranslateMessage(ctypes.byref(msg)) - user32.DispatchMessageW(ctypes.byref(msg)) - time.sleep(0.02) - - -def main(): - print('=' * 68) - print(' WebView2 embedded-engine test') - print('=' * 68) - - from webview2_browser import WebView2Browser - - print(f'SDK dir : {WebView2Browser.__module__}') - available = WebView2Browser.is_available() - print(f'available: {available}') - if not available: - print(f'REASON: {WebView2Browser._import_error}') - return 1 - - hwnd = _make_window() - print(f'window : hwnd=0x{hwnd:x}') - - browser = WebView2Browser(hwnd_provider=lambda: hwnd) - started = time.monotonic() - ok = browser.show(URL) - print(f'show() : {ok}') - if not ok: - print(f'FAILED : {browser.failed_reason}') - return 1 - - # Drive the Kivy-style Clock poll manually while pumping messages. - visible = False - while time.monotonic() - started < 25: - browser._tick(0) # consume the async task - _pump(0.1) - if browser.failed_reason: - print(f'FAILED : {browser.failed_reason}') - return 1 - if browser.is_showing(): - visible = True - break - print(f'visible : {visible} after {time.monotonic() - started:.1f}s') - if not visible: - print('FAILED : page never became visible') - return 1 - - # Resize to the signage resolution and confirm it is applied. - browser.resize(1920, 1080) - _pump(1.0) - print(f'resized : 1920x1080 (bounds={browser._size})') - - # Hide, then re-show to prove the controller survives a transition. - browser.hide() - _pump(0.5) - print(f'after hide -> is_showing={browser.is_showing()}') - browser.show(URL) - _pump(2.0) - print(f'after re-show -> is_showing={browser.is_showing()}') - - browser.shutdown() - print('shutdown: ok') - print('=' * 68) - print(' RESULT: PASS') - print('=' * 68) - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/windows/test_webview2_navigation.py b/windows/test_webview2_navigation.py deleted file mode 100644 index bac7290..0000000 --- a/windows/test_webview2_navigation.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Does pythonnet fire NavigationCompleted for a real page load? - -This validates the mechanism the player relies on to tell "page loaded" apart -from "page failed" (e.g. unreachable host on a closed network). If the event -does not fire, the player cannot detect a failed weblink and would show -Chromium's error page for the full slot. - -Checks three things: - 1. the delegate can be constructed and subscribed, - 2. it fires for a GOOD page -> IsSuccess True, - 3. it fires for a BAD page -> IsSuccess False. - -Run: windows\\venv\\Scripts\\python.exe windows\\test_webview2_navigation.py -Exit code 0 = PASS. -""" - -import ctypes -import http.server -import socketserver -import sys -import threading -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -PORT = 18766 -PAGE = '

KIWY-NAV-OK

' - - -class _Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = PAGE.encode() - self.send_response(200) - self.send_header('Content-Type', 'text/html') - self.send_header('Content-Length', str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *args): - pass - - -def _start_server(): - socketserver.TCPServer.allow_reuse_address = True - httpd = socketserver.TCPServer(('127.0.0.1', PORT), _Handler) - threading.Thread(target=httpd.serve_forever, daemon=True).start() - return httpd - - -user32 = ctypes.windll.user32 -kernel32 = ctypes.windll.kernel32 -WNDPROC = ctypes.WINFUNCTYPE( - ctypes.c_int64, ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p -) - - -@WNDPROC -def _wnd_proc(hwnd, msg, wparam, lparam): - if msg == 0x0002: - user32.PostQuitMessage(0) - return 0 - user32.DefWindowProcW.restype = ctypes.c_int64 - user32.DefWindowProcW.argtypes = [ - ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p, - ] - return user32.DefWindowProcW(hwnd, msg, wparam, lparam) - - -def _make_window(width=1024, height=768): - hinstance = kernel32.GetModuleHandleW(None) - name = 'KiwyNavTest' - - class WNDCLASSEX(ctypes.Structure): - _fields_ = [ - ('cbSize', ctypes.c_uint), ('style', ctypes.c_uint), - ('lpfnWndProc', WNDPROC), ('cbClsExtra', ctypes.c_int), - ('cbWndExtra', ctypes.c_int), ('hInstance', ctypes.c_void_p), - ('hIcon', ctypes.c_void_p), ('hCursor', ctypes.c_void_p), - ('hbrBackground', ctypes.c_void_p), ('lpszMenuName', ctypes.c_wchar_p), - ('lpszClassName', ctypes.c_wchar_p), ('hIconSm', ctypes.c_void_p), - ] - - wc = WNDCLASSEX() - wc.cbSize = ctypes.sizeof(WNDCLASSEX) - wc.style = 0x0002 | 0x0001 - wc.lpfnWndProc = _wnd_proc - wc.hInstance = hinstance - wc.hbrBackground = ctypes.c_void_p(6) - wc.lpszClassName = name - user32.RegisterClassExW(ctypes.byref(wc)) - hwnd = user32.CreateWindowExW( - 0, name, 'Kiwy Nav Test', 0x00CF0000 | 0x10000000, - 40, 40, width, height, 0, 0, hinstance, 0, - ) - if not hwnd: - raise RuntimeError('CreateWindowExW failed') - user32.UpdateWindow(hwnd) - return hwnd - - -def _pump(seconds): - import ctypes.wintypes as wt - - deadline = time.monotonic() + seconds - while time.monotonic() < deadline: - msg = wt.MSG() - while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 1): - user32.TranslateMessage(ctypes.byref(msg)) - user32.DispatchMessageW(ctypes.byref(msg)) - time.sleep(0.02) - - -def _drive(browser, seconds): - deadline = time.monotonic() + seconds - while time.monotonic() < deadline: - browser._tick(0) - _pump(0.05) - - -def _wait_nav(browser, timeout): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - browser._tick(0) - _pump(0.05) - if browser.navigation_succeeded() is not None: - return browser.navigation_succeeded() - return None - - -def main(): - print('=' * 68) - print(' WebView2 NavigationCompleted test') - print('=' * 68) - - from webview2_browser import WebView2Browser - - if not WebView2Browser.is_available(): - print('FAIL: WebView2 unavailable:', WebView2Browser._import_error) - return 1 - - httpd = _start_server() - good = f'http://127.0.0.1:{PORT}/index.html' - # A port nothing listens on: guarantees a real navigation failure. - bad = 'http://127.0.0.1:1/missing' - - hwnd = _make_window() - browser = WebView2Browser(hwnd_provider=lambda: hwnd) - - ok = True - - print(f'\n[1] good page: {good}') - browser.show(good) - _drive(browser, 1.0) - result = _wait_nav(browser, 20) - print(f' navigation_succeeded = {result}') - print(f' status = {browser.navigation_status()!r}') - if result is not True: - print(' FAIL: good page did not report success') - ok = False - - print(f'\n[2] bad page: {bad}') - browser.show(bad) - _drive(browser, 1.0) - result = _wait_nav(browser, 25) - print(f' navigation_succeeded = {result}') - print(f' status = {browser.navigation_status()!r}') - if result is not False: - print(' FAIL: bad page did not report failure') - ok = False - - browser.shutdown() - httpd.shutdown() - httpd.server_close() - - print('=' * 68) - print(' RESULT:', 'PASS' if ok else 'FAIL') - if ok: - print(' The player can tell a loaded page from a failed one,') - print(' so unreachable weblinks are skipped instead of shown blank.') - print('=' * 68) - return 0 if ok else 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/windows/test_webview2_offline.py b/windows/test_webview2_offline.py deleted file mode 100644 index 5278885..0000000 --- a/windows/test_webview2_offline.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Closed-network test: does a WebView2 page still load with no internet? - -The signage player lives on an isolated LAN, so the important question is not -"does example.com load" but "does a page on a reachable *local* host still -render when there is no internet at all". - -This test simulates that properly: - -1. Start a tiny HTTP server on 127.0.0.1 serving a known marker page. -2. Create the WebView2 environment **with the same offline browser arguments - the player uses** (webview2_browser._build_environment_options). -3. Navigate to the local page and confirm the page's actual content arrives — - not merely that the controller came up. - -It also blocks real internet resolution for the browser by pointing it at the -local server only, so a pass here means offline playback genuinely works. - -Run: windows\\venv\\Scripts\\python.exe windows\\test_webview2_offline.py -Exit code 0 = PASS. -""" - -import ctypes -import http.server -import os -import socketserver -import sys -import threading -import time -import urllib.request -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -MARKER = 'KIWY-OFFLINE-LAN-OK' -PORT = 18765 - -PAGE = f""" -pc - -
{MARKER}
-""" - - -class _Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = PAGE.encode('utf-8') - self.send_response(200) - self.send_header('Content-Type', 'text/html; charset=utf-8') - self.send_header('Content-Length', str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *args): - pass # keep the test output clean - - -def _start_server(): - socketserver.TCPServer.allow_reuse_address = True - httpd = socketserver.TCPServer(('127.0.0.1', PORT), _Handler) - thread = threading.Thread(target=httpd.serve_forever, daemon=True) - thread.start() - return httpd - - -# ── Win32 window (same approach as test_webview2_embed.py) ────────── -user32 = ctypes.windll.user32 -kernel32 = ctypes.windll.kernel32 - -WNDPROC = ctypes.WINFUNCTYPE( - ctypes.c_int64, ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p -) - - -@WNDPROC -def _wnd_proc(hwnd, msg, wparam, lparam): - if msg == 0x0002: # WM_DESTROY - user32.PostQuitMessage(0) - return 0 - user32.DefWindowProcW.restype = ctypes.c_int64 - user32.DefWindowProcW.argtypes = [ - ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p, - ] - return user32.DefWindowProcW(hwnd, msg, wparam, lparam) - - -def _make_window(width=1280, height=720): - hinstance = kernel32.GetModuleHandleW(None) - class_name = 'KiwyWebView2OfflineTest' - - class WNDCLASSEX(ctypes.Structure): - _fields_ = [ - ('cbSize', ctypes.c_uint), ('style', ctypes.c_uint), - ('lpfnWndProc', WNDPROC), ('cbClsExtra', ctypes.c_int), - ('cbWndExtra', ctypes.c_int), ('hInstance', ctypes.c_void_p), - ('hIcon', ctypes.c_void_p), ('hCursor', ctypes.c_void_p), - ('hbrBackground', ctypes.c_void_p), ('lpszMenuName', ctypes.c_wchar_p), - ('lpszClassName', ctypes.c_wchar_p), ('hIconSm', ctypes.c_void_p), - ] - - wc = WNDCLASSEX() - wc.cbSize = ctypes.sizeof(WNDCLASSEX) - wc.style = 0x0002 | 0x0001 - wc.lpfnWndProc = _wnd_proc - wc.hInstance = hinstance - wc.hbrBackground = ctypes.c_void_p(6) - wc.lpszClassName = class_name - user32.RegisterClassExW(ctypes.byref(wc)) - - hwnd = user32.CreateWindowExW( - 0, class_name, 'Kiwy Offline LAN Test', - 0x00CF0000 | 0x10000000, 60, 60, width, height, 0, 0, hinstance, 0, - ) - if not hwnd: - raise RuntimeError('CreateWindowExW failed') - user32.UpdateWindow(hwnd) - return hwnd - - -def _pump(seconds): - import ctypes.wintypes as wt - - deadline = time.monotonic() + seconds - while time.monotonic() < deadline: - msg = wt.MSG() - while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 1): - user32.TranslateMessage(ctypes.byref(msg)) - user32.DispatchMessageW(ctypes.byref(msg)) - time.sleep(0.02) - - -def main(): - print('=' * 68) - print(' WebView2 closed-network (LAN-only) test') - print('=' * 68) - - from webview2_browser import ( - WebView2Browser, _build_environment_options, _offline_browser_arguments, - ) - - if not WebView2Browser.is_available(): - print('FAIL: WebView2 unavailable:', WebView2Browser._import_error) - return 1 - - print('offline browser args:') - for flag in _offline_browser_arguments().split(): - print(f' {flag}') - - options = _build_environment_options() - if options is None: - print('\nFAIL: could not build offline environment options') - return 1 - print(f'\nAdditionalBrowserArguments set: ' - f'{bool(options.AdditionalBrowserArguments)}') - - httpd = _start_server() - url = f'http://127.0.0.1:{PORT}/dashboard' - print(f'\nlocal server: {url}') - - hwnd = _make_window() - browser = WebView2Browser(hwnd_provider=lambda: hwnd) - - started = time.monotonic() - if not browser.show(url): - print('FAIL: show() returned False:', browser.failed_reason) - httpd.shutdown() - return 1 - - visible = False - deadline = time.monotonic() + 25 - while time.monotonic() < deadline: - browser._tick(0) - _pump(0.1) - if browser.failed_reason: - print('FAIL:', browser.failed_reason) - httpd.shutdown() - return 1 - if browser.is_showing(): - visible = True - break - print(f'page visible : {visible} after {time.monotonic() - started:.1f}s') - - # Confirm the page's REAL CONTENT arrived, not just the controller. - # - # NOTE: ExecuteScriptAsync also returns a .NET Task. Calling .Result here - # would deadlock — the continuation needs this thread's message pump, which - # is exactly the mistake this file otherwise exists to catch. Poll it while - # pumping messages instead. - body = '' - if visible: - try: - task = browser._webview.ExecuteScriptAsync( - "document.getElementById('m').innerText" - ) - deadline = time.monotonic() + 10 - while time.monotonic() < deadline: - _pump(0.05) - if task.IsCompleted: - body = task.Result - break - except Exception as exc: - print(f'note: script eval failed ({exc})') - got_marker = MARKER in (body or '') - print(f'page content : {"marker found" if got_marker else "MARKER MISSING"} ' - f'({(body or "")[:60]})') - - browser.shutdown() - httpd.shutdown() - httpd.server_close() - - ok = visible and got_marker - print('=' * 68) - print(' RESULT:', 'PASS' if ok else 'FAIL') - if ok: - print(' A local page renders with all internet traffic disabled —') - print(' web links work on a closed network.') - print('=' * 68) - return 0 if ok else 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/windows/test_webview2_runtime.py b/windows/test_webview2_runtime.py deleted file mode 100644 index 2f5db1e..0000000 --- a/windows/test_webview2_runtime.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Standalone test for windows/webview2_runtime.py — no Kivy, no player. - -Checks the runtime-detection logic and (optionally) a real silent install. - -Run: windows\\venv\\Scripts\\python.exe windows\\test_webview2_runtime.py - windows\\venv\\Scripts\\python.exe windows\\test_webview2_runtime.py --install - -Without --install this is read-only: it reports the detected version and which -installer would be used. With --install it forces the installer path to run -(useful on a machine that genuinely lacks the Runtime). -Exit code 0 = checks passed. -""" - -import os -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -import webview2_runtime as w # noqa: E402 - - -def main(): - force_install = '--install' in sys.argv - - print('=' * 68) - print(' WebView2 Runtime detection test') - print('=' * 68) - - version = w.get_runtime_version() - installed = w.is_runtime_installed() - print(f'registry/SDK version : {version or "(none)"}') - print(f'is_runtime_installed : {installed}') - - installer, kind = w.find_installer() - print(f'installer : {installer}') - print(f'installer kind : {kind}') - print(f'describe() : {w.describe()}') - - ok = True - - # Version parsing must be comparable and tolerant of junk. - cases = { - '152.0.4191.66': (152, 0, 4191, 66), - '1.2': (1, 2, 0, 0), - '': (0, 0, 0, 0), - None: (0, 0, 0, 0), - } - for raw, expected in cases.items(): - got = w._parse_version(raw) - flag = 'ok' if got == expected else 'FAIL' - if got != expected: - ok = False - print(f' parse({raw!r:16}) -> {got} [{flag}]') - - # An installer must be discoverable: without one, a Runtime-less machine - # has no way to recover. - if installer is None: - print('\nWARNING: no installer found — a machine without the Runtime ' - 'cannot self-heal.') - print('Run: .\\webview2_runtime\\download_runtime_installers.ps1') - else: - sig_status = 'n/a' - try: - import subprocess - - out = subprocess.run( - ['powershell', '-NoProfile', '-Command', - f'(Get-AuthenticodeSignature -LiteralPath "{installer}").Status'], - capture_output=True, text=True, timeout=60, - ) - sig_status = (out.stdout or '').strip() or 'unknown' - except Exception as exc: - sig_status = f'check failed: {exc}' - print(f'signature : {sig_status}') - - if force_install: - print('\n--install given: running the silent installer path...') - result = w.ensure_runtime(timeout=600) - print(f'ensure_runtime() -> {result}') - if not result.get('installed'): - ok = False - else: - # Read-only path: ensure_runtime must be a no-op that reports presence. - result = w.ensure_runtime(timeout=60) - print(f'\nensure_runtime() (read-only) -> {result}') - if installed and not result.get('installed'): - print('FAIL: Runtime present but ensure_runtime() disagreed') - ok = False - if result.get('action') not in ('already-present', 'installer-missing', - 'skipped-recent-failure', - 'installed-standalone', - 'installed-bootstrapper', - 'attempted-standalone', - 'attempted-bootstrapper'): - print(f'FAIL: unexpected action {result.get("action")!r}') - ok = False - - print('=' * 68) - print(' RESULT:', 'PASS' if ok else 'FAIL') - print('=' * 68) - return 0 if ok else 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/windows/verify_sendinput_fix.py b/windows/verify_sendinput_fix.py deleted file mode 100644 index ea8f09b..0000000 --- a/windows/verify_sendinput_fix.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -"""Rigorous verification: is the fixed SendInput code in the compiled run_win?""" -import marshal -import types -import dis -from PyInstaller.archive.readers import CArchiveReader - -EXE = r'dist\KiwySignagePlayer\KiwySignagePlayer.exe' - - -def collect_strings(code, acc): - for c in code.co_consts: - if isinstance(c, str): - acc.append(c) - elif isinstance(c, types.CodeType): - collect_strings(c, acc) - - -def collect_codes(code, acc): - acc.append(code) - for c in code.co_consts: - if isinstance(c, types.CodeType): - collect_codes(c, acc) - - -def main(): - arc = CArchiveReader(EXE) - data = arc.extract('run_win') - code = marshal.loads(data) - - all_codes = [] - collect_codes(code, all_codes) - strings = [] - collect_strings(code, strings) - - # 1) c_ulonglong can ONLY come from the fixed code (old used c_ulong + POINTER) - has_c_ulonglong = any('c_ulonglong' in c.co_names for c in all_codes) - print(f'c_ulonglong in co_names of any code object: {has_c_ulonglong}') - - # 2) INPUTUNION should be STORE_DEREF/STORE_NAME'd inside - # _force_foreground_sendinput (classes are defined in a closure, - # so they're stored via STORE_DEREF). - store_names = set() - for c in all_codes: - for instr in dis.get_instructions(c): - if instr.opname in ('STORE_NAME', 'STORE_FAST', 'STORE_DEREF'): - store_names.add(instr.argval) - print(f'INPUTUNION stored: {"INPUTUNION" in store_names}') - print(f'KEYBDINPUT stored: {"KEYBDINPUT" in store_names}') - print(f'MOUSEINPUT stored: {"MOUSEINPUT" in store_names}') - print(f'HARDWAREINPUT stored: {"HARDWAREINPUT" in store_names}') - - # 3) The new code uses INPUT() + field assignment (type, u.ki.wVk, u.ki.dwFlags) - names = set() - for c in all_codes: - names.update(c.co_names) - print(f'Has "u" attribute usage: {"u" in names}') - - # 4) string markers - print(f'string "undersized buffer": {any("undersized buffer" in s for s in strings)}') - print(f'string "real Win32 x64": {any("real Win32 x64" in s for s in strings)}') - print(f'string "ULONG_PTR": {any("ULONG_PTR" in s for s in strings)}') - - # 5) Console-window fix in _find_kivy_hwnd: cellvars/freevars + tuple consts - find_kv = [c for c in all_codes if c.co_name == '_find_kivy_hwnd'] - cell_vars = set() - for c in find_kv: - cell_vars.update(c.co_cellvars) - cell_vars.update(c.co_freevars) - enum_cb = [c for c in all_codes - if c.co_name == '_enum_cb' and 'SDL_CLASSES' in c.co_freevars] - tuple_strs = set() - for c in enum_cb: - for x in c.co_consts: - if isinstance(x, tuple): - tuple_strs.update(str(v) for v in x) - print(f'_find_kivy_hwnd cell/free vars (SDL_CLASSES/sdl_windows/our_pid): ' - f'{"SDL_CLASSES" in cell_vars and "sdl_windows" in cell_vars and "our_pid" in cell_vars}') - print(f'console class excluded (ConsoleWindowClass in _enum_cb tuple): ' - f'{"ConsoleWindowClass" in tuple_strs}') - - verdict = has_c_ulonglong and 'INPUTUNION' in store_names - console_fix = ('SDL_CLASSES' in cell_vars and 'ConsoleWindowClass' in tuple_strs) - print(f'\nVerdict SendInput: {"FIX PRESENT" if verdict else "FIX ABSENT - rebuild needed"}') - print(f'Verdict console-hwnd: {"FIX PRESENT" if console_fix else "FIX ABSENT - rebuild needed"}') - - -if __name__ == '__main__': - main() diff --git a/windows/version_info.txt b/windows/version_info.txt deleted file mode 100644 index aab1552..0000000 --- a/windows/version_info.txt +++ /dev/null @@ -1,43 +0,0 @@ -# UTF-8 -# -# Windows version resource for KiwySignagePlayer.exe -# This file is used by PyInstaller (version=) to embed publisher/product -# metadata into the executable so Windows Smart App Control / SmartScreen -# can identify the app instead of flagging it as "Unknown publisher". -# -# Note: A code-signing certificate is still required for a fully trusted -# publisher name; this metadata at least names the product/company and -# supplies a version number. -# -VSVersionInfo( - ffi=FixedFileInfo( - filevers=(1, 2, 0, 0), - prodvers=(1, 2, 0, 0), - mask=0x3f, - flags=0x0, - OS=0x40004, - fileType=0x1, - subtype=0x0, - date=(0, 0) - ), - kids=[ - StringFileInfo( - [ - StringTable( - '040904B0', - [ - StringStruct('CompanyName', 'Kiwy Signage'), - StringStruct('FileDescription', 'Kiwy Signage Player - Digital Signage Player'), - StringStruct('FileVersion', '1.2.0.0'), - StringStruct('InternalName', 'KiwySignagePlayer'), - StringStruct('LegalCopyright', 'Copyright (c) 2026 Kiwy Signage'), - StringStruct('OriginalFilename', 'KiwySignagePlayer.exe'), - StringStruct('ProductName', 'Kiwy Signage Player'), - StringStruct('ProductVersion', '1.2.0.0'), - ] - ) - ] - ), - VarFileInfo([VarStruct('Translation', [1033, 1200])]) - ] -) diff --git a/windows/watchdog.ps1 b/windows/watchdog.ps1 deleted file mode 100644 index 7a76ff7..0000000 --- a/windows/watchdog.ps1 +++ /dev/null @@ -1,441 +0,0 @@ -<# -===================================================================== - watchdog.ps1 - keep the Kiwy Signage Player running 24/7 on Windows -===================================================================== - -WHAT THIS DOES - Supervises KiwySignagePlayer.exe and restarts it when it: - - * CRASHES - the process disappeared without the user asking it to. - * HANGS - the process is alive but its heartbeat file has gone stale, - which means the UI thread is wedged (a frozen player looks - exactly like a working one from the outside). - - This is the Windows counterpart of the Linux `start.sh` watchdog, which has - been running the player on the Raspberry Pi deployments. - -HOW THE "PASSWORD ONLY" EXIT IS PRESERVED - The only supported way to stop the player is the exit screen's password. - On success the player writes a stop-flag file next to its .exe: - - .player_stop_requested - - The flag is SESSION SCOPED: - - * While the flag exists, this watchdog will NOT restart the player; it - stops supervising and exits (the machine is left with the player off). - * Every time the watchdog starts, it CLEARS the flag first, which is what - makes a fresh launch a fresh session. So starting the player again is - all it takes to resume - there is no file to delete by hand. - -CRASH-LOOP BREAKER - A player that cannot stay up (bad config, missing media, GPU problem) would - otherwise be restarted forever. If the player dies `CrashLoopMaxFailures` - times inside `CrashLoopWindowMin` minutes WITHOUT ever becoming healthy, - the watchdog backs off for `CrashLoopBackoffMin` minutes before retrying, - and keeps the reason in the log. - -WHAT IT DOES NOT DO - It does not install itself to run at boot/login - start it with - `start_player_watchdog.bat`. It also cannot watch the player before the - player has ever run, so a totally broken install simply logs and backs off. - -USAGE - powershell -NoProfile -ExecutionPolicy Bypass -File watchdog.ps1 - powershell -NoProfile -ExecutionPolicy Bypass -File watchdog.ps1 -Status - - Stop it with Ctrl+C in its window (this does NOT stop the player; it only - stops supervising). -#> - -[CmdletBinding()] -param( - # Path to the player executable. Defaults to the standard build output. - [string]$ExePath, - - # How often the supervisor checks on the player. - [int]$HealthCheckIntervalSec = 15, - - # A heartbeat older than this means the player is wedged, since the player - # rewrites it every 10 seconds. Matches the proven Linux value of 60s. - [int]$HeartbeatStaleSec = 60, - - # After launching, give the player this long to write its first heartbeat - # (startup shows a splash video, so it is not instant) before health - # checks count against it. - [int]$StartupGraceSec = 120, - - # Pause before restarting a player that died. - [int]$RestartDelaySec = 5, - - # Crash-loop breaker (see header). - [int]$CrashLoopWindowMin = 10, - [int]$CrashLoopMaxFailures = 5, - [int]$CrashLoopBackoffMin = 10, - - # Report current state and exit - never launches or kills anything. - [switch]$Status -) - -$ErrorActionPreference = 'Stop' - -# UTF-8 so accented paths in logs are readable. -$OutputEncoding = [System.Text.Encoding]::UTF8 - -# --------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------- -function Resolve-ExePath { - param([string]$Override) - - if ($Override) { - if (-not (Test-Path -LiteralPath $Override)) { - throw "Player executable not found: $Override" - } - return (Resolve-Path -LiteralPath $Override).Path - } - - $candidates = @( - (Join-Path $PSScriptRoot 'dist\KiwySignagePlayer\KiwySignagePlayer.exe'), - (Join-Path $PSScriptRoot 'KiwySignagePlayer.exe') - ) - foreach ($c in $candidates) { - if (Test-Path -LiteralPath $c) { - return (Resolve-Path -LiteralPath $c).Path - } - } - throw ("Could not find KiwySignagePlayer.exe. Looked in:`n " + - ($candidates -join "`n ")) -} - -$ExeFull = Resolve-ExePath -Override $ExePath -$PlayerDir = Split-Path -Parent $ExeFull -$HeartbeatFile = Join-Path $PlayerDir '.player_heartbeat' -$StopFlagFile = Join-Path $PlayerDir '.player_stop_requested' -$LogDir = Join-Path $PlayerDir 'logs' -$LogFile = Join-Path $LogDir 'watchdog.log' -$ProcessName = [System.IO.Path]::GetFileNameWithoutExtension($ExeFull) - -New-Item -ItemType Directory -Force -Path $LogDir | Out-Null - -# --------------------------------------------------------------------- -# Logging (kept tiny and never throws) -# --------------------------------------------------------------------- -function Write-Log { - param( - [Parameter(Mandatory = $true)][string]$Message, - [ValidateSet('INFO', 'WARN', 'ERROR')][string]$Level = 'INFO' - ) - $line = "[{0}] [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message - Write-Host $line - try { - Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 - } catch { - # Logging must never take the supervisor down. - } -} - -# --------------------------------------------------------------------- -# Player process helpers -# --------------------------------------------------------------------- -function Get-PlayerProcesses { - # NOTE: the packaged player is TWO processes on Windows - the PyInstaller - # bootloader parent and the child that owns the SDL window. Both share the - # image name, so selecting by name covers the whole tree. - @(Get-Process -Name $ProcessName -ErrorAction SilentlyContinue) -} - -function Get-PlayerRootPid { - $procs = Get-PlayerProcesses - if ($procs.Count -eq 0) { return $null } - # Prefer the child (it has the main window); fall back to any. - $withWindow = $procs | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1 - if ($withWindow) { return $withWindow.Id } - return ($procs | Select-Object -First 1).Id -} - -function Test-HeartbeatFresh { - param([int]$MaxAgeSec) - - if (-not (Test-Path -LiteralPath $HeartbeatFile)) { - return $false - } - try { - $age = (Get-Date).ToUniversalTime() - ` - (Get-Item -LiteralPath $HeartbeatFile).LastWriteTimeUtc - return ($age.TotalSeconds -lt $MaxAgeSec) - } catch { - return $false - } -} - -function Get-HeartbeatAgeSec { - if (-not (Test-Path -LiteralPath $HeartbeatFile)) { return -1 } - try { - $age = (Get-Date).ToUniversalTime() - ` - (Get-Item -LiteralPath $HeartbeatFile).LastWriteTimeUtc - return [int]$age.TotalSeconds - } catch { - return -1 - } -} - -function Stop-PlayerTree { - param([int]$RootPid) - - # /T kills the PyInstaller child too; without it the visible player window - # would survive and the next launch would collide with it. - try { - & taskkill.exe /F /T /PID $RootPid 2>&1 | Out-Null - } catch { - Write-Log "taskkill failed for pid $RootPid ($($_.Exception.Message)); forcing by name" 'WARN' - } - - # Belt and braces: make sure no stragglers remain. - $deadline = (Get-Date).AddSeconds(15) - while ((Get-Date) -lt $deadline) { - if ((Get-PlayerProcesses).Count -eq 0) { return $true } - Start-Sleep -Milliseconds 500 - } - foreach ($p in Get-PlayerProcesses) { - try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch { } - } - Start-Sleep -Milliseconds 500 - return ((Get-PlayerProcesses).Count -eq 0) -} - -function Start-Player { - Write-Log "Launching player: $ExeFull" - # Start in the player's own folder: the app resolves config/media/playlists - # relative to its working directory. - Start-Process -FilePath $ExeFull -WorkingDirectory $PlayerDir | Out-Null -} - -# --------------------------------------------------------------------- -# -Status : report and exit (never mutates anything) -# --------------------------------------------------------------------- -if ($Status) { - $procs = Get-PlayerProcesses - $pid0 = Get-PlayerRootPid - $age = Get-HeartbeatAgeSec - Write-Host '==========================================' - Write-Host ' Kiwy Signage Player - Watchdog Status' - Write-Host '==========================================' - Write-Host ("Executable : {0}" -f $ExeFull) - Write-Host ("Processes : {0}" -f $procs.Count) - if ($pid0) { Write-Host ("Root PID : {0}" -f $pid0) } - if ($age -ge 0) { - Write-Host ("Heartbeat : {0}s old" -f $age) - if ($age -lt $HeartbeatStaleSec) { - Write-Host 'Health : HEALTHY' -ForegroundColor Green - } else { - Write-Host 'Health : STALE (player may be hung)' -ForegroundColor Yellow - } - } else { - Write-Host 'Heartbeat : (not present - player has not started)' - } - if (Test-Path -LiteralPath $StopFlagFile) { - Write-Host 'Stop flag : PRESENT (password exit was used)' - Write-Host ' a new launch clears it' -ForegroundColor Yellow - } else { - Write-Host 'Stop flag : absent (watchdog would restart on failure)' - } - Write-Host ("Log : {0}" -f $LogFile) - exit 0 -} - -# --------------------------------------------------------------------- -# Session start: clear the stop flag -# --------------------------------------------------------------------- -# This is what makes the flag session-scoped: the user's password exit ends -# the CURRENT session, and the next launch begins a new one. -if (Test-Path -LiteralPath $StopFlagFile) { - try { - Remove-Item -LiteralPath $StopFlagFile -Force - Write-Log 'Cleared the stop flag from the previous session - new session started' - } catch { - Write-Log "Could not clear the stop flag ($($_.Exception.Message))" 'WARN' - } -} - -Write-Log '==================================================' -Write-Log "Watchdog starting (exe=$ProcessName, check=${HealthCheckIntervalSec}s, stale=${HeartbeatStaleSec}s)" -Write-Log "Crash-loop breaker: ${CrashLoopMaxFailures} failures / ${CrashLoopWindowMin} min -> ${CrashLoopBackoffMin} min backoff" -Write-Log 'Stop the WATCHDOG with Ctrl+C. Stop the PLAYER via the exit password.' -Write-Log '==================================================' - -# --------------------------------------------------------------------- -# Crash-loop breaker -# --------------------------------------------------------------------- -# A player that cannot stay up (broken config, missing media, GPU fault) would -# otherwise be restarted forever, filling the log and thrashing the machine. -# If it fails CrashLoopMaxFailures times inside CrashLoopWindowMin minutes -# WITHOUT ever becoming healthy, wait CrashLoopBackoffMin minutes before the -# next attempt. Returns $true when a backoff was performed. -function Invoke-CrashLoopBackoff { - param([System.Collections.ArrayList]$Failures) - - if ($Failures.Count -lt $CrashLoopMaxFailures) { return $false } - - $windowStart = (Get-Date).AddMinutes(-$CrashLoopWindowMin) - $recent = @($Failures | Where-Object { $_ -gt $windowStart }) - if ($recent.Count -lt $CrashLoopMaxFailures) { - # Only old failures remain; forget them so they cannot accumulate. - $Failures.Clear() - return $false - } - - Write-Log ("Player failed $($recent.Count) times in the last " + - "${CrashLoopWindowMin} minutes without ever becoming healthy.") 'ERROR' - Write-Log "Backing off for ${CrashLoopBackoffMin} minutes before the next attempt." 'ERROR' - Write-Log "Investigate $LogDir (and the player's own logs next to it)." 'ERROR' - - $backoffEnd = (Get-Date).AddMinutes($CrashLoopBackoffMin) - while ((Get-Date) -lt $backoffEnd) { - Start-Sleep -Seconds 5 - if (Test-Path -LiteralPath $StopFlagFile) { - Write-Log 'Stop flag appeared during backoff - standing down.' - $Failures.Clear() - return $true - } - } - $Failures.Clear() - Write-Log 'Backoff finished - resuming supervision.' - return $true -} - -# --------------------------------------------------------------------- -# Main supervision loop -# --------------------------------------------------------------------- -# Two DIFFERENT failures need two different reactions, and confusing them -# would be harmful: -# -# * "never became healthy" - a slow start-up, a bad install, or the -# first-run setup screen. Killing on this would restart forever and could -# interrupt an operator entering settings. Handled by the crash-loop -# breaker, with a long hard cap before we intervene. -# * "was healthy, then went silent" - the app is wedged. This is the case -# that must be restarted promptly. -# -# So a hang is only declared once we have actually SEEN a fresh heartbeat. -$failureTimestamps = New-Object System.Collections.ArrayList -$hasBeenHealthy = $false - -while ($true) { - - # ---- The operator asked to exit: stand down ---------------------- - if (Test-Path -LiteralPath $StopFlagFile) { - Write-Log 'Stop flag present - the operator exited with the password.' - Write-Log 'Watchdog will NOT restart the player. Start it again to resume.' - break - } - - $procs = Get-PlayerProcesses - - # ================================================================ - # Case 1: the player is not running - # ================================================================ - if ($procs.Count -eq 0) { - Write-Log 'Player is not running - starting it.' - - try { - Start-Player - } catch { - Write-Log "Failed to launch the player: $($_.Exception.Message)" 'ERROR' - [void]$failureTimestamps.Add((Get-Date)) - [void](Invoke-CrashLoopBackoff -Failures $failureTimestamps) - Start-Sleep -Seconds $RestartDelaySec - continue - } - - $launchTime = Get-Date - $hasBeenHealthy = $false - - # Watch the start-up window: break early the moment it is healthy, - # and notice immediately if it dies. - $diedEarly = $false - $graceEnd = $launchTime.AddSeconds($StartupGraceSec) - while ((Get-Date) -lt $graceEnd) { - Start-Sleep -Seconds 2 - if (Test-Path -LiteralPath $StopFlagFile) { break } - if ((Get-PlayerProcesses).Count -eq 0) { $diedEarly = $true; break } - if (Test-HeartbeatFresh -MaxAgeSec $HeartbeatStaleSec) { - $hasBeenHealthy = $true - break - } - } - - if ($diedEarly -and -not (Test-Path -LiteralPath $StopFlagFile)) { - [void]$failureTimestamps.Add((Get-Date)) - Write-Log ("Player exited during start-up (within the ${StartupGraceSec}s grace window).") 'WARN' - [void](Invoke-CrashLoopBackoff -Failures $failureTimestamps) - Write-Log ("Waiting ${RestartDelaySec}s before retrying...") - Start-Sleep -Seconds $RestartDelaySec - } elseif ($hasBeenHealthy) { - Write-Log 'Player started and is reporting a fresh heartbeat.' - $failureTimestamps.Clear() - } else { - Write-Log 'Player is running but has not reported a heartbeat yet - continuing to watch.' 'WARN' - } - continue - } - - # ================================================================ - # Case 2: the player is running - is it actually working? - # ================================================================ - if (Test-HeartbeatFresh -MaxAgeSec $HeartbeatStaleSec) { - # Healthy. Forget past failures - only CONSECUTIVE ones matter. - if (-not $hasBeenHealthy -or $failureTimestamps.Count -gt 0) { - Write-Log 'Player is healthy.' - } - $hasBeenHealthy = $true - $failureTimestamps.Clear() - Start-Sleep -Seconds $HealthCheckIntervalSec - continue - } - - $age = Get-HeartbeatAgeSec - $ageText = if ($age -lt 0) { 'no heartbeat file' } else { "${age}s old" } - - if ($hasBeenHealthy) { - # ---- The real hang: it WAS working and has gone silent ---------- - Write-Log ("Player was healthy but its heartbeat is now stale ({0}, limit ${HeartbeatStaleSec}s) - it is hung." -f $ageText) 'ERROR' - - $rootPid = Get-PlayerRootPid - if ($rootPid) { - Write-Log "Killing the hung player (pid $rootPid) and its children..." - [void](Stop-PlayerTree -RootPid $rootPid) - } - $hasBeenHealthy = $false - [void]$failureTimestamps.Add((Get-Date)) - - if (Test-Path -LiteralPath $StopFlagFile) { continue } - [void](Invoke-CrashLoopBackoff -Failures $failureTimestamps) - Write-Log ("Waiting ${RestartDelaySec}s before restarting...") - Start-Sleep -Seconds $RestartDelaySec - continue - } - - # ---- Running but never healthy: be patient, then give up ----------- - # Deliberately generous: a slow machine still inside its start-up window - # must not be killed, and neither must the first-run setup screen. - $stuckLimitSec = $StartupGraceSec * 2 - if ($null -eq $launchTime) { $launchTime = Get-Date } - $upSec = ((Get-Date) - $launchTime).TotalSeconds - - if ($upSec -lt $stuckLimitSec) { - Write-Log ("Player is up but not yet healthy ({0}); still inside the ${stuckLimitSec}s start-up allowance." -f $ageText) 'WARN' - Start-Sleep -Seconds $HealthCheckIntervalSec - continue - } - - Write-Log ("Player has not become healthy within ${stuckLimitSec}s ({0}) - restarting it." -f $ageText) 'ERROR' - $rootPid = Get-PlayerRootPid - if ($rootPid) { [void](Stop-PlayerTree -RootPid $rootPid) } - [void]$failureTimestamps.Add((Get-Date)) - [void](Invoke-CrashLoopBackoff -Failures $failureTimestamps) - Write-Log ("Waiting ${RestartDelaySec}s before retrying...") - Start-Sleep -Seconds $RestartDelaySec -} - -Write-Log 'Watchdog exited.' diff --git a/windows/webview2_browser.py b/windows/webview2_browser.py deleted file mode 100644 index 735fe66..0000000 --- a/windows/webview2_browser.py +++ /dev/null @@ -1,718 +0,0 @@ -"""webview2_browser.py — Embedded WebView2 (Edge/Chromium) INSIDE Kivy's window. - -Why this exists ---------------- -The old weblink engines launched a *separate* browser process (Chrome/Edge -kiosk subprocess, or the dormant `cef_browser.py`). That model caused every -weblink bug in the tracker: the browser opening behind the Kivy window, being -handed off to an existing instance and exiting instantly, fighting for -foreground/z-order, and leaking `msedge.exe`/`chrome.exe` processes that were -never closed. - -WebView2 renders as a **child HWND of Kivy's own SDL window**, so: - - * no separate top-level window → nothing can open "in the background", - * nothing to hand the URL off to → no instant-exit hand-off, - * no z-order/foreground fight → it is literally a child of our window, - * teardown is ours → no leaked browser processes, - * the page renders at exactly the rectangle we give it (1920x1080 or - whatever the Kivy window currently is). - -Licensing / distribution: the WebView2 **runtime** is a free, evergreen, -Microsoft-shipped component (already present on this host as -``152.0.4191.66``). We only ship the small managed SDK + native loader DLLs. - -Implementation notes --------------------- -* We talk to the .NET SDK through **pythonnet** (``clr``). -* Every WebView2 API is async (returns a .NET ``Task``). We must NOT call - ``.GetAwaiter().GetResult()``: the continuation needs the *same* thread's - message pump, so blocking would deadlock. Instead each task is **polled from - Kivy's Clock** (the SDL thread, which pumps Win32 messages) and consumed when - ``IsCompleted``. This mirrors how the old CEF code pumped via the Clock. -* All public methods are safe to call from the Kivy main thread. -""" - -from __future__ import annotations - -import ctypes -import os -import sys -import threading -from pathlib import Path - -# ── SDK discovery ──────────────────────────────────────────────────── -# The managed Microsoft.Web.WebView2.Core.dll and the native -# WebView2Loader.dll must sit in a folder we can find both in development and -# inside the PyInstaller bundle. -_SDK_ENV_VAR = 'KIWY_WEBVIEW2_SDK' - - -def _sdk_candidates(): - here = Path(__file__).resolve().parent - yield here / 'webview2_sdk' - # PyInstaller one-folder layout: bundled data lands next to the exe - # (sys._MEIPASS points at the temporary _MEIxxx dir). - meipass = getattr(sys, '_MEIPASS', None) - if meipass: - yield Path(meipass) / 'webview2_sdk' - yield here - - -def _find_sdk_dir(): - env = os.environ.get(_SDK_ENV_VAR) - if env and (Path(env) / 'Microsoft.Web.WebView2.Core.dll').is_file(): - return Path(env) - for candidate in _sdk_candidates(): - try: - if (candidate / 'Microsoft.Web.WebView2.Core.dll').is_file(): - return candidate - except OSError: - continue - return None - - -# ── Win32 helpers ──────────────────────────────────────────────────── -_SW_HIDE = 0 -_SW_SHOWNORMAL = 1 - - -class WebView2Browser: - """One embedded WebView2 instance parented to the Kivy (SDL) window. - - Lifecycle:: - - show(url) -> is_showing() (True once painted) -> resize(w,h) -> hide() - -> shutdown() - - ``hide()`` only hides the controller (it stays alive), so switching back to - a weblink later is instant. ``shutdown()`` disposes it for good. - """ - - #: Set True by the integration layer when the SDK + runtime are usable. - _import_error = None - - def __init__(self, hwnd_provider=None, user_data_dir=None): - self._hwnd_provider = hwnd_provider - self._user_data_dir = user_data_dir or os.path.join( - os.environ.get('KIWY_DATA_DIR', os.getcwd()), '.webview2-profile' - ) - self._env = None - self._controller = None - self._webview = None - self._hwnd = None - self._showing = False - self._stage = 'idle' # idle | env | controller | ready - self._pending_url = None - self._failed_reason = '' - self._poll_event = None - self._lock = threading.RLock() - self._task = None - self._task_kind = None - self._size = (0, 0) - # Navigation outcome. `_showing` only means "the controller was told to - # be visible", which happens the instant Navigate() is called — it says - # nothing about whether the page actually loaded. On a closed network - # that distinction is the whole point: an unreachable host paints a - # Chromium error page, so without this the player would show a blank - # error for the full slot instead of skipping the item. - self._navigation_ok = None # None = pending/unknown - self._navigation_status = '' - self._navigation_handlers = [] # keep refs: .NET must not GC these - - # ── Availability ───────────────────────────────────────────────── - @staticmethod - def is_available(): - """True when pythonnet + the SDK DLLs + a runtime are all present.""" - if sys.platform != 'win32': - return False - sdk = _find_sdk_dir() - if sdk is None: - return False - try: - import clr # noqa: F401 (pythonnet) - except Exception as exc: - WebView2Browser._import_error = f'pythonnet unavailable: {exc}' - return False - try: - cls = _load_webview2_types(sdk) - version = cls['env'].GetAvailableBrowserVersionString() - return bool(version) - except Exception as exc: - WebView2Browser._import_error = f'WebView2 unavailable: {exc}' - return False - - # ── Public API (Kivy main thread) ──────────────────────────────── - def show(self, url): - """Begin displaying ``url``. Returns True once the request is accepted. - - Rendering is asynchronous: the caller should poll :meth:`is_showing` - (the session's ``wait_visible`` does this on the watcher thread). - """ - with self._lock: - self._failed_reason = '' - self._pending_url = url - - if self._stage == 'ready' and self._controller is not None: - return self._navigate(url) - - if self._stage in ('env', 'controller'): - return True # already starting up; URL is queued - - # Start-up order: environment → controller → navigate. - self._stage = 'env' - if not self._start_environment(): - self._stage = 'idle' - return False - if self._stage != 'env': - # The environment resolved synchronously (fast path). - return self._after_environment() - return True - - def hide(self): - """Hide the page without destroying the controller (fast re-show).""" - with self._lock: - self._showing = False - self._pending_url = None - controller = self._controller - if controller is not None: - try: - controller.IsVisible = False - except Exception: - pass - - def is_showing(self): - """True while the page is actually on screen.""" - with self._lock: - if self._failed_reason: - return False - if self._controller is None: - return False - return self._showing - - def is_starting(self): - """True while the environment/controller is still being created. - - WebView2 start-up is asynchronous. A controller that does not exist yet - is NOT the same as a browser that has gone away, and conflating the two - made the *first* weblink after a cold start be skipped instantly (the - watcher saw "not alive" and advanced). Callers should treat - ``is_starting()`` as "still alive, not yet painted". - """ - with self._lock: - if self._failed_reason: - return False - return self._stage in ('env', 'controller') - - def is_alive(self): - """True when the browser is starting up or showing. False only on failure.""" - return self.is_showing() or self.is_starting() - - @property - def failed_reason(self): - return self._failed_reason - - def resize(self, width, height): - """Fit the page to ``width`` x ``height`` physical pixels.""" - width, height = int(width), int(height) - if width <= 0 or height <= 0: - return - with self._lock: - self._size = (width, height) - controller = self._controller - if controller is None: - return - try: - from System.Drawing import Rectangle - - controller.Bounds = Rectangle(0, 0, width, height) - except Exception as exc: - _log(f'WebView2 resize failed (non-fatal): {exc}') - - def shutdown(self): - """Dispose the controller and environment. Never raises.""" - with self._lock: - self._showing = False - self._stop_poll_locked() - controller, self._controller = self._controller, None - webview, self._webview = self._webview, None - env, self._env = self._env, None - self._stage = 'idle' - for obj, label in ((webview, 'webview'), (controller, 'controller')): - if obj is None: - continue - try: - dispose = getattr(obj, 'Dispose', None) - if dispose is not None: - dispose() - except Exception as exc: - _log(f'WebView2 {label} dispose failed (non-fatal): {exc}') - if ctypes is not None: - try: - ctypes.windll.ole32.CoUninitialize() - except Exception: - pass - del env - - # ── Start-up ───────────────────────────────────────────────────── - def _start_environment(self): - sdk = _find_sdk_dir() - if sdk is None: - self._failed_reason = 'WebView2 SDK not found' - _log('WebView2: SDK DLLs not found (expected Microsoft.Web.WebView2.Core.dll)') - return False - try: - types = _load_webview2_types(sdk) - except Exception as exc: - self._failed_reason = f'WebView2 SDK load failed: {exc}' - _log(f'WebView2: SDK load failed: {exc}') - return False - - # The controller must live on a thread with a message pump; Kivy's SDL - # thread qualifies, and COM must be initialised on it first. - try: - ctypes.windll.ole32.CoInitializeEx(None, 0x2) # STA - except Exception: - pass - - try: - os.makedirs(self._user_data_dir, exist_ok=True) - except Exception as exc: - _log(f'WebView2: could not create profile dir ({exc}); using temp') - import tempfile - - self._user_data_dir = tempfile.mkdtemp(prefix='kiwy-wv2-') - - _log(f'WebView2: creating environment (profile={self._user_data_dir})') - try: - options = _build_environment_options() - task = _create_environment_async(types, self._user_data_dir, options) - except Exception as exc: - self._failed_reason = f'CreateAsync failed: {exc}' - _log(f'WebView2: environment creation failed: {exc}') - return False - - self._task = task - self._task_kind = 'env' - self._start_poll() - return True - - def _start_controller(self): - hwnd = 0 - if self._hwnd_provider is not None: - try: - hwnd = self._hwnd_provider() or 0 - except Exception as exc: - _log(f'WebView2: hwnd provider failed: {exc}') - if not hwnd: - self._failed_reason = 'Kivy window handle not found' - _log('WebView2: could not locate the Kivy SDL window handle') - return False - self._hwnd = int(hwnd) - - _log(f'WebView2: creating controller inside hwnd=0x{self._hwnd:x}') - try: - # The parent window must be a .NET IntPtr; a plain Python int does - # not match the overload and pythonnet raises "No method matches - # given arguments". - from System import IntPtr - - parent = IntPtr(self._hwnd) - # HWND hosting: WebView2 creates its own child window in `parent`. - task = self._env.CreateCoreWebView2ControllerAsync(parent) - except Exception as exc: - self._failed_reason = f'controller creation failed: {exc}' - _log(f'WebView2: controller creation failed: {exc}') - return False - - self._task = task - self._task_kind = 'controller' - self._stage = 'controller' - self._start_poll() - return True - - def _after_environment(self): - """Called once the environment resolved.""" - if self._env is None: - return False - started = self._start_controller() - if not started and self._stage == 'controller': - return True # still coming up asynchronously - return started - - # ── Async task polling (Kivy Clock) ────────────────────────────── - def _start_poll(self): - try: - from kivy.clock import Clock - - if self._poll_event is None: - self._poll_event = Clock.schedule_interval(self._tick, 0.05) - except Exception: - # No Kivy (or called off-thread): poll from a plain timer instead. - if self._poll_event is None: - self._poll_event = _ThreadTimer(0.05, self._tick, None) - - def _stop_poll_locked(self): - event, self._poll_event = self._poll_event, None - if event is None: - return - try: - cancel = getattr(event, 'cancel', None) - if cancel is not None: - cancel() - else: - event.stop() - except Exception: - pass - - def _tick(self, _dt): - """Consume the in-flight Task once it completes.""" - with self._lock: - task, kind = self._task, self._task_kind - if task is None: - self._stop_poll_locked() - return False - try: - done = bool(task.IsCompleted) - except Exception as exc: - self._failed_reason = f'task poll failed: {exc}' - self._task = None - self._stop_poll_locked() - return False - if not done: - return True - self._task, self._task_kind = None, None - self._stop_poll_locked() - - try: - if task.IsFaulted: - exc = task.Exception - detail = '' - try: - detail = exc.GetBaseException().Message - except Exception: - detail = str(exc) - self._failed_reason = f'{kind} failed: {detail}' - _log(f'WebView2: {kind} task faulted: {detail}') - return False - result = task.Result - except Exception as exc: - self._failed_reason = f'{kind} task error: {exc}' - _log(f'WebView2: {kind} task error: {exc}') - return False - - if kind == 'env': - self._env = result - _log('WebView2: environment ready') - if not self._start_controller(): - self._stage = 'idle' - return False - - if kind == 'controller': - self._controller = result - self._on_controller_ready() - return False - - return False - - def _on_controller_ready(self): - """Wire up the page: bounds, settings, first navigation.""" - controller = self._controller - try: - controller.IsVisible = False # stay hidden until navigated - except Exception: - pass - - webview = None - try: - webview = controller.CoreWebView2 - except Exception as exc: - _log(f'WebView2: CoreWebView2 unavailable: {exc}') - if webview is None: - self._failed_reason = 'CoreWebView2 was not created' - return - self._webview = webview - - # Chrome-less, kiosk-like surface: no context menu, no devtools, - # no accelerators that could let an operator escape the signage. - try: - settings = webview.Settings - settings.AreDefaultContextMenusEnabled = False - settings.AreDevToolsEnabled = False - settings.IsStatusBarEnabled = False - settings.AreBrowserAcceleratorKeysEnabled = False - settings.IsZoomControlEnabled = False - settings.AreDefaultScriptDialogsEnabled = False - except Exception as exc: - _log(f'WebView2: settings tweak failed (non-fatal): {exc}') - - self._hook_navigation_events(webview) - - width, height = self._size - if width > 0 and height > 0: - self.resize(width, height) - - self._stage = 'ready' - _log('WebView2: controller ready') - - with self._lock: - url, self._pending_url = self._pending_url, None - if url: - self._navigate(url) - - def _hook_navigation_events(self, webview): - """Track whether the page actually loaded. - - ``is_showing()`` alone is misleading: it becomes True the moment - ``Navigate()`` is called, before anything has been fetched. On a closed - network the weblink host is often unreachable, and Chromium then paints - an error page — which the player must treat as a failure so the item is - skipped rather than shown as a broken screen for its whole slot. - - Handlers are stored on the instance: if the delegate were only a local, - the .NET GC would collect it and the event would silently stop firing. - """ - try: - handler = _NavigationCompletedHandler(self) - webview.NavigationCompleted += handler - self._navigation_handlers.append(handler) - _log('WebView2: navigation tracking enabled') - except Exception as exc: - # Not fatal: without it we simply cannot distinguish a loaded page - # from an error page, and fall back to "visible means OK". - _log(f'WebView2: could not hook NavigationCompleted ({exc})') - - def navigation_succeeded(self): - """True / False once navigation finished, None while still pending.""" - with self._lock: - return self._navigation_ok - - def navigation_status(self): - with self._lock: - return self._navigation_status - - def _navigate(self, url): - webview = self._webview - if webview is None: - return False - with self._lock: - self._navigation_ok = None - self._navigation_status = '' - try: - webview.Navigate(url) - except Exception as exc: - self._failed_reason = f'navigate failed: {exc}' - _log(f'WebView2: navigate failed: {exc}') - return False - width, height = self._size - if width > 0 and height > 0: - self.resize(width, height) - try: - self._controller.IsVisible = True - except Exception as exc: - _log(f'WebView2: could not show controller: {exc}') - return False - with self._lock: - self._showing = True - _log(f'WebView2: navigated to {url[:80]}') - return True - - -# ── Module helpers ─────────────────────────────────────────────────── -_TYPES_CACHE = {} - - -def _load_webview2_types(sdk_dir): - """Import the managed SDK and return the types we need (cached).""" - key = str(sdk_dir) - cached = _TYPES_CACHE.get(key) - if cached: - return cached - - if hasattr(os, 'add_dll_directory'): - try: - os.add_dll_directory(str(sdk_dir)) # let the loader find WebView2Loader.dll - except Exception: - pass - if key not in sys.path: - sys.path.insert(0, key) - - import clr - - # Framework assemblies we rely on (Rectangle for Bounds). - try: - clr.AddReference('System.Drawing') - except Exception: - pass - clr.AddReference(str(sdk_dir / 'Microsoft.Web.WebView2.Core.dll')) - - from Microsoft.Web.WebView2.Core import CoreWebView2Environment - - types = {'env': CoreWebView2Environment} - _TYPES_CACHE[key] = types - return types - - -def _create_environment_async(types, user_data_dir, options=None): - """Call CreateAsync with the options object. - - The SDK exposes exactly one overload: - ``CreateAsync(string browserExecutableFolder, string userDataFolder, - CoreWebView2EnvironmentOptions options)``. - """ - env_type = types['env'] - last = None - # Preferred: explicit options (used to pass offline browser arguments). - if options is not None: - try: - return env_type.CreateAsync(None, user_data_dir, options) - except Exception as exc: - last = exc - attempts = ( - (None, user_data_dir, None), - (None, user_data_dir), - ) - for args in attempts: - try: - return env_type.CreateAsync(*args) - except Exception as exc: - last = exc - raise last if last is not None else RuntimeError('CreateAsync failed') - - -def _offline_browser_arguments(): - """Chromium flags that stop internet chatter on a closed network. - - A signage player normally lives on an isolated LAN. By default Chromium - still tries to reach the internet for component updates, field trials, - safe-browsing lists, translate, and Google services. On a closed network - every one of those attempts has to time out, which costs start-up latency - (and, if DNS resolves but routes black-hole, can stall for many seconds). - - These flags disable that background traffic. They do NOT affect loading - actual pages — a weblink pointing at the local server still works, and one - pointing at the public internet simply fails fast with a normal - ERR_INTERNET_DISCONNECTED instead of hanging. - """ - return ' '.join([ - '--disable-background-networking', - '--disable-component-update', - '--disable-domain-reliability', - '--disable-features=Translate,OptimizationHints,MediaRouter,' - 'CalculateNativeWinOcclusion', - '--disable-sync', - '--no-first-run', - '--no-default-browser-check', - '--no-pings', - '--disable-breakpad', - '--metrics-recording-only', - '--disable-client-side-phishing-detection', - ]) - - -def _build_environment_options(): - """Create a CoreWebView2EnvironmentOptions with offline flags applied.""" - try: - from Microsoft.Web.WebView2.Core import CoreWebView2EnvironmentOptions - - options = CoreWebView2EnvironmentOptions() - options.AdditionalBrowserArguments = _offline_browser_arguments() - # Don't phone home with crash reports. - try: - options.IsCustomCrashReportingEnabled = False - except Exception: - pass - _log('WebView2: offline browser arguments applied') - return options - except Exception as exc: - _log(f'WebView2: could not build environment options ({exc}); ' - 'continuing with defaults') - return None - - -class _ThreadTimer: - """Minimal fallback timer used only when Kivy's Clock is unavailable.""" - - def __init__(self, interval, func, _unused): - self._interval = float(interval) - self._func = func - self._stop = threading.Event() - self._thread = threading.Thread(target=self._run, daemon=True) - self._thread.start() - - def _run(self): - while not self._stop.wait(self._interval): - try: - if self._func(None) is False: - return - except Exception: - return - - def cancel(self): - self._stop.set() - - -class _NavigationCompletedHandler: - """Adapter for WebView2's ``NavigationCompleted`` event. - - The event is ``System.EventHandler`` - — there is no ``CoreWebView2NavigationCompletedEventHandler`` type to import - (attempting to import one fails). pythonnet converts a plain Python callable - to the generic delegate automatically, so that is what we pass. - - The callable is kept on the browser instance: a delegate referenced only by - a local would be collected by the .NET GC, after which the event silently - stops firing. - """ - - def __init__(self, browser): - self._browser = browser - - def __call__(self, sender, args): - """Fires on the WebView2 thread that owns the message loop.""" - try: - success = bool(args.IsSuccess) - status = _describe_navigation_error(args, success) if not success else '' - with self._browser._lock: - self._browser._navigation_ok = success - self._browser._navigation_status = status - if success: - _log('WebView2: page loaded') - else: - _log(f'WebView2: page failed to load ({status or "unknown"})') - except Exception as exc: - _log(f'WebView2: navigation handler error ({exc})') - - -def _log(message): - try: - from kivy.logger import Logger - - Logger.info(f'[WebView2] {message}') - except Exception: - print(f'[WebView2] {message}') - - -def _describe_navigation_error(args, success): - """Human-readable reason for a failed navigation. - - ``WebErrorStatus`` is an enum whose numeric value is not useful on its own; - when it reports ``Unknown`` (common for connection-level failures) the HTTP - status is more informative, so prefer whichever actually says something. - """ - parts = [] - try: - error_status = str(args.WebErrorStatus) - if error_status and error_status.lower() != 'unknown': - parts.append(error_status) - except Exception: - pass - try: - http_status = int(args.HttpStatusCode) - if http_status > 0: - parts.append(f'HTTP {http_status}') - except Exception: - pass - if parts: - return ', '.join(parts) - return 'connection failed (host unreachable or DNS failure)' diff --git a/windows/webview2_runtime.py b/windows/webview2_runtime.py deleted file mode 100644 index 4c2a25b..0000000 --- a/windows/webview2_runtime.py +++ /dev/null @@ -1,446 +0,0 @@ -"""webview2_runtime.py — make sure the WebView2 Runtime is present. - -Why this exists ---------------- -WebView2 splits into two parts: - -* the **SDK** (the ``Microsoft.Web.WebView2.Core.dll`` + ``WebView2Loader.dll`` - we bundle in ``windows/webview2_sdk``), which is just the API surface, and -* the **Runtime** (``msedgewebview2.exe`` etc.), the actual Chromium engine. - -The SDK is useless without the Runtime. The Runtime ships with Windows 11 and -is present on the vast majority of Windows 10 machines, but Microsoft still -recommends checking for it and installing it when missing — so that is what -this module does. - -Deployment notes (per Microsoft's distribution guidance): - -* If the Runtime is missing we run an installer with ``/silent /install``. -* Run it **without elevation** → per-user install, which never shows a UAC - prompt. That matters for an unattended signage player: a UAC dialog on a - kiosk screen is a failure, not a prompt. -* Two installers are supported, in order of preference: - 1. ``MicrosoftEdgeWebView2RuntimeInstallerX64.exe`` — the ~203 MB offline - *standalone* installer. Works with no internet (drop it in - ``windows/webview2_runtime/`` to have it bundled). - 2. ``MicrosoftEdgeWebview2Setup.exe`` — the ~1.7 MB *bootstrapper*, which - downloads the Runtime from Microsoft. Bundled by default. - -Nothing here ever raises: a failure just means web links fall back to the -Chrome/Edge subprocess engine, which is far better than the player crashing. -""" - -from __future__ import annotations - -import os -import subprocess -import sys -import threading -import time -from pathlib import Path - -# Per Microsoft, the Runtime's presence/version lives in this registry value. -# (Edge Update client GUID for the Evergreen WebView2 Runtime.) -_CLIENT_GUID = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' - -_STANDALONE_NAME = 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe' -_BOOTSTRAPPER_NAME = 'MicrosoftEdgeWebview2Setup.exe' - -#: Don't re-attempt a failing install on every single start-up. -_RETRY_COOLDOWN_SECONDS = 6 * 60 * 60 - -_install_lock = threading.Lock() -_install_state = { - 'attempted': False, - 'installing': False, - 'installed': None, # bool once known - 'version': '', - 'error': '', -} - -#: Set once a background install has finished, so a weblink can wait for it. -_install_done = threading.Event() - - -# ── Detection ──────────────────────────────────────────────────────── -def _parse_version(text): - """Return a comparable tuple from a version string like '152.0.4191.66'.""" - parts = [] - for chunk in str(text or '').split('.'): - digits = ''.join(c for c in chunk if c.isdigit()) - parts.append(int(digits) if digits else 0) - while len(parts) < 4: - parts.append(0) - return tuple(parts[:4]) - - -def _read_registry_version(): - """Read the installed Runtime version from the registry, or ''. - - Checks both install scopes: HKLM (per-machine) and HKCU (per-user). On - 64-bit Windows the per-machine value lives under WOW6432Node because the - Edge Updater is a 32-bit component. - """ - if sys.platform != 'win32': - return '' - try: - import winreg - except Exception: - return '' - - candidates = [ - # (hive, subkey, access flag) - (winreg.HKEY_LOCAL_MACHINE, - rf'SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{_CLIENT_GUID}', 0), - (winreg.HKEY_LOCAL_MACHINE, - rf'SOFTWARE\Microsoft\EdgeUpdate\Clients\{_CLIENT_GUID}', - getattr(winreg, 'KEY_WOW64_32KEY', 0)), - (winreg.HKEY_CURRENT_USER, - rf'SOFTWARE\Microsoft\EdgeUpdate\Clients\{_CLIENT_GUID}', 0), - ] - for hive, subkey, access in candidates: - try: - with winreg.OpenKey(hive, subkey, 0, - winreg.KEY_READ | access) as key: - value, _ = winreg.QueryValueEx(key, 'pv') - value = str(value or '').strip() - if value and _parse_version(value) > (0, 0, 0, 0): - return value - except Exception: - continue - return '' - - -def get_runtime_version(): - """Version of the installed Evergreen Runtime, or '' when absent.""" - version = _read_registry_version() - if version: - return version - # Fallback: ask the SDK itself (also covers preview channels). - try: - from webview2_browser import _find_sdk_dir, _load_webview2_types - - sdk = _find_sdk_dir() - if sdk is not None: - types = _load_webview2_types(sdk) - reported = types['env'].GetAvailableBrowserVersionString() - return str(reported).strip() if reported else '' - except Exception: - pass - return '' - - -def is_runtime_installed(): - """True when a usable WebView2 Runtime is present.""" - return bool(get_runtime_version()) - - -# ── Installer discovery ────────────────────────────────────────────── -def _search_dirs(): - """Folders that may hold an installer, best (standalone) first.""" - here = Path(__file__).resolve().parent - dirs = [here / 'webview2_runtime', here] - meipass = getattr(sys, '_MEIPASS', None) - if meipass: - dirs.append(Path(meipass) / 'webview2_runtime') - # Next to the .exe, so an operator can drop the offline installer in - # without rebuilding. - data_dir = os.environ.get('KIWY_DATA_DIR') - if data_dir: - dirs.append(Path(data_dir) / 'webview2_runtime') - dirs.append(Path(data_dir)) - env = os.environ.get('KIWY_WEBVIEW2_INSTALLER') - if env: - dirs.insert(0, Path(env).parent) - return dirs - - -def find_installer(): - """Locate a usable installer. Returns ``(path, kind)`` or ``(None, None)``. - - The standalone (offline) installer is preferred: it does not depend on the - target machine having internet access, which is the normal case for a - signage player on an isolated LAN. - """ - env = os.environ.get('KIWY_WEBVIEW2_INSTALLER') - if env and Path(env).is_file(): - return Path(env), 'explicit' - - found = {'standalone': None, 'bootstrapper': None} - for directory in _search_dirs(): - try: - if found['standalone'] is None: - candidate = directory / _STANDALONE_NAME - if candidate.is_file(): - found['standalone'] = candidate - if found['bootstrapper'] is None: - candidate = directory / _BOOTSTRAPPER_NAME - if candidate.is_file(): - found['bootstrapper'] = candidate - except OSError: - continue - - if found['standalone'] is not None: - return found['standalone'], 'standalone' - if found['bootstrapper'] is not None: - return found['bootstrapper'], 'bootstrapper' - return None, None - - -def _has_internet(timeout=4.0): - """Quick reachability probe. A closed network returns False fast.""" - try: - import socket - - with socket.create_connection(('www.msftconnecttest.com', 80), - timeout=timeout): - return True - except Exception: - return False - - -# ── Cooldown bookkeeping ───────────────────────────────────────────── -def _marker_path(): - data_dir = os.environ.get('KIWY_DATA_DIR') or os.getcwd() - return Path(data_dir) / 'logs' / '.webview2_install_attempt' - - -def _recent_failed_attempt(): - try: - marker = _marker_path() - if not marker.is_file(): - return False - age = time.time() - marker.stat().st_mtime - return age < _RETRY_COOLDOWN_SECONDS - except Exception: - return False - - -def _record_attempt(): - try: - marker = _marker_path() - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text(str(int(time.time()))) - except Exception: - pass - - -def _clear_attempt_marker(): - try: - marker = _marker_path() - if marker.is_file(): - marker.unlink() - except Exception: - pass - - -# ── Install ────────────────────────────────────────────────────────── -def _create_no_window(): - """Keep the installer from flashing a console window on the signage.""" - try: - return subprocess.CREATE_NO_WINDOW - except AttributeError: - return 0x08000000 - - -def _run_installer(path, timeout): - """Run the installer silently. Returns (ok, detail).""" - # `/silent /install` is the documented silent invocation. Deliberately NOT - # elevated: a non-elevated run performs a per-user install, which never - # raises a UAC prompt on the kiosk display. - args = [str(path), '/silent', '/install'] - _log(f'WebView2: running installer {Path(path).name} /silent /install ' - f'(per-user, no elevation)') - try: - result = subprocess.run( - args, - timeout=timeout, - creationflags=_create_no_window(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - except subprocess.TimeoutExpired: - return False, f'installer timed out after {int(timeout)}s' - except Exception as exc: - return False, f'could not run installer: {exc}' - - code = result.returncode - detail = (result.stdout or b'').decode('utf-8', 'replace').strip() - # Edge Update installers commonly report 0 (success) or 3010 (reboot - # required). They also return non-zero HRESULTs when the Runtime is already - # installed at an equal/newer version — which is why the caller decides - # success by re-reading the installed version rather than trusting this - # code. We only use it to explain a failure. - if code in (0, 3010): - return True, f'installer exit code {code}' - return False, f'installer exit code {code}{": " + detail if detail else ""}' - - -def ensure_runtime(timeout=600): - """Install the Runtime when missing. Blocking; never raises. - - Returns a dict describing the outcome (``installed``, ``version``, - ``error``, ``action``). - """ - with _install_lock: - if is_runtime_installed(): - version = get_runtime_version() - _install_state.update( - attempted=True, installing=False, installed=True, - version=version, error='', - ) - return dict(_install_state, action='already-present') - - if _recent_failed_attempt(): - _install_state.update( - attempted=True, installing=False, installed=False, error='', - ) - return dict(_install_state, action='skipped-recent-failure') - - path, kind = find_installer() - if path is None: - message = ('no WebView2 installer found (expected ' - f'{_STANDALONE_NAME} or {_BOOTSTRAPPER_NAME} in ' - 'windows/webview2_runtime/)') - _log(f'WebView2: {message}') - _install_state.update( - attempted=True, installing=False, installed=False, - error=message, - ) - return dict(_install_state, action='installer-missing') - - if kind == 'bootstrapper': - # The bootstrapper downloads the Runtime from Microsoft. On a closed - # network that can never succeed, so fail fast with an actionable - # message instead of hanging for the whole timeout. - if not _has_internet(): - message = ('the WebView2 Runtime is missing and this machine has ' - f'no internet access; only the ONLINE bootstrapper ' - f'({_BOOTSTRAPPER_NAME}) is available. Bundle the ' - f'offline installer ({_STANDALONE_NAME}, run ' - 'webview2_runtime/download_runtime_installers.ps1 ' - '-Offline) to run on a closed network.') - _log(f'WebView2: {message}') - _install_state.update( - attempted=True, installing=False, installed=False, - error=message, - ) - _install_done.set() - return dict(_install_state, action='offline-no-installer') - _log('WebView2: Runtime missing — using the ONLINE bootstrapper ' - '(downloads ~150 MB). Add the offline standalone installer to ' - 'avoid needing internet.') - - _install_state.update(attempted=True, installing=True, error='') - _record_attempt() - ok, detail = _run_installer(path, timeout) - if not ok and 'already installed' not in detail.lower(): - _log(f'WebView2: installer reported {detail}') - - # Decide success by RE-READING the installed version, not by the exit - # code: a non-zero HRESULT can simply mean "nothing to do". - version = '' - deadline = time.monotonic() + 30 - while time.monotonic() < deadline: - version = get_runtime_version() - if version: - break - time.sleep(1.0) - - if version: - _clear_attempt_marker() - _log(f'WebView2: Runtime available (v{version}) [{detail}]') - _install_state.update( - installing=False, installed=True, version=version, - error='', action=f'installed-{kind}', - ) - else: - message = detail or 'Runtime still not detected after install' - _log(f'WebView2: install did not take effect ({message})') - _install_state.update( - installing=False, installed=False, version='', error=message, - ) - _install_done.set() - return dict(_install_state, action=f'attempted-{kind}') - - -def ensure_runtime_async(timeout=600): - """Kick off :func:`ensure_runtime` on a background thread. - - Called at start-up so a missing Runtime installs while the player is still - syncing its playlist, instead of freezing the UI. - """ - if is_runtime_installed(): - _install_done.set() - _install_state.update(installed=True, version=get_runtime_version()) - return None - - def _worker(): - try: - ensure_runtime(timeout=timeout) - except Exception as exc: # defensive: never kill the player - _log(f'WebView2: background install failed: {exc}') - _install_state.update(installing=False, installed=False, error=str(exc)) - _install_done.set() - - thread = threading.Thread(target=_worker, name='webview2-install', daemon=True) - thread.start() - return thread - - -def wait_for_install(timeout): - """Wait (briefly, on the watcher thread) for a pending install. - - Returns True when a Runtime is available afterwards. - """ - if is_runtime_installed(): - return True - if not _install_state.get('installing'): - return False - _install_done.wait(timeout=max(0.0, float(timeout))) - return is_runtime_installed() - - -def get_state(): - """Snapshot of the installer state, for logging/diagnostics.""" - state = dict(_install_state) - if state.get('installed') is None: - state['installed'] = is_runtime_installed() - state['version'] = state['version'] or get_runtime_version() - return state - - -def describe(): - """One-line status for the startup log.""" - version = get_runtime_version() - if version: - return f'WebView2 Runtime present (v{version})' - path, kind = find_installer() - if path is None: - return 'WebView2 Runtime MISSING and no bundled installer found' - if kind == 'standalone': - return (f'WebView2 Runtime MISSING (will install OFFLINE via ' - f'{path.name} — no internet needed)') - return (f'WebView2 Runtime MISSING (will install via the ONLINE ' - f'bootstrapper {path.name}; needs internet)') - - -def is_offline_ready(): - """True when a Runtime is present, or can be installed without internet. - - This is the property that matters for a closed-network deployment: web - links will work on first start with no outbound connectivity. - """ - if is_runtime_installed(): - return True - path, kind = find_installer() - return path is not None and kind in ('standalone', 'explicit') - - -def _log(message): - try: - from kivy.logger import Logger - - Logger.info(f'[WebView2] {message}') - except Exception: - print(f'[WebView2] {message}') diff --git a/windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe b/windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe deleted file mode 100644 index e10abbc580a880a96f9c36356bcd4976012f94a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1783000 zcmeFae|S{YwLg3&bCL{XUHZd*v_rJ)mHD-e(P25wO89>tAG}CCZS0{ga8(?V2$3i?l5UP$P@;ea=xFn&rC9? zz3;t$ynnpU^X7SSe(bY(^d;PuV?BNJdGLWc+KIB<;qX{tCtQzrUCak~DtW zTjQnI$Gms#Zo}R0UAxM=zA<-W!?*8m_{O(#@B7As4}RO9`^|N^4S@%9*FTuMbVWt( zx4yl0-Oc01jm=9`-C+9U5A)W4Z)fuVy0pi4EyDYCY1?b^m*%ZcCHccL6L9e`un`p zX0nGarzG8N$dD$!_I+P6tzSwrWEsX!lXjq^E3ed(+iG57N6Bf{Xe}#E|lrD@ki^ zZdm&b|2OdINqnHb{}A`raZmXx1VV1svq`gT_^<{IkHvi%?kRtTsNv?d>ycBEW>I^5;y^Fq<=v#-CxRAcyq(L4c|sa!V%$0x&rs;Rk)1)6-v^Q(YOEq zi7@cy(#9ncf3w*jdHdg9Eb%#;B&oXV;K<+Zethq$WJ6lO?31*BX=OR*nx-+6woz90 z8_M}(2P8?^H?=+Vuu#n`Grqd*$UY|rDm{E(CeIm42 z5{=nItxcx!DB&|$o(h#J{TI`(27$TwyJ+ShN-?LYJ><_*;%0x262HQ4R^r$BGnKe2 zFflUTOKsss6+|*?y1eOyQVV}+q^Qk}vJO^eYTqB2z+XElNxmy;)!F>??<6TQgH>7q zCogcC8^`b`>Em^gPh2a@EkEb{(d~p6v;CZ#Z>A2{)^xe?Qm$ps4KJ43LxF59yA)3> z+J3HijAhrk)(43)sYS}c= zai(#aWmiUUZn4sxrkpke{*07y^~&v%KNA44?ns(FR0m{swLEN+dJOw+Tq5}`%EPkc zAEP}aYuO>xmooH%2E@plfOg98B;mYMmPV-KAmLYOGD-gN7?eb=r&Dv`J9=_KM_@L~ z#PG4%?2wl2>ICqIwd0X|rO7M>u3(wvY@?j|#U6@Ski8t$ZbsuA)z;M#C8RV@?T@xR zklM1Ii`r*r_*jgdZrJKs61*o<`LDR+*tWY_*ZFnk_M@AN8q$@x zwt1lv|I6n2z|_NX&}Cf-WL`Gpb+wpNIjn40StCoTDR`IGQ)nhpqL!M7M(^D}I>mZI8XTX3;yWZJOs$fH0c;32vL4fbTvcz^(>y7{K9DFjGeWS{RgfSerB&1tL~ zL{EIq^$c)`f~Aa zz<-+Ct!4LuQ>+9(O1=B!>Xk0v^q{D|r+onRq+?7`CC6jES{Bo?`-o$e4Y!AC!K&@= zaU~#0`-$C^myg1ztc%kuYclce!O~T8)wt5_9h|sCa`9hoGk|YHhMn^lh=Q|FkiF}2 zQ_-vSqE~LuV22`^3$OEME#7Wu3kCklE=96XA`=3k3FFUtY6VBWS71OH${j@kgpCaL zSEiH=Xl{@F+&hIJo$_E=zuMHV2Dlm+QUg&n5W@iRmEcN%bRxd;n?NnNvnc-*sd|3? z0UqnX?9h3j+zO>jM|lPN9ngG%O@av9z#s@>($=-yH3JxfNb*-ROa7T7m$sBMBddx9 zWqZNmSo0(oJ50juPuD=!d+^8MmKwsWovedk^>{C~PLggzAi{H8H*wG!1Hq@$&0HyD!dM zA_bi>?*Q&D{^s8?xPh2mD}l%@Xo@#4V$P^ro4Z@fHnl7vX`4%AP-1X|o36wsZxsAi z^Y4aS(ZBo0;(LkX!q%Qh+OG^+AgX2W1F;5|NBK$67I3${tFG4m-o5-VIObOZZ$C-& zIRtpn0iFXTX}Z0jIFhGI#$iY|w1eiAP5z6v+V*)xD>xvAqCwOHmVaL;c;30H8rnOgvcT5DBn zZE9_fT052J>R)pCUoo@zMExy~e}=b?+L=ie3|y?W*{W%Bi$9&)j3AWddCH)bzZnOH zmgmyr&+!;sZsTMwvE^n;`d9JQgvZ*!@8KEKT^WeC4$Xor(no)v+LkvHZ=eA7As@h4 zf}n5WVeqxKkfm)3*c4}AB1pB_kG?6*f#9;jk7Ud0C@Hi|UEo}q% z;+=H^NnBsOOI@0m2b5z>@nplCwgm6av+?HnC?0Ru0*i!lnQaEr>D`~};mj=RoX9dg zDWU)>i;Vo^(9-9~ATn=dcZuFd!F-gERo$AGL#>E{BRY+Cyk}|NR9AkXq^{1(?OvK^ zk%G9m07nkL^ffVy$G2=nvi~#sYKWwZOkZqX!2KV{FY*RV5C?+|Kv9cB@@KR>GE)j< z0k+I>qQXzvUFKk;RT;0T{yZDaaZ6hfGArRIU!p^c`mFEBNCU`L5B~s06R6AzM1Z3b zj-Ek^*M#u_xU+niZJ&XMV4mT1rpKi2Dy3IQW~!icHFu zm=q`m(x$!b%9LB!A--V(Fo$*tj7QEPYxm$UXt>%iVZ0%_i1`hCd57`l;wM0o%063U znhUMkyf#c<7w-~@1cw#Ommft9^z7pQiQa%3=3yM?C~NcbB*2vC;Uz?r{=7WOQuDle*9FZzmjfpro|0bYu1%s5x=YsEe zp9Ic=?ew1DkI&E?e1j77WE#B{CT$Z$ z)^XH})hLBI8M%(VOOorKi3ZBzE@sR>>vqNr)U!8`kEDb%W`7R^!C&5x(m7j7=Yq~5$he9M z@7u~$u9#82(ApZhQyypDn!nLn%L$stxb?AQegum z!uBp9*ze`@s6|qyAOP(9F&lH;zME_73uXtdu zV`={pzOr^|o*gQ51>0MwcK+5RFigPluQq%QQW~k7FP)*1zvl#{2Vc#A@|Yu-he_X+VzpVhq`xbnLJ4J2R37%B(Vs441F zo1k3#sf$f9dnn5pweQagihA0&qDX$29ah^n;Au*bs#9KCi>t)Kt)GxR2aQi^QG*+i z_VLdsm)h>dwY>b}pHaRK`+pz)=s7>0K57@&sd@a=_YBf0fxVCWtEIy!ztpOU z#+I0>KK3qnMP3x5$kILoXu|8Wcx8fQJi&HBM2B2FbD}L!=Sc6xZMdrSV)YC%2rhY&YR3}ja!R_KN zg7?8|ri0{m`wT8ndkm)4PYB+)qEY$f(ZNCtG8sg?S#KfEE6@THJ(*zz%_?!LrF{q^ z2(mpW1p@~`wr}jCM)(=XVCYZn@GF7C6FhP6DgloX@F@GN!FHks`#JRuL1b?{NyW-t z8mx-XP?m`7pHUVZ3l@h9CP#wEo}!FIXG271R5t543qL(0=-~36CMx6f(zI#c}B17OI zC_t_Xol?-k`)EPfuPn}Q!Y8O4H!0e;{WCBYI)Otb;7NzXKOVS%BA?4|fXn_@1`sQC za}HV((m+44-_j2({I)j#-C!|h*`tfV@!+w%%tQ;NK82=NPSpwJ?--lL6VqMcw1HREAt)y*8~qBlZa95FdpuN~>v zD3!)Z{UbHWI~&L*3&#l|Kn4zjYk(~HqF`B+4^068WYFB|CM}J8#ucm&*7y^!-{6D~ zTn)`;qs<<2;Sb_8(Y524{Lh5QRv+Pn$FaPUP}f?AaxUY90D|lTdqTVPxZrZ_1mhGk zIEv1?Jl-b=&zoR_5v>ES)xq{5nj(AY>heJ6l`uEFX;w+rbySRkBuwBfH#3nHg|2t; zzY6-$Q{Xl>x!6M^ZaVEagZ-|n7v$%eP9W<9k*b2hrIH|snWqy3@pnwR(V;vBZN^Q) zgzOr9UPk#pBeDIQr7eir(t3%A!lJa|S=*e8xj?fg4>`QOg7jnj{1{z4*MM`@fO9^9 zZmZ5AxK8~U3D18ZMus}3^+AD>zyzgf$Pkz*ybnJ?QsiopFlHnH5(RU@#=nkLwOtpEma>wV5|^6`m32{?V?J344ZBEZ=JN>mPZNy=qP*=fvSmQrW`VK0%={8C2KAFf zKo-`oU@Hf#VyGZANZ$u-S-moBi;?$$vD;_}u7Ljs>I!w>815r_>;5(RWDq`B9{5Aj zaSrA~u9q?se-v~o7N~^J5Q%*20|ZsgWe#FJKd)bpPa=2g(96KXh&CP<-W>M1+P)T8 z0^?RLjcXJp|9O%mPTu3>Yv59F*-!R3hg$Gd&OT6UI)Xtz@~bs%{K=KHI-u4(U8vUV zQF_ufPD&-F-x#SW>%kEYwT8^T7z$*gXNIn z%t}cFqy9v&>rbMq?W^(5e~%&1eY%#mHq-}+ruWMzddx(X#!eQb+{Un5dqZ?m&!fFT zbs9U3VVCxXK!!Zl#a}Kb7MKg#O1GaZip*s}8bojdW?)rTB3m@#E@$s15O54@qFB0! z6~N~w+fPO^fppzrTzo(1!^7{O8-u}@P)>_#X^z7!r(7@b)gVH$L*~#-SbsM#a)>{l zft$Xf_yr9op}NNT3@S!*?INaQ>)(uU(D*Ih2T-08*Pw#P^)#7(2++0JnA+YA_~zor zbYVy=9+PV4fKD?X;4bF=tEi3SPhFx|%v%8=j+Ov5vG>H(4mJzM|Hd0KBL?1_Pz)%;DtD9(XzeZ-(UlE6&{~l2V#Yx z1a=YsbssczJVae?-PT^K=Nntl6b+;k%Svb6;8a-5YLpA{K%QwT>q+F7kY8BjYw%Ea zFCZjw^ZM!pbge^E(f-IJ9!`;uF-SXjQC|E;LORmB5Ik;DXLUcCrR_}+prhNO5Kq*d zDe7KMaxxgCn=Ul@kKx*YoNjADE*Op^Xw|(!-0Io*4R8kOZl8kYn17Yh9LwCg9CpwE zRps#+Js!_jU~3lGcu;nt2heAH29KAMJ@We+BxeA)!B%D}!Yx_*2O4%b+P=iYbpy32l3e zwVXTu3(*qr-cH(0yomLZ*KVQiPJYCPg|=78!?PBgj}TV&Dp|HXon&;`tNbScJ>&^C%LN(! z>nqrag3RWLsL}ik*`Ia*Q{Beh+FK{6rue^Yd$Jvk6A-a+zfW z7k0q1u&^v)|3Xxdf|sck!^2ldktTp1%b0)FgKAu-(1X^3r~v{dODT}HW@s~(V(i8G zuUPsK!AL%d|Nb5WYzY=Ef$(!f0Sa@u5H3b|zA^N^4A@w&>nhs>AQr~VA$|aLlfF0E zBkCyO|B5Qa#JmD4xRG8|G>-DV>*hZOD-!FjScBBPpO8LgXjbj#;66UbERKg_TPM=Y zyAB-+mRbRTYKmN`FML*DG@;$W&YRCq(X-Q}2cTJwEo_|QKj3d-S*)nWs9)|^N_?Xv`1P6&t z3YyD^BbTFSP+kJnOa6|FSDhrjfz=OCINw9?7=d?I7$dia_lmU47MeZBf2Xe_kS+;_ z6|*vj@)v7xS=4dZf3vd9EN#7-8625`$!>l#cq}^%!~PGzXt3C-8fzn2S_2?iDJZ#) zb%|j`cR@;=BNyo$Z-dF9c2I}yryzGMk72x^+3{R(tO#thLwpsWPlVi1VOqENPf_|X zZ=uow69(y;^WDkf-Q*1T1newgbMqe_13lBI!A*9UU!tlKvjcUZVN!wOT3`ap00>#y z#<+{K_i1ub%P%6km`RkklJX*yWjf2S?_Zo>I?M{=`JGw4O<611Awt$O1WZQC%zOGn z9eW@XfVd@_O>i{CwvDGTGt@JAV@lR4eia~z zTyM%dW}66=^HW;b&AHM*Jr~cY?^oM&HSYjoFz9JxB!B3A#0p(ydje zTb*Qk9Nk;XL4Wdmwk+ldgL$0EKkvBEjL8DSV2a~RU@}OF$I!RrY!P$cOzX1KL9#So zn9&`Sw?HMPcc&@x(Fk!C0Jek70D{;OMMn@L#x`0<@PY_2OzRxJ4J6FsaJ4;j**64Q zVNk_F7#JH?9USDy@M#q;pAi9Vq@QfT@=bFdT<|ow7eP;0uF@*7+MCEg&iO|HRU{J+ zg&0!%#Agf16cqvQxCloW9Zo6k91_W@3*v(-Jc$R*Qy`Hlf#!+4@0W-=6S1@0gvZDoSTs5vl;=6ZjpO*Q#K-FhSD%!x z1jiwa{_8h{T!>`Te3;C7p--FG$~Z?nGP+s;qMreZ1X&A?MA4SKAQ~Jtq9JH8@u$tlNi5} zx>z>sIJ4OpT)45q6Ev5yWBe5meKoX@81nL`XpD*ZgF^~rs9ic~=tI{`1!=w4!yI2CTNV!%nUw|7)+~o3kjc(QH3=4ArY{X9MGD2BY9Xhl zu_8IR+=^I@V3Czo$V*nbcnqi}asfSd@mJ9Y8h2tt7`*z1Pz=AWW}s@Ah%AgC z6s){SSbdELOQWoSEtWADU+!LwiN$?s6_}E@jF3M%qdQIKv!-Fk-_Xd%!3qPbi3c}Y zSJGM?5f2R9>gwQKQFV*Vw}BLxGg_z4)}=8A``0Ie(_G;WPMsK>3!j^R4s?@A+`c18 zoEU^b?=NT(w#kI)IN9<0hDmOZ2)oUy6ZA+Ns0*{aoZq%eS6sBfczyo?{MzyR z#t-?&B1O>j5HNr_1^L)yVw059R+fg27ktOmbTc@}O%xi+Buw{}DZwBhHGRTo>GUB~ z3J#n~2SrFRzxP;Twi0n&gyhlu%^u!!5NJ424Sw~|sH5$dNURRZ#b^`^9KBwRXkOM^ z4|Za!cO39vg|OaaO5o4-{Rqu-9H5Zr>E!JFI%Y+F_mQ}xt06BaPl2UMGo}V=5Y%K; zk&e=l|DZl}6Y+OJFFQ{Rrwq$NR)`lfr?pSvg1XFmJ`#-wVsYycSaE(BvkXPZB)J38 z8>oIEt-?1W%p<`XZ$YhCbackZ0q5ql#7KTeQkvQOVv34DyhqE(+|p;$?4wDxkYd_X z^Ry5Q;Y8!2J5GIYq{7#|6ynfr-w)Hg+*Mxg@nRDO%!y}(kPM91v%)-XKaR#R_w@qs zHnd|A_<5wkpYkqjGms-0(ceP~G3xvyM=pXbLgP^@qkKOF7*s|b$Kex&_ZD0;i@4^) z;F|NmH7f+y{Fk34$%-F+6;p7L&Nc54T(e1h9OarrG%oAlDZscyCeeUQV!a>^@Jty@ zyt>*Av3ceK^qx#S`~vL@7x6xH3)H3?8h62!Li~&W2qlOqv<_LZm@#UwGMYYM6zoH_ zJ&GzkLQuTWM5yT;*4A|H`U+l0WTDq$R2+8!f(>~h9` zDz!*bV5@K|JGq``wJ;YsceK(3CKtVPK>xBzd>P=^j(+JEZYNQ<3ttrkav=OZ(9i#l ziW8-&efS2c{kv204Dk;s57K!b9+Y|+(opw2-L{P8k?5S2IL#S$(d{jT^-fcC4AwT> z-d>pPaLBO}`~rF6_gP!EAo4$rEogZ}+#RG*C&DrZ;v-1fhp4crx`s`GvKgQ($SCHt z!K&Quw5rY4j$&JPakfEi&cTw_a{gNk4#l*Y`A=y;SLK-y_Lf8AgRr=%_&4L9LgZ}x zk!vwA{dp#e$HD>-Ok@r!M_H3voWmDMn4)k$Y1yZcsumL$n~bcv;B8)4QK96)r>OX3 zMO;=MF*d|uef>_5@gHhnhGT3YQF{!6HjLyt84sPjc5o~p6yt7|{PVR`Wkvm6%Nx3N}nUo{dl4D=cBLs9yvkkVse+(;@ z9lh{^^s6eW(F+Y0;uI14!x)QR8G$;}E`hM01He*R?cfOa7^B z6Bytsd~^dJLhlC-b-6FPX>=u;=3|+`cyGD|W4$1jK0mXbTFr1h#2ulB? zEM>lYah@c&iHEBwW7j-h5Fk`&v1|uVD@^iXP^wxa!wmCR!mkKHiKgRdS}h{G@HR@5 zku;1X)~8lrzkq)Rd_&|I>OcF6Z)&itA6jL$1eXBvBOZ5Xhe5r|8W{uLfDjvrmLik< zuY|aQOe3r`V-ztE7@HVsR_bYNuptFWK$bpSCL|%c8%dZ^B5Ap-3CjVZPV|h_=yU!W zH~$x?t)zQ~{4OS8)c{IRl?ifs30q+e7Rkm#;OX;P9J2$rY%ek_d^B*AW#?UKmYqe$ z@FylEG|3pA0iw6~CqWvsLRl@b!I`X<n;bV?p6Sgif0j(!s3QcFr zZLHYJ+&RVrMyJWqyLkeXYAB;CNpfHv3+{_KA<{$56gEXB%BX;Jxjn*~K|L}WZA9Q< zaOwib#|`&;V;J&sb_{a-T6B{2RaZNX`CY*!;~ia_??ly>oy*L-CD5Yf7rmC9OUBh3 zdo8=dq<-YX@^V_SR7c7Rd?Ei{U}~7`K}nZTGM=s!!mL}KFGZLL?oNQF49)(6Nb>l~ zRA(P5gKScr?^pRMu)ScY%IC!G&sDyq_xMWc)ymKB!D|%%=X`ZlxaqqPF0#coe2fJb z&URd~Jo*t3p~S~q9*3PzqAFPTd1Q>^tfg%q-iv(*k-&-oUkxE36rjL>nOXT3YkH{Km|o@rrqF!*R3y>mnx7PT%MOZ!;^b1l1;j+NiJ8)I7U=<$zzXYF_Orzx%Br;*oqfEh5n z7clqJ`-(#w@Hkm1h ze}ZMF*=~6|!_gbC6gvXuz+c$Kdr7A3?{ZWQ`(sN)MMaUrg0-wkA&fDQSYXU*JYgvCQx&e0vja@)G=_gRfOLRq1&5SHsP-F6E`JivT zsB>rL{0Y!A0opMVOrn(Ksji3-i+Li6Sc&ix(*vn4JN66JCAn4L#U&(_fy4`}M0UaL z){qjZpxiHG&9Y8Nq%9~1w6qS9)jWb3M0k*C32UT6R-bzxsvb*&RZ6;nmo{<*+fPy2 zAs4u&zJLlnw&O1t?N(?N$Y5#z6fZu+Q+x#J=Y1UdfQv6%o}^DR`q43Md)MIGlq)@v zt4-v}OXjk)|CqkgqHYx`aq*8n1oNnnF%}G)HK6LKd;0)J5gY4*y~Q4~u|pzGbQGc* zRKR-d`_G@4q2bZkV??3$UKE;C#?|0K>O@dskDn!6h`w0||jL)Gbo0M%T?o ztmv_J(Qv7ZCD|=gX#(q;Ok*oy9VgKG0hVQ_DNX&-<|~=#Ykt!yy>7khST8Z@?{iMF zw3Td&X|$;F62X87L}~`>64$a4liE%Q17+FV+GaEBO*Hcqr9xpXkp&2E2)r!N-XKJ| z*a}3S36y$lVE@?If%yF%_jcl1g1oNR zmD7ZXO$k3CPaQO5w+Ep{XApyy+->oohu^;p>Ic?9VP|Dvl1>$&KY=V%c_*sGGR{&c z<%p04`KwKFmv_C<3TOUjI~V{P9nQJHfK66qd3S?zd+3Vi9DyT!@{d^SA-^ zsLir^J9Lf_RYFQYFSt_f$lM!F_!QL|4z;)7_P{i6iw%76U4DliW|Lfz4&6?@H%S0Q zZ5je({Qch~@cUB)(}Bczm!1&gPb0xwh_yHLMwV3$sLp;k09|l~B|U(5z?NMlY}rFU9j$d&iaQgm&p%I3T}!D2BdNK1Y8$0aA4wgnOqX`2QC(96H};S(EM-@Z z3ZFB%n;@Ts{tvkfpIM>0SSB%=G&~b`kQu(amG!cLJtPOl`f|#-cS(xk{pI}VkBw}3 zNCGn{wWR7&7+-#|jg?s)y$!G~)GQ3(RcyJ{e$=?!mVbD=T(G1gvxFHPeGO*~#R%_n z80)5lY6}6TH5I1a>5{P}C5hm3+U)yvtvEmIFHyJw&LeIJR4Yw31EM2}Z4rpsQ8+MY zarU9H$vmmXT2N-)I(}PvL7BNB(@|_|m=PI=TjMp63;>UDNiElYvSHk2*%9BAx!VIE zA`_T1M{R}*-Db?UmKU9+Fm9x`bUCGZT}G6#!{i*5OR ztrtmfY)XHNZVJp;X|t(8c6{EL#*VW)t;Sx4%}Gqc0tW2VnK%dl)@^RmJ4!1VTO7zS z6J4~6TK#!b4U28YxN;1BZ+x*TCnP{M6%bk>%O4BCB51&(lcoIuJh7gvE|B#`Le_NT z&K*G&Uh+m>;Mq$T7)2AafI`^BQ*J{CVH4*_yA2>@u&X5g^!HIR)!%bQz6*K~h@^d} zs=cR3hGHRU8*%E!nGmH zC;LgLX~cGYh6-0AdT1$3S}?_B;MEA)BFO^Mc)e?&e!Fo+_G7=V>D zS!s&P6weuxH)qkd7fUz#5HWVt$-3Ya@d9lNNIy(EDw|DM=o?4dXk0vRukP0)!wxz) zr1Uy*0LAQQpo@tEDAq%Pk3fpnkac>6<5I&!cZG0(kA=Dnc5FWvxdjnb@PEUPf_HGl zG5ib)`L2j$IW9F$-~;q>9UW$IHPvNAz4yBLyL-U4u_EvlV2$`U&Tujl6P|-LZvG#O zQWzM`$RSsXASK>K*ljYafgL>=y%^Ar+R~olyEB1F{_k&-v|R4uk06+ZKdry5;@_gT z?fP34ucNoE`rB$=jyIT)FmX4)5U>H`N_^?&HCn(LbnYgv!&EVD{wid$>n&oLssT#}pRvFWe4^#*E730+pzb=r2al=*1?OIVY95V~E^fB} z8lc>H3Bv>Xoh@GtjaBax{}slJEw3UK_GR(7iXNX6k1jmc4t^icKu^-+;IrP_lS$~< zUR>HyQp$?&R!i6W1q&qin6!4)zTitHSL8TcBmK@VJ!@xtdfy0-YUx6rQ^7+eA4 zoKRPAG5Kq9MG>qs(9{uf$W7Y^^kn~xL<2MeSBh4k1-QCPN)5@z{FX>!O~9%zH1U7? zyQU!rRG&rE-erHHjWf`XdNOtZGyF5`4^f^L_2P94r3d3 zyx}1Bv%9$uaKYOpRt_Kyu#MA9PPth+ey`$h(+UX|`l5UWaw_-1RqG!7Apt|t_DQBB zz}b%o)q=A4rh0-J&R#Cp4!#dmj8qA-8hNG%)KAW3kB}5BP7*?= zl0>dAx1a0g^S{u3PsYt5#yeIXC>A z7nQ>`u)|PYdv6^%ee?mExJzIWyaU_%J<#-^@Y$H##;1wiTKQfStRC6akN)#lD5XxV zm9c!BL^|+EXn@cSZZWVkj(9_vq8S=W9nH4QMMQwht*q7-?oTuJ0%ZOk+>QAk`=`+t zHzFFspQIu1=q(qZE;n{!TlP*0EU{|KZII^Vk=2>xIwb2YLTwspn^Mp|T%AhT%72Ke z*%yx!qVLL3&ZzW&#(l)D`DExGmAr~Avz4f2*o_v4Q+*Q$ugH!K-Cv9|vhN2!cq+`okxZ!2M!N`M z)b+(LY);1<^w6rI|9bu^v|;f$g_aCy@eeak%cdnkT2xj*g^?^!#}cTGv;YnV!n0T* z0K6NOE*X9fLIrI*=Q-;VBZ4J#aT?jG3OGZsPNd6h|4kSsQMXyt?Y{~n%6q|WXbn(b z7M^>v?u+1sSZnn__WYDSyCY-y(|-k{PlxKy5Bw5CB4*h??$ie~3#fmWcZyasFYDB| zuvV7nR1e1gD>_BoPwyV;%+ip(m7MXandvp@pP7Vv7+K5hYd2r1z+qhG`){z6=cwI)AwhMOkHxA{{O0 zOf`i#LXAU<2x}1=u!syX@H&99s1oPUtwCl&#r|&!ROD%a0lt@>M@ZquSqZHD7@lw) zNqsw#NJ`o44|%9;qxJ7fjCpeXTkMf=iBs2->)&+1$jJJ)rR@g1()MTaMPu0m9C?85 z12~+B?l_@Hq~oxnA%x0qmVtjQdm+HrjU6Ta9ZUO-sDl>1rxD<)^Hf1#h>npP;^Xy% z6A_~tI8hLYLaD%>uJ|{oeM3^4#nxT0=TWBC8rggb~SJNXwdIgZ2JJqT0ThPc^3Ap@$0^69@pESt~N zp_$A}01e;+Y5bfjfgXXGf(YdVKYs7$NfJo$$&Tvub5RX=QItPE(txE6E3ki=V$B;T%jExTTn3E%Hq6{VEYzytisjfJSDWsS?DL? z)kG?^h*SKR;6p)#m10mX;M+U7s9OK*!6|8J71+%|D;U_EAj+VKi#Ly!`3=g5jLC-h z0g>IRzmpZ(fsmO*3qSf&?q|Q4`|F6tN#tIS+}M+MiYoaNlEIy_pqLWWzXi(};SO0d zi5R~m1)zpM3EwATNBE3?$7~b9*5CgvI)f-I_^09h5U~_Lgz*@x!??s8C$ORcVLWPr zKvRJ41Lxd9c7cU{4{A2lUldmEHJ%ZymQAuD5Yc@MBK&iUmlRf;m$g5`14psl$NF{S479xPUOe~yV zY&vq<(MkJeShLA^(Qz2=T~X$FltIiOf(8S0QcNC!RxCqP$2x(lf-eVLfMS;jEVHTC zV3WoUgK)7SOd=V2XaPZ7#Nfd(p!~%UIHQM816GRGt2DbP_$9Fq73UfVGl5+Mwmn$a zz{y_GSv znJl9{VwH&+c-^wAkJb|KT#(Kg$%kqm(N7SMm0@hf3Hyt^U z--&?2Fi$r+U&n#B%UU}(j0gK`+AU%8M<9dN8U1sFgbNrMKrBNr#wwGxy^J84?tJms zX;$`=WBtay^_@}%Dy@tg`%n@#hjJmaahq~MhT95-zZb?5b~zaL2jvL@R7lG` zqX3&z0Um|6al#0s0?a*;aryD^>2$cLoqHA&_~j)DzhNk!7JDQ=b zw5s!2%PzSH2AAqQh!(MP`5$81xL@Gf83KLTSp57^9o5c|=)u9rg#0*xA6ks}8=VK4 z^We3Wdsxl0=z{2rd=I1d7@*R-WO;Nw$Q9$jDtkN3Rs?W4`xh6gSg>~X50b$+f|A$8 zfYMO1N;osH`uL=)g@*gUKZI7m8V0Ddl9&!-{0LN{je50h8C9T!%^1JkYca}$vms={ zgX!qSk^CqTi+-F^z53*0${H9;bYp4z1FTQWuC~Qb>xdp5>WTVLXGKAsz_lRH!{I@Y z=K;qVi?WY~t|pqK&kN{@KpZHvt%52GF0q9BWp>O6(x>qVVPuvhsQpWo{p?A@W;DKnXwUgF& z>ea<*K9liCwOV#iE!z{Bo@fN^65jyT=643aPs>j}E9I>MR{*cB3>6x|8U~D=f?rri z#`vpXAgoVH_B4rdoHLE4Frrb-Igo$Eva6GofiZx?;ba|f`T@9@o(0Fq7fzqD0SEvD zyEhGCb`Dq`-H533RHh58w}#h1kcRs+iHCu6xza{}N}Y#AsAg155NO$71g+O6M>bZ zc~G=6_J7{US0H%eXbYNiPZGz3V6-sW%>PH*z>F~-6`eVlph8K^hTen#`0vMLMA`~^ zbiN+3jv@<0zXYeZl|8GRE);Ugpj?zKkG_pbz&hE1BZJDPhC4An%7HYBg;Yov1*f>Qgx5u;0GgD zWL1WA!MaCt?n9z3T3OBON7g|?UvEDb{U411BU!CW>pPD zcQ0|3dX-TdIPkG1)*4=l$AmKSziIYM!THWuOy`lJ$0W6mAmzz&3M{F%re z8rneq0X-hZQF#ghM^i$EGN{Za@|~w{1lxvciODe5;XJYVTBsbxW5y2=-h~v50s78P zY?mi=q%CnA3!FoIMWA=PJf}mp12ooGlwWz`{O_h5o!&kUqn75;(U?^^tfo&bokdc-bOGU`s*u&}Q@2c2 zH|DCP3)QNns%tKs(OAG6KqIZ;iG+^R=E(Dy&WRNl-HKxAEK)hJWr{j5AM zzq5i>+tiw2Rz1^AlIA?CUVyj-Rx^yl$Y6je56_kY6Ir#Hb>%Of<>*F8Wc6%(H9Ryc z|B{OO&cUtT6nA_;>RBwm$Jn`D&gqb^TB7{f5E!gg%k!{AMScW|L(r{ErFpi*J_y)w z_%rLtUp_0pIL{H@l+N6F#v`?sy0iLjBxsinDOqq5&&X^0(xM*$@yteRKClQZmQ%Vx192)WHg zNH<$%I<^jj1;sUf=XClo7gPj=7IO^811+<9*mtH{c9|C^MlHutGYr}T2Tiq*XvlSw z4zk*!+=1p0u(BVmq>~8;_ff&J*@5(eWwWqdU|AjlHjHf9Ot!@4GYJz8Y^?ewlRb3o zbRlLsyHkc$S{5yx4gwmJYS2XuI7D{8@}0Svjmu%Z!8d8#Z`oxtB!J5nz-^W<2QEd| zmf?>iOCh>AVV&(oErYkB09&%4U`bwM0<8Qc3ye$h>_?X%a@!;#|4BSxIeLKgPA?;; z7|ceab4WMB$nd!gJ%5Fc^Ck2ckLq?t1b_&z1ci_z645ts4Vb9CeWbb?s|Gu9hm`^w1=8$sVK}7r`MIyI5;WIvTcT%) zK9MPqTJpYsSQ0LQctj?o^f>#;1(z@ouoqYu2t%pW(Ah;_)u$mK913KQGfH*<$PyDs z31JRJ9-x|aBOuB61a^+nn5Q^JlGvf4%cV^vR7E-||0L{&d?pMS=#Ho%a#cRHL{yU> z4YulPKTHE6OX1SMctB>vQUSm3A2b(3bEA_2l{?C!4SPvkIAI~da+WR1B$S03-vGZ! zHTLY-kA^?VOeZzo7>1qOZ#)Y2kd|mnpMie`6278yq{ojmwgl2axmef`g8n8nw^s>s z(A(}*E{<=!ighae2BkOcSU>p?5RJQ}wUhL=$Q`3EnX#(yE;2e#MQ>#fGkW3no{)majlB{p4^eqUhMB#BL z3^5RnWI!OLu`r1r-GywU9ft_->?C(ObhIHh4USZPAH3Lz!&I)55=`9Ec8QSOe7A&X zwv*Unk3d;>m2xq?al&>($I>~gwc@J9TBZ^)1U^&COiFWAy5-RU6lrN*Em_*H7G$}+ z#T*a29HygI4i^;XHQd08OCfj06u5IO zZO@|8f=VkEH}HV9yY}6qaIhGnFwcF48{8=dfN(FkX$EtT-nh(NLNB zC)n~F;Arq!&;|7|u3WG+ehvQdVDW02OZZtH=<9*_;_dQnnlrwnBLf*4PUC<*io>H* zW}+%$)sO~{BU+zc9(1pU7=8(j3AkQI#VK%!qb0ESs(3YG9SEw-1XPa^K@(I^_Tu^7 zO87-%nBX(uph=8Ea_HOB;VY)7+~v7IOh!){Is^3KsEVg35OjYRqKBR9oaU2|ByIl+9IC|K-Q$mGU^}|+R zY>%HnD;cP~%zR_nu;5Wv1L7g_*j={#ZkO>8aE?7G2cj@#D!EyMOp6nTw9VF$4L!>JRyqNqwhJ5bjBd-$1{n_KtSH{n zk`D&)u$z9o55>WJpx13rQ#YFze$tTcldB>(TXyy$0%c4s3!guS^BpGJkDfngKZ;ae zx<~{4Ag&f4;Z5<#`kFvF3$taiacqU>9U2uVRL4FGR;_C7p^2bu!B$xljB1FUp3D$7|+a03(DwGE0 z1s+1scCa!^Cx`eGNwozfHf#uWBHU!A!{A@4ZMHG+*h2`P(03Ur_uC|9rxQMgQ&O?} z5({@L5N;h9FZMk?0~&;`fUrkYg&)u{ykrlFfHNJO)So1wpIKneTvcqb635}^xK(iq zEsJ{8Af1I5+#APL6`PFSYz2jG?-Ly2>??>jhZ@>W2jby(xx~?PSPo%v$8(fqX}c~V zV2Ozpm8dm2ik80h(}F-=0FfucN@ja$+Yoc+<%{ES>DVZYt2nvfB(VV{ywexbW*g`Q>eue$g{LPPSuG< zm^c=2yS$*Jqy0SU(+?S3$OGdoJ3IBx1{an@#s-_>3kUtiVADm*u1xk69nGh<_u~pv zFMJvvITJpij z_R>&@*ZkwkX}QBTADbcb{4>!pZt9TrHwgYPhnrh}Glx&-P`<$obPqcZ1b z*1JP3lz1grDjz2bzHf(?K+~<9XCQ&!iwp54uk;u)zKj;Cc@)dAJVKWXRPYEcF83?af3YKMX`}cNGcpy5{__^b_|4M8Cb}TbDjActq#wBJr zTFgr4O#Gn8X*znKi*-gWxcN&MMbJ_1+UIl7_S>_7T;`G^vZK#$g|>7cYI$uu1}%yW z>|Hwy`UXK9tQE-YPM?ju2L1$k#Y;f$h~C3GcTE@1#H_~4SR4$v9O>1O!5s#1PEE_A zTTn!zjqdnuO-LcQE7FxqhQK%ZH98pAV=KmW-nq!dmtuAhy<5D6^m?oQ>d(hg`fO#t zrjDar>bM2sx=>sxqdIP>xR&DDolfmx`!;&(OXr2a+(DnoTT0aXB#3|rHL)WboxmQH zn&%hd*1DTs#D?s<-y|MaU(k_5pKk}g-=uGJW;Wl7F%VRHgR&)0+InqbEU{S}Txy7c z>RV}|hb&JbM3D#}kzr2gi(`$Ev+1Z_-FSmc$#Ontm*betB~u9X;>{ z&_VYh8y8KVdi1jYHWZ;h-ginJNlvbW&%7JAsKKTt>>0v<)CXb?x6z}9mP+rv-~$I)*X z;@sHfe=Pj<+W=O>*@bfZp}_V0Nwms(9ft#%Z?3?{$atiV9XFwbct19Ps-ooiBX#OH z8>$eebR?ru4`KlcCm|Jf?ZzlbU0xUUH+v)6PX;aTFmz$c8u4QRk^#Ry%lDw`;H^nU zw-7{raCa4ob@3w|fTL7aOnGXl%yD9<{+5O}v{8^Qqjd^(-JOdc-t5{vm97}7)}cI1 z%nILRM2(_hOrCUTkMG^+GxFbpSnwO52h%9P=oA8Z=;Tu$mUv0|)qf{O{KksDUw`ad6ewv0kWmWvX zO^_abf)72ST?0DL3Wt3zRs3QIPEjRD`avux0bp=AY&HnF$4Q5jkslf71fwsAm#@;! zV~_aDP>%H;Ty+uye+iq5*5bQRt+TzN*8wxQpb4GsM-A0U{cx!dyMFgjV~uDGE#VCm zLYx23Ik7PjXCpd?_ILTlR_*T^oQ)Yo2g+873jOvs_0thdfg1%=Yb_9SaO!S-4ld5Y z{;Y@n<2Pj~Z*sIL(H0kMPw?hKRO;|0;;%*6(froi?`!;4e_#gAi7`TPD7G!iP5;4_ z{;L;VOSdZ)T}L;|BB}5hgWr@qjfKAeMx@SW2<-ni@x*_(02HIlSVJ?gtHS-Z72M<3 zba9+*ywbyNhYNRXG9X<00D(G=vkik_;R%97i5x0Hm;Xr#Z%eRHTB||UVXUyLP~DJc z4dOq)A4a+sOmG;62#bOsf@FNkPW`ENqR-- zJw{zw7lrADN^u}p)2Rx}zp=CTKA~o+i2YoFpQBPeW_bD;e!dbeoAV{>9t)nr)c$PILjH&>QNhI^)3?*jR_Q?nif# zqBR+5I2vcsM!ID$a%C9tW5h_rEfa}G3Fka4@&b0{ut60M*o!Le@Id-9VW|%nIAdF9 zW6P@!vuIauk1Ur8Y+LO=-z8xV`o}AW(-L(UvAXd*Ap+i;o^-+uQT_{*az0F_?OED> zfCQyi*1w|o=vT?)f0%MfR{ulx{T2fjCWhd(CN^Id^<~sf?{;(tJc;T}dIjJSdIjgY za{UgaH&;)FM9`BVWUyj(!f_&S4eEKTAL*!UdZ(i^Fisz`LiJXH6SQOx(fMx$={aCQ zMD-5ABB8ns&O|ZrB$`DuLBZ5M@J6{{;u<`RMzE7tA6js7dHAx za&Dk&wwJC~uchm)Yv{UYHLmrKXLpF(xUJ%Lb(OfKuM)QzE^+(X-MBsbz4R8`m{WSe zFYfXSTW}9LO)s?3yI@c~=^-%$2I+bF_`+7a^`vjal>qNa_lvLKrs*w-Cs@W0gruI9 zHoVi|=hnHfKzh+lBD zrwai{zruuR!rLHTu;GzUhn$BRfjv0v5%al1xjt|Y{C)6y!1GP<*hRyNcFDgUj*DPP zT2L-wQ(J>_E5sB?4gridnCy3hZy&@4q>c3y6Rq zDKeR=y2dNucndwD=)K2Ln0~{ApG5*rVu#BO1V+Du2&fRE*t$S)%?0?uYT!@uf89%W zoHb|wHR%P5=2v99}5MnO_`%g$!KMgLWVrLG--N)k5#!ugZ zK|mw?5E58n9)^588Z0pR%{U+ndemRR@@v431Vf?WrW2a!a0Fu~PFo&_fn>HTn0c)6 zkTDF!8&&BO?)KI@Ru&cY`zN;EQMLm2v8{IysrjcwCh>2gEoDoN)NJA&JZhymLLhhX zI|-^SR)*bix6|WB8^4nPMak3>Ma{XlJT%* zLZ;GVRX&U6AJ(hbba(K@JmirQwziU1rmzkS;E|tf9-s;5#iS)H+C4 zNEYG^zro>>iFO-rA88XHH%vW?O7UM}ReExj!Ca&@4xJCfsVe!u8Qf~=ANihu|9luo zf%wCT5?B<_-vXbXr{Cv=A5cXfWUN=555pylAI?qo;9v1of(B3`D?Rx2Uz7el@TWwj z`ZS@XgH`zXRysEU=PXpN#7Ums+#ihSEP5ROJ{*W(TC2{W8j*E2t1{8y2KFnlM@_kO z$D zm;nL=i4p}BiI+$~69+UwbI}q^jO3ycY^yP6uoht^U`rrj6U<~gE$zYc+EZ<@Xp3#F z^lb&KEhYpLK#Cw%QM^P;am7h%R7`?K=KVfv&xD|DkLQoy=Z9wY+H0@Nvz~Q(*0Y|= z^R#!NS(_@B5SDr(jHOE{03vmfPD8VTaXSQa!!qx|tVrb0t)gr{ zv}Ty^BRk#(8$kFCI@w+lMk2?fDH#5s3)#<~f%oc=H@}3BF|Z?^t2fDa(Uiwnoq0aQ8fD zsuFcG+8R|N5U)6ia9(k*+eJ*fF*hCJClZL~NQLIw_#&YnW=kAC?b>M#!t40o^iM>9 zy$Tg&{Ym|e51J1pW}fGj`=-Q9$Y zQZ*G{Cm2E8A4+~BycW5G+$>;JiDIX01GUl;4pOlqlazFM_-||nk4i!@VK}-(y-Y?U zSvQIkF=o7;+d;mpKDJ`pcToVn&5AZYl5mi3Mn|gAnQEL$U3QdXyH%{%!-F|7 zTfW1eNMF*CS`pf@i8nya791t(tRJL`OhtIHFhzz&!~XqcrO##pQa2T9j#~6xmL(yG zuXO~MxE7X{R8}pjx|J^3A2}#1WYiHsc&V8bN^A0bi$2KOKHJ~xBGrP&sy;tve}-*)WT{>h zIX{!a0HVCMQgW5ZkAxU>U)NI?D-mQP_mJ&x+@MACdw^=V9 zmUWfn^2?8eIP8Y?6xFq|kN=a3rw_}$K{7VWkA!><^`sCy(dLc^cQ7S|92laa!n|1T zv#eqs%>?TkhL5jug-Iw1p~=HcrKsK+)~<~bNUB+!hW3gTW_^e&WwzG`!EY=jvPhoR>i|D9_KihISuJ04|H zz-@rgRn|$BYr9ezLp2jvL68TFMw>8@=yjtfx2&<=IhA=G7`x+lKg#8VEiF0EqLb}!EbfN z0BQ~8w^{(vTwH6fn1-gdGzA&z0cw|pS4*ZqrT3_(g*411Vo)#9(o*a=aA%n>*Nmc_ zs!NG1?5(O2Hkwg?%;ukXhk3TN9_6b=t-<&^g8B_xsi$imu$U;AG|z`j1^U6EJVU_A z@#0&9<~Ii;UD_bNtR1p=++k{t0rHkmnmJ&E6QK;HK|Q);&ykyxTnBm_g2Oz=9p+pO z&Z2>!Hwp~9(q1eL$#feO1KtaqSqIb)&`^`Lqb>zIR#GS?p=742$4ATRl5R~h$`m3m zI9fd}g>r!-Xb-=xk~!QE6EXewy=Low;bVRhCWbzfk^QNv#Y-bq7o~eN{Xt zeeumSb4hxni`rrwFfrA@I2hCpI&}xgDrwDYwbZJM1ECzcxPa!|HEi=BSW%8gdohok z0{M!s1@lmRhxb&S(>vf^yHWNpNZ7UFqROH_mk-g5?zP_|w)?NZIEU$9(Kwjg4k!sLomAe#ISHF` zh`(I`Y5eVbGDCHl#z}cUT;smV{hABfcnWV_^0rIfgpU69pXIejUca3lT9dhad#KLU z{ZzE*fl%E*=z6K;-Ug>EMI2Kl*-zgqrQ@fRH<8>Fz|;g$ls7?-d{ z5IC0aR$NO#TytSuENy7k=+GRut`+OOGNi22)7p*nQ54b?^Hl)J*w<1JX)cT`xY2mu z9{5;iIfpFfAwcE3@elMI9iM;v_#hzn0@?=KPjhWf{!;n7n7>$^Ed}w-h4I5+)9+`( zWu>v=&S*=2r%VOE`}zepN}H_Lz=zh9T2|Zmj|M(BejvYeS0aB4Jp7cW^HZJ4PfadA z_muEccRN3;mqt%uHRns+Pn&v?2pxcm{5y1RzGGDIx>*VH7Lj zc-9lM!{~C0G}2#b3`66cHEr)F`D>zQK0Uq@WwgsYE0;-G&2-d9*c)YlRuDy7h_A`z z=bjRN>Tc&}^-@0EMI7zpX9aPzFSJc?W*u&C2y6zn#dPia3GhSC4e9gcmm3he?Y9pmT@p+ zUn$~kI@gGn3C$CAt~M11yN{At!&t$J0v}g(cgr&Gsm;uV=hFabg=<4pYl}4FU?d|m zyUOWar%#|Ic>2NEbE-S^k)YDa771B}n+{j}M=4V^k$nFF z-;ttWCo5O4u~%}8G1jCw0_CVH`%5Js7ua`e3!cHma4x0iEKUtfr2}q(QpBP|iY{AS zuan^5W203_csxn2BuQ3Z5@u{~#auG-7F|(x6{>Bc*xKasGCWpnu%5|^Qr}5uONL`s zBG{WJuKWD7*U226o52@F1YV6w&^f&9vz^Ea9+7}^;0;}GEH=>QE)TiS;dr_K} zca#%Gy4YxOsv_o-uwe5q<{%Sib`n>jwqk_E3{1m{Yao@gO@&GxNk11ja!ZJ)P(=Ww zU0z4<_>8?SN#xO`1=#Y8-Pv+>!Zlr zFhIEfUgU;!-(xp(Q=n4Bn&uelvnmVB zkctA+VwH@IRWc%43F4R(zwT`*@HZ5cvoy{B4fU85oeQCTLj8y!rc%m+OViXg*bP-x zuy1qC`V%=ecLBche>EA-~t?@Z^FfZ<0GSftc`Nh7;!z z?teJL6m!)Stltk!A481rf-J;5MJNXIKS9g}KIas2pOr@UYHmAhz*Hi|dT~l9f4`Ut z`J=Y-^^8~tS=b6U2~9%L)LeMNyKs-&cprpvO(QDk_E7ypOZKJs62znt0)rn`ef)S% z{zLBXZzYWv73lB(LOKG0_uic1$E+!AmZh2VTxKTH*TMWQll#@c8*-rN4b}e|(Opgz zVo1#lyn%DU>1H-QGjC!f{T?JRKp3Lase1v1==?oReQS;~@;NT$iLqTIL-WzfeJrpw zdN*%ZHw5Uptvm_!Bm~=khrs$i~W%rnc>8zy2nlxL$wZ-nQHkf3A%CLj*cx^H{$S~W$ zgq7>KqLgs4z_KE(Ov{?cfbicQzKH&)o0gr=Sp6Z`0#prfda3jXuF3$ifp`uWs(_M= zl~i`O98h2hPZqhOGIf&%jYFThmH}LQH){~bxDu7gu%j?FntTFD;t(Y8N)lPM`B+x2 zcG1&Pnb7(!*o76UT~A|AiSjcn)laBKFCjhIa@*7BrIH5sx`_-MTsHg`TgONmt#tYe zU<-NVeATNSCNgKK)1TGo)g11OMz7|0Pqlg?)(3JJC{t-1tT;!=*4#!*^`RAW)V> zvu1%efB%~6>1*s0U=YD*?Tv5KdmJJq_aSd`X`d}Oa1kdRu5_k_iuX`;DQu=Q0e(`B zy~tZLZi)wxlRsg!r2qhx0>iTPR_D~7Wnqm=0pS$SbFe7Zg^HXFIX3@2eQ^z3yZIB? zBLiPI7h`4yOR$-tYn?5Vu1XD@g|+puEmiibD(!*M+|h7TgM`5`*iZ@jqK1-0d^Mvl zDUKrIoM<}bd-`k`JMN3K`;+@@Iy%lDpVe1DCfJ&}v1J%gSgX}9-e)kYK7P&ORK$lD zI)44u=m#=FzQQ>^cFn+1VRpw{Zm zJw1kj;U`Qv-LuQt-n zsJ~EtiJC{Qm_bojx#~GStODPh+O2X~*ZCG*DqNpq!V}V0upGe;!fSw(5PuS?l6?tt zclrBOKg))h3nOQb>d`^A!QJW|f?R~D*CeXg!?Gn*3kXD`nr$+MWm!baltWe|ymKB2 zE~3Ign-=PqTfOhFI9RfNKcF1r_H?0pxKd`@0O=?XGVhOBBLt5+3o&5=9B;^0@p8@r zS6%LswiL74Xe5*~r6(3ian zT+3Q-!Zcu+DhCRPo2;XNcP@l!0OLJctWp&7`KR<%0#51Aq~=IxF5)Kopk@%!5O&3Tf<+$n2OZoEdZN-bU(+{@`LUbEv9X&*6auYF(HTa4t1U1M^^tV?LTwgb z(ahv=M*SZ}lmT^^Ir6(f_QGW!j?mwO8o-+RT4_UprnG?^b`qJTW4w!J>4tZRlc?3y&eL4I@wx ziV&fvwpqqo#T!E9$XpX*a;&vYo4puSQx8#CBp0UWAmZ97{#qFL?(hAa0`N!fT>*9X z-x5ZA-_?sE934YvVVS(wVe@Co=I2yhy8B$U@jPXw7CIyTUWdAt*D!|FdekokTSAjd zC789N+VFoyyI8v~rQ*O93^)LEsvlUzeYv-fsUB<)M{ix_s}%zRCRaKM3zLqQY%R#WThY-nduuZIxniCkWy z{$?FHAZL)%#{SKTBG`R8spxd(t2M8N<9=rsT90|WODI2)e*e|t>K05vwX5VPWV9=G z0j?eF$kND#IIWdCagVGZ0%LBPX02h_p9&Lp?1-*pd^LI;W$?+PJXX>!)J1w=tg5+m zADhKNjPAaX;O3u9VeV8;JqY;+UkBw6fi zTa(&-`T|a-3>E=W>FH~x8vFAZMpUl{YU(G7$oh_8g zEgN-Bid+c+vsvC@gdSkO!P=^`BiCUsS9cMMAzBWa+{|MxyCctGTRe0N-ylXJMbErzY(Ao{44q4>U;3p>sovSwFm;;}fFUa9}jHJ5{%Zu)Hi89I~2?36)!768N z_W;?jlZ)lrJ(2D(FTzC@!8LJ)XWZv-x|_BW8=5yQRC0GXF<8kUb|HGzXI49RaM*6m zv9BofU)`7!BJ97cG3R^oOmEEjzC6b?<^Wf#*&g`Djz)?JSMFFZkJ256JZ{*rRvy>x zSSOD;jX6Jz7J`JCE(F3}7xFYu3ORcRwb;Vrc2J2e?Ap;JkAxj9JaF1q;v43k27|%Q zum_KEwDPpnAvX_Iw5!|AB#vI@JvxhG4=%6`mV(2n&L~Z%3!`a5AU(A zZWgucFD9?qq=p-Fcn{Pm;$}moNc|&<%X$D}TgWvGL^@B)-dDlv z*EwWmx(8^hHd|%tNTUbsFR_oW)oRk{(sJXoIR9khGrNC+`WphxmMQ&n2$>z-pMswX zwd8t;f6++YnG1AT%wF~IwKm(MzrTkzaJruT7y%i~$mup@BY1CiAd-cBJBB5COoKRl zDXrkJC8sx`e^0=(R}-1*a#5E~D_m-c)XW`Nc6g>`SaA?~XE5YgkEu)yUDlBkasVg; z;=I0S^A>6}JIwygzm~w;Vf+zb6nubuin|Ig&P^{ zG};-oPMxlqdl4CHxaPb-^18iS&qFG`koWNHNIWY_LQ7I}VnIt%VRQZ|_f8H`-mg&$ zxn09P7q;Y|;_x;JakF)M15eLM_dDi{2s}ylq>cidCEITlfw-0=Q3lxsPNE4C3R{xG zA4{YV&@4-=Z_eQ5vi7q?<)=hMkyG^Q3UgcB4JU8&_cxq8FL3D=dtkDz$?s}dV?Qqt zUl<<6d)7x@KYt9J&WiTGkm-gw_ z>6U%{hG@$Qhczt{U)Wv8aI^}vQDFBhUI7+718+QPh#J%=&gHU88`HaHc@8TK>Cn*& z;M-;S4EIyTp0?w0W=H=)>?lkQIYz_LQeLz-aJJQ}zE6N*$pIzK(!!7_xu{z_eH(L& z=xZAXm{x=r)DH`wZc69}eaW9-ych{jD_1kXp{PaZch}~d$&nS=EE%~@pGo0W z`Vs#<%#-j<<_n>Fhms!%<)O(v(D3P8|CQ3XYDvvyC)m3jolgd761*~637omi2&gq{ z3=XOdk~DvyD*i~zIE3)FgvV59k_F%ce5$I#cDPOXBp5X)sPEBAV5@P#Y3&w*e5#sa zCRUiI!m!f3B!{1CCJADCX}@F_p@Zn+^kGG4HD;OEixF+m8`nZ-l}Kv$Bfp`YaGu_i zbQV@c)8)!D1CDHFS*7w@mx98fPk*-h%F|`fyI$vHzFETP)iqkrm zk=c?{T_)M8+PfVvM9m00`ljnZ&TLt@SauS2RuY(bv?Xy)`U)o_dj{v@$#c#NB&<8? z-iUrFv-H728pzH|aRPy&;zZB&`+btBox)nBu=mBde7<)$IByh1gSMNeEvUNRrJD+i(I3{c0@Qb(+JPmcNpD*E&#(+IA zsOIIhC=o@c%keYpRQm|y zXV@i+E5vlD_!P!JA-zNS=*F_KRWcRf1zG0I6^x=2C|>}i6y=Y@T@7WhJw~flSw%oI zU>A+AOk&O4TJ#)QY~`?UhQPSwTOf7o56#eT`$fz=s;n_<&1gT1n{Wt0^hy~m5bV+Nm;7L zF8%Iv)!OnHg9!&>Gv;EJeLZ7#rY)Oid#>S5D1pGS##8CM`BTtZ992=SRxKn^RWOn1 z7k=lfOsNZ`R15jcMAq-QfM>&*Awu8V9hkJ?bSomgD4x}V#oYVtQMONXV7d^m7t+2M zUo6p~CZ7EmYFDv}#dbxtB7+gN(21p3Tc!EE63*v+ZE@&OVM`<8&tfya!uZtgpJIF( z2WnElM-(;lFJn+*`G2DE&gbEHV{cqgXAUPB$(w2rQe-WE^lsqV5N(2%a6C(f@p$!T z@XSNx9gEQ|(9Si;ZAW@IkgR(wXa}LShnVJotQLWyt@dFWKa`L3 z@p5M9_={Q5XHO#tx5SEy(@}E53hEE)L)Ndz?5#RNqE#0W47s3=69{yDLJ*;YY$o=) z+nj;%70=y%`#c*<^GG-=zad9e@M?>$tj#t6V-Dd(K;m$ zT*L_L2%p8pfQxh^)LEoKI3Pid(TVl(10w5oS-zS&u^RBq`Q%tCiO^1 zg11P+%Sw;&^Nf#L99N^g01cF&1wiog5E#`wQgxBYcV75KJQN&OPk(K=&`0?4u-dSw`(deDPGXHc>m1S5HA_s2 z81<<_lyZPd2{vu|46bjgx{buq4QC@ViL651;=l|Rx7>Dh$z4-gD71>iY70~Z$BxjS zHt>daz)QHp{uM%ff0Ck%t~iu}35?nm)Cq!*Y~(HY;6wZdMm9hAFi#e;q>d&D!J9}A zHAz7hB%!7T!X-cy(vZ>3++&9xBT}7!WvvyttyiQv>QroQ_a0aySDdt#~mSh~aUYhfsKOe(yIhBo}E znIJC3Ox<|nQQ7}LU(8{IZ6)i^x2k*w+1_^QO+bn~{Cupaz|+1{s3X+`uUmX7{M1+O zhQH90(QZhVPW?CCa4zWh|Dzke#aJ5F4d49#yc=Gi!vC=wc0T`AyFo^OV7yv#!4Rf5 zvqXStZN7myj1+l&immR7;rC~(z)l-{2pbk7 z%F42i#`op?R7w`kjTDJY?RI&tz#K*R@^bYcYcVrk&eLbf*(CIRNv(d!AZBIzojs^i zm#9Bea1~b)i>t*yg&wYFRDMmJTjdO|{AMbLBlPcUpZL#fPx)f)H-5Qx7*jx~4adT7`jh0bJPJznAV%TGL(9e6 zg6oU;BKIDN>|0ESeu2Wfm2o3os6{raW?` z%I0ZePSayH%9c>QB@Pw$J#j((MpbrLee|uv)^H!Xtkh_1(p!f$AS2uKcr0hlFn%sU z?zgNtPeUQN=JEmhg3GKbGmchv80T@NMQNI;r&~qy%OKDjn#pY1T9BS`wm8w19@}!L zW#3hY{Uck7?71z6;A;2g5`De?I5CVDb6dm7JN?NGC-ePINkfW%v=l|mgGCJ|GXpM> zi7tvr*x!HE3xW6|Gtx3LlxuHxhHkQx_9N8%EC8SZ^15n_fm>F%2^d{#Ovm%H{*q@C zF7T4p5%Jtc>bOkeM6vf@<#Ihg3yP9lI9_*!9DAa<6~8i&*itDHH|F;=jz+YMXm;j??wDq4;ahJGXo&K zupwV4oMERLN1kAz%g8`;f3vySFed)!Tbrje>0sm~}y@Yy+fpTZ1BusSdb|D10G-)IZ@1M13ue}c z@Yh|;Q~``s&7-VT*Af+3UqUmDo$`yB$tz?&7o>J)@VxuaxwZg*E&Tl_e-HEbD1X1? z?|1wOFL?-0lXRIwc-kp=`Z36f;^{CGm;L;!;pr|8eP6`WlovIg-l5!ojVD3A#uH)= z!|-%mq6JTKeW&rX9EAN3c=`fj@}r0mHWbrwBi;50fB%=iXZhRB-(LP+;_oPb*vFb_ zsC54&hKl!}XDFwCqTV}4@g7Jsu1|M{C-CbCd-%1(CcZvBJ?!8&jx(!5xDD_E<5U%B zi(>ExX+mMJhqhyYt0QlY{U-PJ(wU8IGD>e!ua1zDU3-mj-^>}I(&_%Bo78jAMeRmj ziUU`x*ystyK_SH(Bjr#mIn7<=#-|1NpRyZWurT0w-vV|z^U{*2yt-QyER0VV1THk~ zKIbh{cj*+_M6x@mUBD6r>(j{sR&~2^-^I4Tai6cc#@pe(|9Q%AuiM72F>t;AYsSEQ z|5e7o&HjsgwYSt@n7!#Ad@}PqH8~z+@LJ>~1%%fXMA`C@q z>wbnGvy@SdPlK$$q#ENpaWeyBL-QOY7|AzE>3)!>F>pm-f_$A7a9dv)obomN9b@1Q z|0HAJKl~}iz_(Fo7zp@b(XZt6H&7Y4jdH-FEnG$*K>|1U?v@7mXBY#1KNEKard~e% zP9Tf#%tTvw1_74@&Ltp-3#aNLtQmWZfu$>orQo~jE*G@W z#(QYvz_$ZWlSZswgdZdD3fKt$*jSM6Df6KYs_vCiOae=!l(hs3Nvf|l4Yl08PHw97 zct6N^cQ?svS+^`o+0~iurg=bUzJSC=4fIgfOJ;TYyb9d!idP*6Ji~RnP|s*@Q;7r| zzDvT1>t6Sd_GMI>`)Xi9k&G+0_Ct!VBWhrynfox26Mi=-h+W; zDI)>tN5}7Ep+9PT`gOm{mnYx+;wX)ohSsRuO(6d1V2wYYixgia$HU?iI+4?tcWVts z|FN%G6I@l?hpZ@-o7K}on6<6W4(*hwm+@L(LAop8DJk=%SE&Dh=>#hm5+qdMDj{77 zUiY*cPbZ@T8@H&PsCZzSg!V~an>{c_(r}mAQ=u*)FB!SLJ)nxgW@tgGkz-pm(JY8c zaVD4QIufjOD1>v|t(Nod!X0wZiq8FMGWXlDK=8%6@09hX|42|)n4$gKX$*{7F~hyB zA@k*28{1JD4`ESrzp;a^l?RQKhs;2WcfVl-i9S1=Ym4LQ;cp^;HhY{cp52Gk@z%yr zVHB|TK=!Q^H|3Wihs!Q|VWjS28R*&@?iK1Wc&)?AVz zk}Dy;!xIDJ-P;$;tnt3!7Y+kP;Sz%7*xpQ7L}Np%b4#!)n}(z|tFD`17YxBmUkU)PUSTKuLVY(;9%!AE3VCHE~^q_i*45mKznR{S7#F=lqcNWY<&Z7>|PzS0W zb<;~qDcDYfrLHxkhr>C5Q^;??Zl#Zn-4|QKb)2z!x-EdebaKv88|m$;DruMRuBsYu z8^gO$o&jrIFusS9-uH?zxd%y#1%XjUO?tm? zIU~JbX6QTimXY%yAx~z!id(>7dG-*a8!U@EV>%K1(2w+-^p1Ob<>0U$e3ypMgWzqZ zsSmQviw2wXKZlJL$MFoQBCaNvI^>*Ns@z=jk(Q1c-%*fh54(q>wkDVwd-XHpINkwI zXMt=vwisPziEsfGu&Aq`5~|>p!#wM@`&Y_O`StRfg%Fv4qB?uR&?dDIrW`Bd2+9&n z(}S!uTewbTa1NN2gW+4OXq;PF(MZkY4u-E`=wf|h?iW8Mx}W3gx$f=!PX6-8okTm_ z+j0kggyHnpqmy2=7O-%!X51=AQ)GE^|J%l4TmMmGZ!YG@rNiq-bQXw<0$X?tW5sx< zpLou{$|0io947+~dhKoQB;|@S;F6wzBN`Y{R-ta93&M%$6-ZK9ERv|}9AkC1&6jzL z-zlfv9FIRade*H8j)@d7n>4X22Y^^FTT@k)SLwl?qTWmy!ryp;teDpc+S}p1RS5?m zLw?TItGvfhj=%xv#vftUrGhgYL6~@>Yj_GbFQKI%wHe`^Ol)L|v`A^X*twOpc|_yB z1{%$I6ZW!GO@cy}n^aF{-gH@T*i~g?S9Xd2!m?65o$g_nU^^s`@GN`FnlsmyB1EzE z$b_#5s-DYaH6eLfh2Ty#&E$Mng<2YX&*)NvOfyTb5CDrY)_!`uVII7OM1#3}R!+Th z^$Zbx@J~Z7qSn@cY7Z7;^zFM7iDNB_B2p5C-g@V%SF~h_oR?U~A?6+!H-ymLh$UFW zoN-^84T|Kxv?TvHHRvJzPi+`u$^ zmVt4EdRNPoLQ)6oT@+L=S295439M{szc_Qwh!sxv&UT~qV0bF;U!OB6`u;?C9PfEU z?|bzXDg4Y49MR|cMplQjwTNFY#m-rYFi|B-Umy3u&T8Zs!G&@QHAJ0qJrnQqR1ZOs z^%AGV{yy9}BC*JwVCZWH8%}CCUNG9ufP?ol;NV^0*c(B~(SMou!14@KhrIc{%SLfE zTHI6T@VUYx-A{GkNsvKzf*LDn_^5-@^dHH%2Gt7(Q~=70%i+Fur#NR&$o*jNJC9ps zcxs=^jw5D>wnvPTQuDkyVspB z984^PSbV4M3G!-P2W=vsjFJlVA`7e@CG{ynHyIHbB@r1VU(=Pcts(d|`irAPJ2GyU zNfHX(&InW+d#r)d7Yqyro0bx#$BKJ9GIID+ET>^=b(yS!&^E{2+ZLtP7^g0wEic4{ z6A8-rSm<(ZpK0uh3oexLvC!4oBqC;40l^MW1PtU4+z!651L)I_-d_Fa`BXnt4YR&8 z|JYLf=sK<+Z}jTNks8+Z&it2`>c_$3`mw)PKlapcT*KhXQvGPHVRE5SvQ$5I9M_NE z_v**i8qSEF`M(x(7nqtY0V$pNkKLjln-1#7!>{YdL+OIM{Eaj8V?!>JtTX?C8}y^O zYoffZdtW~qJgBX7Hm#zaXX(&N3DHcAWhCcy4Nn$u{(6^ zQ&#Nn-D|JWv0HWQkF3~VyVqW%W4Gv7!-{>(y>^_A-9+rNdnxl__gcG-c}QY7gatOb z*M1;lyLf}d6ch7+d+nP#rdeXV#H@3#eL=@GNX$hw-lqlU%hS7`CYCqByT<7yGSu`E zBZj(WfMuxrWgOdYS&w9IQ_Xdtn@e zZr}{(k`q5tJ(Ygh{u6yp`CKCF(k401j?JC ztpV_TE0zI}u482YEUPCsoj+Q~Y?7EI#L)So^ko+7&IdWt`S0r(-T8&Y(D_GnjPCr| z#L)R2I;KI&nHuZ-v%0qu8|{4e+ReOx`Tu3NiA=y4Xo}OZcoP7U^mmFW}Hs_jWbdgd&)_LBDT}K9MNk_C~uUBm25}J zR5rL7#%hOswM#@eZc;B5GYQ5c_#~R9>?i8E^PzLqg-3)6a&eC&7akDBn3~fj8gHAH z(%(*NBL0$$ef^@DiWw^0dXKp(E`Vz#|5fww+*CKKZ&!Sv8^>|yM_7aB4)U|&b<;7% z8y7figdKG$p{{N0 zBkTlPiBUm-vwZ(^$>D1xr zEN2Z$HSLuAM^>k=R`U6}WIaYLO)oSaeb)wyI!It^h$kt7>HN(~bVy^=tr z$h4H2=q#NfuO*bZmXDGl4w0axZ7ZQ%5Pch(W1n|hxIV!^n zN0w%)^jL6~8XpU0tC6wbbQOVvO1{X_i?z^<4(6)vSnPG`t!QxT?KD^&pLL`Yr^iwE47fo~0Mp8*TPLiE1aw(0wiQLY%BkkJQaqKb;Oh1@o)CTCmQa zGFN2l;1Y293ZRSd^aUV-ffX#6U(FmQ<^qY4bCPJeYb%~+@1rGjovgQMC0g!HJQ4Xv z8Hg3FjIi?s|@ckLZC53`Ctgr{a{egr0J%bik^@{p=b9p+nAxU~ZP z1&RDDLvtj?oKDtFOhx$BS`i7XP~R6U*wiEBl2hT^C|`)F&;GY3N2Y#;eMp1~Zf#dr zyp9-_2B%yAPAyS)P>L4(7VDS(mEn7afDP&z{ddNI{hN$dt#}d`UlN7tPSTXC=^E5I z$o^?iOLWG7JC-O{5`|yWfR?M{+{ggv2}+Zb8A?T5iMOkVU(*6&{{pQ$X9%MH6rf&CxMoS=OG$v}3IvQKCjN#j3(#&3)c)^@Mapa)}bDI3roI$>mh^Ks23tPv4U; zQzI3Yp2Crzum}4}LQP#7z!G)u&ryQe&)OGi%G5&vO(CMJ1lX_$HXR|Q^%1{age|vJ z!H~FQvix)fA9-2APal-uKkeri9~lGs5$e&8NA~DP({BBEyj4G*{3DNtzpvd)nc^Bl z{CH^}87ByIYIvePGJ@KRmdq#(xClu!9Rn7ft%pF&HHJA5b%LS6#?YzQ*$0I}mbwKG z78(vc0vfcy+hTR|rlCpB!m{}RqQAXcZM_$Mf?^Kh#u?YjXEr8g!~(khlspIEe~o(kk_1Pz)9mcT_UYU}~ha ztL9kL%~q7h8haw)1)f~KuB?|T`}@54u4S{_Pl<4AU}8ZyMZNx_#%OxE%3;EY9M|}A zpEEMshLmk&{6p((Q zqya&PDI~~Q?FuAgukwI;pLC$a2hUF==bYh}Dk^-Davw@BDe7f9n90NU2{C{k6oAz8 z{F=5f;!Z9v#&ukBow3O(DLt~n6vkIzsrdm&hVB#7JZMO}=m zuOs4813fz9no@Xrr_}Z$##@ysjQ6~tG3oSf?rF1oo)sU$M z$sik?yQx{Q#qQtX2r}Ul$^W=ne+2j9FklxAW_02J{2=+;K1DVsex4{)9Kc%BpaEhNRh&71D4raU^a^QIsp*QvOp$}IgDvqCxbu8-4b*){A*%}eC z#=LSBb{Q}Mo@w?Q*E$>(s*&g_puIJ`E-O+Y|5PbwP7C11o`)DNr}tWYsPc$~250a= z@e_qdt*I^cIhXk-&biG$Zq8MHcPaYtK*CrQ|5|@FbuxVQHvNj^bFM;wywL95;~#eu zG#f0x;8hk{Y zQcZ0OY9Cd>CYeCNN5qL$^CRNWN{P`mhylq~QlWd5Ar}h$ns@OlO5c=npwzCd%Gh%@ zvDh*yF?XICd0HAL@LgxYXCCPmV*qB8#ACNA!`XH+E*M(JTkw$w^zR1!yHWo>q<_Npt`sR3H*)q=1I(O9NvN_T#KSqv7PGfrRYJIE>e5 zpdwF8JFynuGwHyAPi*Gp;utScP67}1w^M+55g3uf?XR;O zG%aRbk(H(uX&(5+k~lnqUu<}@2U@k7fRe7& z))_vOadle+AL9I5gQo>oWq5WO$GWs@$`Cu<2yi+_fG^cyPcSj$+7WU*gyn1Uiq`?P zS-4VW2a@6z0kf!kophZrl%m_Ln>npsqm}HeTX?YH$kuxP8C&ZQ`5;^CdY*b~1$ouT zOv(Bd+h4DOiWhSg!>EEaBB7w3;+LKA%>6Z2m-yxV^#pvXFO(wIEUPT}nbZ5LCA1tH z3=Zj5RX{@Dc~wCi6UVx`n&Wy_Yj$3WKfXCH1vRr^9;U~m)ZCYy_i{nf%h0Wr=~~K? zu+E~t#>B{+--sLq?5~Bg5axS~$B)U74~Aam!C69nt&@ZHSKFa!ew%U<&vgI5dPrK{ zq@EJBOlS{H{xg*|$*z93QRWF;o%DH}aKHUS5@?pxe}Ug2T~VmZuf&b|tz@!B;4@-E zwp0|=VP+Yv$R9;!Z-9VTawTIXJ|o(=rTSySBw4Nayiqw-RYul1~1IgI3r5z zTxq72aLjT9HI(j=4^DF)+{$QOFwSD6e7^IN$Jmwp(~$~5piCwEkqscY}F4vU|BU-GZ@r=ImF6${=)z#d=pXX7~7d3uYe7iy_HT2Uu+C~erEKq zC3?IuFo^M{{1c}$U`PLpD@~L-6Pp#~725erRwT4M6|Lv=hv?$(0&noUT9EkJH1$%`$|MLQR8GQ3X%(l?Vkg9l3+( zCkAZA(6@@nHfeg9a^=Z{%_R$jkEA@Erq)J^Sh{1ml5U!bgy{qMQVlY0{?QHd6DN6E z9!MCZ@bGvvwXLXhiK4u4T+0j14F#LpRgt{Xu zE0@9M^+iZlh#esz%qnWrX_ERoJVA57cmX?dBEA;zJfo@Tx}uLCHwU28P8b)$`lUFb z-Mk+MCaYf|h+~Pxnjd4;7jd#G(Msd;UOQAxP>CYDfUz$4%J%rXDlU?VljMtB-Yoxxe2ucU6Jc$ivRK>YDn%M$W(9T9-biAMp-vID@RR7Z z$I8PK%oiB!?dtm-kfr=-83X$MInV6=>RAY^v^fRZUO9U*`N}->RREfD_=~|?IUX+u` zv7CoC+%XSieoPzkjMhw|=Jpa^cD(U3rRVS=-rU>U*-y4aXYX^w?2rkL@5Kw)?SngI zG6h#7616&w(|J~FXtgXSOsj zr3{S+jVVhSd**-9eT;s&p^J>)!7$t`Mt&mW|0uy71Y_(~n*Yq~-CD+cy8ljNV21zp zxf#6Ijh}m|JZ_qMIS>EW=4Q#`ItZcgMROl|g>c&3C*C5#-{<~L9y8|3;Kvw=4D$dC z1()#-CKA(zSd2nnwNg8h!+XQW^$t(n`Y6Cc8(Jn#o}6Cfyk{O2ql_`2ruwfJpsurk znrQ)bg$2}Q8mRDO3!ZZ=c-$5|<0#HeablIQ@trlGL#r^=Bd&M2|8@7akF}YISNq@6 ztc^fArjC;6?0A;)o9LP|;oc40r?+tKP7wmE!GhYkj01;G zhEi_e(vk0hcdA44;;@CNTPw24k@0&dN?gXXVq!Pz*TBUQ+&of|@z$ZwNKm2P)$7(Y z>l}YYiCXHE%|~{gbA-nV6QH8397e0S*VL3V_Zn|!N$ZYQsLiZyRScE4DPFv4?bm>7 zsj(prJ?E2(=UN#8!~B#Ts4-%Ay$Wx*g*)^9<{y<`^Cxtntj>6T;&y`%e}A?17cN-$!>le{#t0gjlCRqKY1AF zN;iAUw4b(6VONQIM>u^q4n!L6N*)MK39ZQvF5b_F&ccH>+sKg}g)ff`E`FtX@$2~A z-3#YuahbY;8Z;~A^r|A<;`7TwKapB673TfeKRViOqca;5S09xto8;0;nAmpBmA&`7 z>mv~?GuhI?_PJ^)Vu-#GvT!^hmVv*tFX~;|m!#_Gf4vW%dNh^GuV8&wPQG*1l$=?A zN(J|U%hlgW5OnA_7Pu6SjKd7VAO>v<|4ipJT5~r`35DTjWk*g@cT33@HkqM#4CuP1 z?PTJT*6eskNXj1SagFeQm1I~aS~M=Y{&#t2OHIoc)2vfcY$S2Xk!;ffvhTY1>308h z#;W+#h<~nn@=3-wCbOnywPy69r1K9uCwlzfjVtk;2xFr1lW=^AuP-wGpiC&M1YBHCJV&g*G9SfXOI9a}P zP1``?lD$CLB#rO8&Iy!>*hEg{Dq(9h|Fc8+)yKyvQn>Q531`d|<4d-{$j$^E$d$9V zgs2OLz#P!{n$ap-cx~Jf;}1MtWl6cr!)6v$IsF^jn#CUi{De2 zcwaERA$*_N*-Qm9%=5blnm0_?V33?+C~pfsU1|6$sNH z8-mrT#z0&^%F7L3WDMXaf}k^ejzn}8q%u$=kBUhX(vLJ9J34bc5>;qKP&KL`1khqH z>2{fBDO^S~y03=9s>(Xryy!hx)MaXg@NbzPZsjAZnoOqO+4&eFRb`^Ie93Zk%cA!v z>RKrZoUl5X>t%2N#^@6D0nx_K<(&h3hXdXJIA->nLuy|ZC&s<*eTp^<~nb}1JRhvt(YQj!iH$f6f0)F zH(_HmCRt))q)+rt4c>t_p(vW5=a_D|!<#Ta8uPjphP`csR^Ez9*(^BG-63v>n3NaLjUrsv~tFj(MtBn1^W@$GV4g-{wmy zS5J{KRFLMQgC5htY#$aNf24!868xS7*FPl5<4RNmZ;@@AcvIu4AhPW-369i3VYsQt zUnTkP2}+TrILvS;eg!i$!-Y5HxxyiwBx-Xhy>=1u*IG9%k6 zCAdikZDvr^j$ps;+s9%>~8zV#J*X5$}Hu?M)^;eEic&{ah2B>ROS> z=BaNxd;V|iw)1O9*+y?Whwd&nS__NZc0P%@@GstWK6=N0%)^a^F_7SXz@Tw#8(R?7 z!uIb=H92FDHAhCPFJn*l2lB1gIF7O4%8U*n&$3>c+Dqy~|1kH2<}Ag+qDwL)k%0?` z6B&CBo%9}XH$6kXkgMqV4?o!Z{3ikX^Pj)UB7z8WTYC6|VHFfR51o8|U`4mAtn4nU z#JD(cjE%zKUUwge6j(kIAlyFB{{o0WHUPLQ_PRax8h4TetUS6 z1){~Kh^6W6bU2>23oeTB-h|&Gk?i^~l#=KD$UhqWj2yd#fqh`$K(~)B#?#ij8fm-w zU!RNtG%_4|-@9vhoGD%jLUT&FRqq$vMRN-7AXdHmaE2q<(HfYuAY+%&)fL|+SPZ!e z!fsHR6h$R2oYTE0arQ<+SAqAW1x6BJbjA^nYNHe}A~g{R?nue$*p z>o&O8U1Oo!yD!k&okdt$p|SqIZAD;7tOfLd(S^GaX~0;w2jAF3eF3Z;fVjxlhu}n&*Ax40Im=Y5UITFQX@IIQPKP%{Zpc{S(D35{pL|!B9A(#uQT|CqKP{ z1Swd!mRn-S^Wfc9Ql^%FB$G2$tJb*Jts^p&Vhi2iL<_-tNMs!Rv*+3zNJ)-UH#ULN z6KwS7bSCcK4-Gf2L@i(ogY!h>h|a_}-vs#`PIM+X2J{nm`AzUKD-CcPIKjb2SYN{E zi@=(JKgW*c;fN+?rCpKn4Ui!w#&k8U-%#Hx_c5HtEgc23Gj!(>HdKpk9j+fbO*4fk zW-O}hjoDJY2#uZfHY;&-Nb|ing4O<_RY*X__eShyv$S#OrD6EX1E*igIpP;huWr0= zAXnD+5#|^aVaOk6+;>7CD;3ABT1U(OpC&!dx#bJHWhquHJO+ zGz(VoR&Br$sWR7VcsT#aD;T|v#WAu?a;O_%U9u>yZ?Lk*Qe1U9#Rc#ts;VM&tJL|Y zBhMlz*>rPS{rOUGuDN~#rCcJ+ml8GbCcUp4EgkPZEF_(6Cef@Esm@)ZHcAm$D#Qa|P>);#;wG4d zgQUR-hVH4Q@=?CE66S*)h8@B>LE-JxP zvnJZx(f$40##1`wBy+ta+Q4Ewx?KGjY?iCf-w=3WE8Z7MhIWpXTy$p1);L?<8!0+H zmPQO8OT08tmo>0>IX#v}{Hw7v@(W{Wq;ck0x`mbrq!5t}LPWl}lAgHp7`yl@?BXlu zo7!4vla^xTRB$DGU*1G!Tat-$fr~B}Zj)0^C(O_EuuG0z4)uxT`DcvK z^}mhX&x2cWkKvzVp<=d~4P}n#VEB@9&WqpIq-@0hL513X3NY8WY*m4G8zWkr^YscP zqGeTKB&mfBRXZ5QPT+w2NQeVI`UF`0S`#@iH~`B^my5r+Y4O3UJHRsY9{2Je>RIL4H38GmO0^ab>b&{yFZ>vCh6_MmySOKydfNZF-x0} zzTd0uZ^q_O?cIXpTKSQXFHJii7a1D#gRagRp5)IF%VI9Gsrns<1ZHz2*YF)6u{fvw`O`0}R|9 zGJ3OK^6r5bC$?xO?={|e%V;}qUN94{58gw8J~@u+TpA~0A2e`b`A(U#;^5O`Tpba& zEs^nJ@fzbw`XDQX2D(t*jgHer-M}3MmZb?SOdb-I^elI)$58p%?AZX%%)BC1nH-rr`HfKeGBH;9&gXo8txsZG>M(cT1@_A9xKZdQ2 zp?weeTnCWG@~iU%mk8StW1f1%$1+e@aMJt-_Vs+=A8WK-t(TnnKY3XJueY4cNS*qJ?RqdNr+bSq| z1+`h@&Mu5=9<=?|-dy}1RHe^(tM}tIss1ZN1rdS#Jl&>?i}#D{pE&r#jZ(N*Z+*y`2C=sz zc!;Qr$lg$Y+!nZl5-wFwP-tYSbC~mls_<0M46+fpY^Z^1 zXc%?jdNNq#pwLn3W(q}36316xb|?&jq`>>6K%OOt&+kVT$964U{?~iRr)R+9T!mW` zjK#bTf7r~5Tji7?D&RAbkdlkl(U$y+Yr`Q0MtM zoSu~|epAdM*9I|{lq{T@Qk6oxm_dx&5lsIG<})mVqOp}_TKpV%#u#KEL4Uqeh)h2g zS7R1Ot-}mgj`zCE2^-8^I(cE=u2%m%GY3_-jDetCMl7!9F0Vj>dOG!0m8igUsnz8j zV7hrKdq*TfV1~zuUHyWt7&1MscRJnU<-T~7u>wijVr@9l+fkonc5Ifh zAkK8IK`+5)+brWlZ6FNXQPban44}c!KjjJ>;bbL^ z_$@1valQmO^IJoZIcp`i?j>Wx!ipPqEycmpY%4RmJkQ$VxMykWimwny>2agl``R4- zgk)j4h=>v)hLCXK2zhY(C6WyNZ8l;_++iMi&c0t+e#KyhS;<9r0_TFd3-m>`$IG_us9`;{=oC)0q zW0h@6{Fkda)LA8_a$ft^AdMK59swrfxV`PTg~S6E5)W8NJP=1G z#2W{!MjVhv&}HVnsW13vFcJjrG@cgRF9aqu9?)=)=1EOJctFGcu7=%13yB4O3excN z`~v*MIBdpT88fhB*Gt3Sn-cGgr~iAAx6&r)82^lAO49vCP{5F`BPfta?=|g{hn*OT zkXgVN^U$+0!om54K$1@USv+%jjsoWle?d9JUmzz38GX5p@5s2mHRA=+cDJsPv>7i* zT3R4!nI4jsc9<_tt#^w0#i$p(d)(`UlU%A5FiIG)s!z^6RkQ+c;wQdxD*&I4{bwzC zPPgRMf6@{?k-yXus~umZ8GEuiMjdzpoZGD7WNR?nV9a)$!B{g7wY~2!-nE;%Wc^8G z&bqe~jLjq*OdESVvi|T)rxjpdf^p^Mp`C)}=;(jNKb0Pk9-4sh!%-;WfLW%8V3wW~ z%+j~!p~iQ?GlQSRqz)vG>I^eA7gtNkIO`-CXo`~0jC*kfvCtLtKu*C~iwlm4ekv91 z9#3hhx!XG8mZ*KK;%qe>Cq{3VP)c+g|8h#mtZ2%lXNIR_HPHS*g&q|#PIHk*?!iYB zY`6x)@PLC{x|WjFdTQNqd=dVOXcs*BxX^1S8do~C4AQx^)7cfuv@qX|a=Ns*C+^^{ z7);%p`7w6LJeFcLb2HJsEeSEQ|Ht0D$46D23;&bMBusAXC;_8Jh$L8S#9|{A7|-Ma*p7mTHv;JD5MPHnlBI7wZ ztjZ}YPe;^K%p+XxSZ-VYjqG|PE;xW9lOv}6O3{`>N8$JHf(39&VJ-o{)zk4NXmNg1 zUuz3whtp*DNh+`{D3Nsc;+v9mW}>mi-OaM+XZ^FFE%jw-W#mY)6+1;LXK_!tLVnnF zgk84?KLL)uQa*b|9W@XFQ|pK@59QKT7?RElsPb@8(lN$Ghz=XczN$j#F!r zxQ>Z)^rF#VTN+5tXiM0GvBv1Yi3)4rvtfHFN?5GDx-6vx*SGrXr4;gZ#*$>c8H*L0 zxNOucM)^Gu7(7eWi%GiKnr=~c&~>JPb@}PulBlaW zm7NSiPipgnn+{2J5y9|v$GGdqc$RQXIFenT;H#Lv)HSg-7M(IZVT$V377S<>o(zm7 z@t{yjYg6o1*{$ByQAl2KuC>unNqYVp5nJe%+GtX%v>XH0*=HqaKbD)$is^bgM;6T@ zr*#audJ22kf*0x5j0SzNEu$R+N@mY#ueKWN{#6o`m0NvuxqD#NTB|kxh`xkXtw(!q zj}2*jHWWU;4gSK-W?p29_3Qhe11o;-TS&_m72mShxGk2ku{(u>+>DCp9OQ4Wvsaxe z$8+nc0#{6J>cYThbdIPaj;lHae@8DoQstW+h#vIS+5^_X96T0m7*|TG;A{2xzbL9^ z?WYX%3FS~2BP{=cy8PguuNJ=pMYNoi+8Em_=)e}_M)C)cOrWc?3Q@B37GYuN;C=D< zF=$`FSZAvlg~rIPRd=N!W`|>~Xh(kP zYR+}3jAXn$oh)KLmtqW*WAwJEG(V==cBkrXvkH@i)}Zw3t~@yn?nG7#qCkDxZwSy` zB)p}F8J1X_-D(2(LZ7W~T75;v$;r}_=qWp6n#NcHh;UAoGbfBy#gt{4NG>&KYJk#l zr4hz6S#B*5^xeSR7ap6tK5kZi;=1w1N66@#BD|ACTAvjXodEKZwI=sB!C>8zbcRMX z{$BOMG%yK*Xc6gW3F=nlzfs^cn&~9~VU6SZKtJK0In5Q@WS!<+6Ge&R>k6XOP(3+p zcCZXJxO=0l&DLH{VW0x@pfRfngZ-;gKSf)<#JKq9YMkB9>1wTaQS{7w)Sa4Q^YtZZ zdLFEZ>FDX+Vngrb{@DI^n+l?)Rjgd$cxSy`Z}>%j&{BYP{Nb-wO9_JHsnM8{oHgC0 zRanvP<<$GpzpFAri%))U=n@#MvCmmm!rJ~5>cdx4oKmvu#Su}exAG*tj5Irq4>=sx z*Lzrnhxhf^bNia66`Co%%(SpJOItDyilsH1;1OIp)V;Bmzy$d0X7^HPG7ex>$EFVJ zST+ScaDQRO8~?e54qTRkg=w^baL#d~?fRKZQk^M|y)GGT4%K76@@Cu2=@#DbDOY*c z73aM${LE}+*Uq`3 zB&T9J))l@ly^>Z{IUw@Lay#D>>6plXPEnOkMX8{&C)^V~=k`PnTN+e1u(2p&2l$-o znFeOsBMl27lZ-m=)fcf=VRbS;IjceDQ6V{Pz&uw>g5H7&O(3iI?qXVeX{Nhm4RwpFHbdq-wLcYl-> z*)NBFb@*;t~RD1Pxy~HLvmsoQtWB+12w~_{> z-?i-nt~htleo_4(8i&S{Geus(bv_g)nc-7noPhJf*a^&@tImBBkf34pzhYJaThWcS zS+T1x#f`-!%Agm^L84PHA%csqPsQCuukFOl_mf-(3qbZ&x39A85S zH|O7G$cZo4wMWF)lJh;mZY}EWX()@j2Xhhi7|h2$&|rbnj^RxA-~yMeDgP`+$TN|b z?Y3j0IG4?>(XCVjTR1d)i>{fq)V48kJw9yL#5Uy*LF&YrZp;!{;9S_$M{R-0vPoiwkrkI~p*<3L z+KrX8SYW05=v~e{a*$W8*lvnuMy7udpm&bzjm$ahFNkrtfK#@#NA!1nXY9Ce zDb-^|2V%~`N;b!}%X%xKYnL3WEj+$7kih$r-t79?!ecoVQEUy%F$?T1Jjvk*Z*hw2 zdqjMA?@|YzG;^+vo0;DuCz zR>`0plEf0>&(N&%Y?Z49&z1rH%S!LrWq~#z1u%t?d8uKcYeM%cGii57v#Bl=PXYRe z%Nm(u2e>qRr@6);1KPymJ{7&X<9h8+Z(8?yyms=^ecAhvRqW5fpNsb3Hb9U_{P8z9 zYJvBCf9mm#vtEgn?O3}TWBkhKGy~+H{6@dcNRnFFa$Fa5To?Ry; zi*^QrBg?11ABOb)O3%L~@dpFnA~Hv}=Yhdl;WZ8I}nHAL2%y28jr z9Yi0zlZ5{J*{8G{`s>=Cn$7I-+*(@V+0{eI;oDMutF)X#SXU~e8wx#J1vSqusUxD9 zbM-9>)qZ`Kpria5)y0Y(kGlBU=-RObg49u6NgMmuU$6SsxhBi6e%s_v$5(-J(3R67Jhg{k~*DIIG4VI}A_ zQo9W^^bT@A?VP4JOU|mawz?7XR0+%+%(m6v^=wvsJh=ASz!>k7nbJSjUbB%L{cAFG zuNsAD=BNs8mbXxQs>1gN@q^kkohXo2lOpcu33=2RkDqgZ`MnH zJ*&gxRmi=+c2X(!%|kf6t;*ln83I!vy4kBLPse+!ZwNK;erHzJ3B98ZeLEU>f*NX} zffg{6m}rEuQjP66gN~G>DvwYZKap5v9JG)t(q+leV^L$Ah>L}ZXdODgVPtO&FXeZ?2rL@M z5U^I~$J~>#5_AJRhV2p;Odp9a&e{tDf6MCR1_4$Ad$sfGY~yOhypHo5qDOfwkgo5I zM8y-udW*hjPYru7yUW@X%@x@S*Iy(l2mHi}qNe##LCexW9Yq+=P=3W^5hO5C#x*NJ zq1Pfo6xvr|FbvkB_%w@-$7Z;@AxeAJmKiuE*S*2ISmL;YjM9E*%Up9z?$~#yhnFv5 zb15EI3^NR#V}uElEM~PZuXH)(hsgSOUyuV!EBD39a@YAr($Kejz6D}9*RPgqw|un_ z_8Q;+8CbJul?2`Q!)r~uVb&7+x_E6{G}=EG+FZT3n{LqG#;s{q+YMh~&ozW`EG`~e zk7VskMRadNif}89Pp^bc<7g`L-Ww&_JbGm&J2BQ;90=(5**%q+!Y7<6es;ehloifqk<+S$dwk1cDX=1{)Hnd6N*$=R>W^t=y^@WFvhr3M+LRjotl`S8R5jaj z?zdR-YS3Iop(tJWv@a0p=+%Du48Pf}#l`4=!V(B4-Wg6@qx0Qg%|Ea0J3|%umZJfQ z=VePNSp;hCY#tM?#U0#MnmUm;Hjh!YXts+i5m2+G(JIh1U{e_`sF;En{*wfIWUyVc zi;7U^U!-FpY987 zDGS9#Izx0>BNIPS_U`aHFYxBQDi#r}?uK-2dp3gj-hxm>6Mi+^jnP?6t~ib>YTmv% zwEO3nlpXx*2dcpxzhEr)whvY;310nq&dFRRmfd)eS}?0hXbCd-#LvuN<)OenZ%3z2OcCVdcWUE|7noh(i zQP7)NrMmv3665C#0%77GfZ-k~#88rC&W^x7M4xg*(BkM#G`=!~kT0@NeatqcWvT1} z&s3M*U%K%&H3Ba!HJ*TLU&h|0M`h}|#Q0At7B=FC&7c3~)l@Q6HqefQB`f}+;M606 z#1Oj*>gXOEbR`-2kqiS!|KFD%(ypA#W)tFc9ryD?_wpu|lSLH6HNYJjMs+zUcwAuv zv3PGk#WEEbXMFa@Fq+0U2+}iQg0`tgq5Gzzam0G4J|1GU1n+Sc$TY96r;Yw?} zXUW4e)3_~WS01zebA8E^7?aCC2wD1x*fG#aux)Zl~+&Y8|0l+Z07lf znP=UwJpUw3<$CL8>tJAG-K|m6`WkG`iFLF`)!{R>`8{=GYV* zFm|n@ZA{yjrl+_<9@}aQLmy#;q2&A&2sH%xpANzCN#Z{#96o>2SQezFT)a zXFXn3*f z0`Z>wr)Mr}krVREVRf8*yVv!!a1sZy|Mt{VPeF~1k{codji%m<+(-?I19)T@@{^f_ z=<*J%xlY?YX|a*^4fY`AcwwLFF0e(r&&0dNS0vrO*sz7;lY_{t71%|;tMcsX*8_-D zi)_9pc7k<^ZKT?X_ePPA!s`b(4W7GK#oXN_=Ib|fN?W&FJO96|)6&KHfwm4VBm z3o)uO0ArXJvtO1QLlX87lhp`aQvELXK3fIF$bh)axhi*x?7F9R|dZsHMEJeou@KWY~Qn(m6S*0e01H-IgysMGRWg=qbNMdH3wGm=FkO{AGZL*HS2rUUez>ztNq*|K=fUp=Jx8QvMf!I_ zNILX+V+?(xB&)5#{3BuDAt})a+|LAe_2$le-ck_wb$Hi}&@G!l>;s?U*8@Y0#@&LE zx!qPn58GV&m=FR}R45iyE*7&{*^>K?SE*(1rFat+(_XRI;*8w< zgL|$>*IJypGL4byw1=u1n9Vhz7;l7E!gau=nU~0~6(b~Ql{D&i8SW`6q5CfTtCB2$ zMDME}M4JeIxp~YrUx}k?ujZc(T_zM4i})ivcQEO1FV84E3*Wvxp+z|Vo%ak3gOmp8 zNfcSO_yg1KJd@vb{g*;>+0Yw z-5J{%kPl%Leg#s)3PO?$0wqWC|K7;B%|EGpG;+e^Q1>sQ-m;sEg>wzghOci5?P88h zsoYGcx*rL-n2Kn**1)c4H0&7cLr1)Va7BwegIr=!Sdq~;kue2tCfR-3w(cS*`6gBq zjAO;T^aVdO4x`!10%V%Dg%MUt>2Zv@=DTg#;6{`cKupRZjDaK+lxB;1NYp&* z;5>z**nt-mIanJf7+gxyPS}EP8NZFBmqkr(b{oTLmP_n4`&=3l%q)g>gbNvroc9Y8 zh0#$PA29lToZXQhB79_V3Brl!4!g!=_q|YG5Xcr;MPAWImYS``Elcoy8hTzu^b6gA z6#3d6bj^^4Nw%z;R$+aicR72AI-k!Un|WPgRjus;Wh#!c<6p0h&u(3zP~e7G6zh@= z;VPCzE=%b+h?ywC82f%RtUW^PiDmvQ5DhUmQY?y(S2c5nwuSj>bX~-1Rqx8;UJYlw zGd3oyze%oEF<$P95uJn_5jUFd9Ki8zFSj?DM49)y$&@Ox9GAE&6Q?;Z2<$U&b}!NN0b41c5&NvHT z#pJ+PZp`2dk28AYUEK&zQCS4mAo$>2hcv|WfL51`$P1Eu(SMmcyT~{;DDw+Xz|Lfm2AjU9E_Ti=XWw9bZEV$bSGi(>^K5}hWT)>PvW}j$NAAx{ zN{xF8)#S{s=R#G~{lfQq%KRwXdMcXDHm7>Eje)i#QOAj;Iv{?Jckg{c?;>{S$en35 zSy2GoVuMZUJ{{%ymc9mirv?YP(jxDTHqS0G<-6()V2V8s<0D}~A(zpVy^m3PawOAPMp$P5mz9@VEnY?D1T1aqEiy&g^~5Jqaq0Y!rQDn}F$F zrW~sJm4;Ymx-r+AsAD!Vbbqn9!g33eby3XjpFzGo%M6j>s@G2;+nl}6+J^aktU0*b zEjR-}`V5!Gq!oJyO|*t=(WO$&%BZjuQsiA@6QLtaHA?MDjCc@by_f1OIS8IDo@+gL zs?9%%ef!Q8xnX1-r?dwrl1^{eeOd1~_N<=_yFAx>^(!EY&V_!hW0osvAs6l-;Ap7) zED*gWDo+^D{%8`x30n4se)XgJO^=?{b~SliZR|9iaj#ASk1U*wzM4*8hx}s~m&G~s zEqJxx;Luk1)_y~ObN^l4`#(xRRIbf^CVH(#$$Ze~KQ^%6h|BNJ?!$K^NiFV%pl6Ap z)m}-FUfV^~A6+JT)0_A+PvLIwjc2`yn>>XtI`RkB#o!}XwjFx0O^@^B3%f{i-~g=T z3Z2)}z6}(D2;P#j`#+L0Ye?oqJ|6K!m3tR_&t?kwUq!-^DcU&LL?n4$K#@!q> zt}n~yv0^*|fkzU%@9BXfbkJ3=FJnJ|R~Sdrx`Nd)vnqG2xsFna?QR)Sr_B3W zJsulO2rJ!Uvo1P2uHO2(=Z3THHZcswCDdW^p;qoCy^P@&kxoJefnHr=SXnDxWCS1` z>Aq*T?FP1{GBw!I3EUDxX}P_~0QG#z{E#i{GJCG>b6D4;c^BKfDGvl<&GMTWObn-6 zY&j0=>f@dS&`fgA(J_wU_60o&t`o9Ld^QkyznzmsX^& zsOdvo7kY2Etcj_ZwiNY&X5A}g$SDQ0+o~9BoqKEUCbGzIh_2w??D|cIM244*PJ++1 zkYz*PD@L#o#P&H8`xPwpj^d(QA-rl%3|gYBaYDzp3g*TOWJGe~SULbmiN- zHa|(6zV&JOzZdTg{QCSUx=%?%yIZiIm%Y!k<$0n5N!jS=prmkVjlAl+B&8{1&Hz<$ zo?Q%)J`PvZl{dGo9s4+wAjfy_x{G!jBX0@m|5prqkK1Lhuoxqwc33=6r_#*hl0C zY@2HyA|!)Nmaq4q&171gBv6j(B$a7R3$bpW&3y`0ac`aY|3&o(=@R#$Nq9-mI_9Z) zP8Iej|8cdj*R%CSLdNyi(z=@kkA?21qy?$e!~EcBL}9Hzv{}&8cKMufIUdzQfido< zr1dPiSF(IX)(Wo;SE_5)W^qqOI`Hz;NHTAY;0S+1aO6vq(+9Fnc~z5n6JIi0&~bVVG}|UL+r-ej zAc*-Yn-2RgyFSCLhgYhrZRg`YEBoQ|%QzSz1)bEEJ-kdjIB7 zIW>Zg)2^!~3DlbPXVS+*gLX;M6VK|Z24?M1OrSBP0o+fiu8cItot(!#Tkhb0NfBN_ z05KjDw#@Ei{~iW_w{GtU{U^wJyix(P{{$JsGiO-;Nfqfo8Ew*kYC@dbf6`&nfAUNJ zNtXXi|D6)P2}qj#Cz&`@FAnvewCu3{6O8`3{*z3<)PJfTUa79WSw3fMeu)3lf6^HL zO#cZw!~0L_|KwI z;+fQani-mXK(;ztq-JVEtT$#2;G`Ri!tYs+xd+#*{pMnGu$ZGGG=n@_WKyZkS&?y% zFsu;jEEHJ-q*$->{xIlh=ian>vy=y|Z$xA!E;NC8eck7u=lG$}N znF6~#TP5?q&XCQps(98GnHKsP-)Av;zwe`HWm4n%Ybf=DNu0R!*Y|gOs%JcDk*Q6p|TMZdm^y(Osd~kYIW+<`-9A}1VInDi)4E|7o?xzHnl_^x+P(`bSBSb}S z3RU#lFISYU!tjb_xBC2(to2`0&(}iryn6I{e)l^g)RQ|aHCw_YP9`ud3c4XOC-WLI zw-4?MEA{TQdAeF5viDWQmwCK0YFQC$yC}LJf~+Xg9()6t@zw7N?H{;ot+nE^rP{W{ znhiE&HMd<|vyopmgNpe2K8`N++O9G&Fv}H7Cx4kiPoYH{U1Y9eh+WpKCmG#vA8e2i zh6u(R3v+itzYId28mXPPrk*g#rDmc2I0-|E{-|qI zZQ+jwYsVR9uX~pyuplv{Qf9ZB1c?1?bN`cDB|Y@LS_RzuECu==hgz{+-Auq2%rp>= zHJaxkRaj8v68Oci6~#noSW|}(`o4+Kx9j~Cti!VuUDY0ZPwJ(z3iX+g4!wD(zdQ=z zP~GUYG_ujvDoO3w>aq(nS28I#)Pnvf$bq9#tB&V4RAa6B3rK?Q6)=xirgU~|cCi1+ zUEw;bKq%*ZmIc&&mq4+thbQfmw5qOB`}5DKz5hu*%A)pFJdX3lp(^0rEG?q_XZ`9} z?`A2|vsuWR*DGP9>0`ZK0r05BnuB#bDhj)V$YDJ!gtNTyC017%vQ1^HB4a@CJ3}+N z=lsRunus_~%@KKfa;ppqB^0l~{3r_xz1w@K+VFVPc0%+ppAhI%Yn&?`Ev^f6wUoM_ z^s2-;m?c(;&1jRb>$V4Ug?xYJU#vBuL(q zs&`Q;1)di!N_@_lMKxsai{vKt8@Dev(4x|)>sqh0gNZ-^s@gR%j3#p$s1an2oMH2U zF=PBDG#6;Tb-J3-S>`rUg|(V1=mg!I;WXw}xI;Q82=>e7>|mIEPdfP5aQn@oiUQYV z*M~;zuvl-+rv&vc%X)0MUL)2vaW9N{!9+~ipys5=W z$eRSdMBXI5B5x8HiM&agFnJT0pvomg-bP2LdW%9RLf#}R?ZFmCxvuurp~0e7lXJ+M z05W2vIELz6vN10B1qnW0%w57cIWfY&y!dYGRfs15Hdx1zD&q2k*4`437KDpg(CGP0>cQIG|5*9S!i@j z`jr}Fb&UuQR==&rmdxL*8kuh9T-Iu?J%^YX0(CAi)2jbKqzs_f6&_NgkQ*V%!^w>r zI$?4nFXpl7tK_CyvI!5LHH#rN0v(z>6{Zs5JNVD8<%f|f>D6iyRK(xbf7m*_Ugy-PQ#VuYbLI@y zNjtYrm;Hn4towzkj#up%n6-j=kueU>57-Fx3VI-cAX-g~e9RZ{&ec!f=1|)iSC>a^SZbiGQg!EKb0LwH)BS?#Az3U! zNzHY@#KqkH>YLZg=RBLfxt0Jjp7H1i!7*7EZEZuhb)lnSH9l(O_^+0l0j+vPglvce zS21t!{st7TG5~g3_WaU8Q#Xc=>?=FCXT6!duYaqY89a*b09$yoG&367su__tN|-a96W3tN<()>YlN2FDMWqPChT;ZS6J0jIkx zupb!=B}fy)>P!>) zG?<^Fxt2?mYBOi7XzR6#{bS2;v-wDqk2n4YTtXTM?>|7E+@Icp-LFzA7ZN6zRbt$O z{y~X8VEp8LevMttLI)aE_8eaBj{z1hcN@}_|I;*MH(9#N(=2!nYEF`VX_h-O1cd5E zCLlW<66`|0w_BC}jiK^agvy^|+8s+QL4Lt*R7(~CECyoExDk~IC52I9e1`@&*HW?g zz~DyYOOg^W;<<=0wn)mbaB}7_;b5hsl1Q(na4CWi9!SN!ay7yoxUk}uG48JuTQSJm zAlDMf{qMuLzc`q2ig9SK7+mU~FT?^f_XV-8m30a8Y7386x866NJEIuXfjx*?RJStb z{j#{$`7*obU^eaf=)gsUd*^f$wg$!$9+<;v=Q!9uZ>!D!43XA~F_^T#?5?&g|7hJk zpYA_gSNPct8Et_nb&G8JggRf{zr|h=JttawP}aJtFAxcRk-%)f=6;S}ZQE;Dv*S8o zR(|)ISY79-4E7~r+mIV5({LR;rLed-!VO_dfRe6C z#ufdW1sUK%Q!dYPZ6bqyo_o)vBCctTAB7;|3@g(@RCdU1m}Z1%4`JVR?o?+UVlLly z_aLHjN2X1mpJ|&?wGo>sRgNqfM zI@#`E>{ci=_a&7;fXJiCAZ<-rK zntXj_xprdD+;2m$HYSv7Z{L(@>mskla_!3Ck1}`m$XT%`*>h`VqW+ex-A{3MWgoHD%?NPNY#aHfm`vbA|9*Y}AXV>Qh_ zV@!t6cQ^*4cu)Xd<9O}1n&J2ipZ+GS2%kS8p+St^eU@l#b$=X{;7e#;kks^O9O)p6W&0b*ANH0)xvlm{K4hkf`D*b|(fz+E`c$>=52~DBO zB>1}HsQ|C4GI3@&hECY)>$bbG*XOsH_4z2#cW4atX;L*Bk^q8awJ-L460Q46N;e(0 z)N2-IbG?O(PWe6=yXj*o-t3C$U^b4rVMC4jGzAh(6P+Bl4pkSuVdUDpV%lqa zMGZx|hM|a_D%P~3Q8sM%Gk#19i3u$d8AGJ7$R%tgEZwADY`i4+`rp4eh*dd|0~bbO zc|vn}LPL2%ISa^Uzjdu!an9=i3@EKz>_ONgAiG4XSS0LJ^I7mfjY7CCBu^)Tl z*-lq?=UuFV-BYfE+!h&2xLyy5BI1Rl(uRA-Buw*h{eO1Tp?Wq(#l~8WoMn2O-r_!d z9itU8?>iiw&`#?gjz!+v3`LvcIHBElcx*z0%!06JUorZkrDA7MMEtg+jTa=*0+cbZ z2^*c!+4G$29XS+tQo>UF~hcuW?G17;{0kZ_9h&gr35FzbYCD|nZiW8=J0i) zh9*C0=ZrUfhhjoaYvyORtjdodai6ICTErM8qbLE+R&s)G7~Zs-GDESG1}bCcvAvD< zqZL&X(tg=(n+{78W;d{_ph-o4PGo#Tjai1JhZXBDR!x~7YRW0s(Rc}BSO($Du=FM6 zhy)=T4_pq3{5*G`?d?W(AA6mGj3!KicH8KF#;UXzkz%P+CbzUz=vC%7uk1+Vs~=!? z9T`m^Y^|>tlhB4ERTJ&PBiu*hCFr_rj0hhAYhNJM{lRryX1?BTWb8H5A2M4;sK-!w zEsB^#%2Tgqc?m7h6Kz{#Mw7LEMM8^dnDWjPL(iKI;`5}Mj-T`5n5WZ7|FJT zR%ao*8bm3#=e}^;wsg@ezAXMo-5BRL&?L@4li1xHHT&b$v9mu|7ajf4>}EoZgqmh| zyAnjRxjnmeHbP4;F+PKERXbQ=g5%F}^7!4It5|;Y zB>53B-vpzbl;%4cUZ;=}z&iaC>N6&BQF$7~&0BE11udG8e6a}g$XP~?N;3p7INrur z(W!M!Gfy?{fmqjMV;eaen0ybSoI7FybaIrNl5RN)Oif`Jv9ITJg1qZhKVbkW*?y(P zI9PDu1U_yzpk4m!r4~#WFS{pum{h#YA<|k9$5ijM1X@_X3D}BZB#sg&N zMrpwt$vE)c{|9=DV`!DY#YSc*_isgVH~z&%fkXO>N=CBOI3?(ze#VBiFRQU;BMgVuXuC%_KJCQf9h&J{BUJ<4D8a_#f*0!^y)YvNFP*<|2-DY+bUKovd z`GRl7kJX9uo$!gXY5sXr5&UqXGqDt`%H%kRT*Hv0xY$rcG8p~Lg~(FzU&8K%$wBp! zqW-6<|7r4}KJh`j5|4bNB0f0&4#v#yWLu0-x1!i~_mN)YT-aIJG7a`MoKDNN^Vypr zIK%cvn`)G6DeUdm{GCnSYk!lNq&Bs6nmWGQ{JkWH4Jh$bdd>G47WIBBj?GHQY-u+M zcUz|Pay|g+aZ93t-qFj}mW^0%xpqfWxmI?P-{b7t1_|#5j#NvV5X;fn{`sReo%(|0 z+Jmov)F!`}547$%u6^?u$o2US#an$Zv2-cB$=s=0wE69V=ffsmY!s)Ce_Vyj*cFx? zAZOV?j(?uGUA+m*E_Z5wxi-F!@2|6c z<&`Q6$oP6H;^S%JX)f2s(Vnu)WFH%9U0{=nRPhZhw8CpXL3`aok@{S|>C_-~U3a(H z4+0r(@Ej8{Y1oG2T0@(pY><@BF#-aL&;+>{yFp-aH-Zjc|f zNq>aXAr(yK5?m@sEdQx6=K{)KvoD=MI|V4AGQsz86Vcy?L|X&c9eAUu=3dYljQ`Yi zgPfcKX`zpypNB)=zx`?FC-1cyS#0nS^7r-Ce3w*FPMb*l_ci3WqC3Bc|I``X+3kOm z&7UEh(F8nQefv{9ggT@9{!ZS90+0IxeDpsS~N{buDh}L~TWq@bX7s{6CXo-=x_jXClXT9u@zzu_jjGJj^2H z!>4@kcXkIp?CS96k(Qrv;I;J2C`7rmFsA2ZW%DPhRN;>%l2$?PPvN-6&ic+n2c{jR zmhW|bxn`UBcy_3?y3Nv}ovj^*B5|FEI(sFHvmKpiTs-EwsI&fHQZy4-;>eIVXOcRn zHtwL}mcW2IkOq#GxO<%XN&ed*GUoqZbz%J0$sog+W>>krE7rZ+iHUk=j9;X6m8B+g z!LIt@*cjJ&<@%O8K&R@>=&FwgU$`z{EC1_t#fIvaIp7w0zS zpL@^7a_C!Z=iQmHjc{tjGPfGsjR#}yB~vr3=h-;m^=?EM6yA@uE4q;wcTYTAqu@r* z{LENh^?2>cmIU9S1m10)#LNR%WV(093-iaq*G3pg_F;KMsffl9YsuPqMdoddicK{a zPSZq~Rq4+0OgI%t+#y8W=;YsRjS5ocSW2dFx-f{Oa>c*QSK^#=O?+2GGXAFI=ejhU zajMGLefkpT7~@AOcVpR5Azd-QrU7UoMH^3m#ZSoPXyv3(Ot_1I7z?+)(cZ`4}$DD zY~59u_NweueM?crmodv1viU*7vlY9K5QW4LbG!@9-_V30mzRWGbsmC-^#*4ZrnnrU zYvyuw8XfI|easKFu1@G_%;V=VO8qpi#7m*M=qqS7?@EKS$oSg{%*qd^pD~m^LDB>8 zhp(i4Or;HF{WJA4t1!`ZrRXhaH4hNcd9*7(*+e8)t6sx;($T4~qU;}iPnNO39q6OE z$|Q+vF~DY{ZD%8^(w3EVwCgRBB-b@=s3PuDQLc10T4|x|eyB;zSgGgp)H7g{tSO@_ z)$|IsSUJjvf84@%G82dL^;#t z-v#n-to(y`SY3BlBnQS;oDbjfJY3u(#$I7xQS2>9Eh;tsSEzxyVqKu$h(2&K-t|sR zz#-{vJyA1>2B zxZR|pG;zMY^DGpHK^q!K(1)kfvAh}+xBwqAq6oXJ5uY$(m|rxXV{oXptPw_zyF1#7 zj-wv@KWb5$Gu=BcwX~!txW;aX6I-)UE|VrG;tNkQ-x9s^(t$85yq=*?I_9G!l5HVw z1C7^ExI}j7a_+vSbj?d;ADYTkOlFYCJf4@#q#>VDb>EPApVT=6WFX+u7Z9A=W(n zhXk|6Xsx=#L@`;b7V%FOA}(rupV_)BTt?8GP#oxurvI9cash5m$me9Ay#!0wiJKZM zTvB!|>Fqqa>9nXjRF26GzSp`S@GtMFsh#VJ<1&KwU>2GTsJ@r1!s6`KaA^Wdx6jdD zIUT*UtUdaYlYRNUj4lM2qZYIqefcNRBYK`2(^ELCZw?GI@~C3ckzjxpDdlJ zftc)ISH}XZ5#r1(+4wJ?>Y*?C8k3#p(f;Xp>`FH(%~-rj)buUKI8vB83kNPOVmH`i z#VP$?#$1R2&V0yj#rE+$lQmQNDk#;P3>pu|8sJdalYvA>XdY zTI)kG_HazQ5sI;eW8m$EqM{`VyCQaO3{+G`BQeHn^$fuX33hok!%(7XpGZYijR$Fb?2TrQf}w<6x*b6qak*sXy}1>Y!l_aN?S zJW&uj^Yx2_#sE7()T+-)E>Jb@L(L{(U@0n`Ck zSmkEN>sGA$vO%;^A)$oMNXRCkehG28?7r+X2}N<$a@qTO)Xv}unHN?u2{J>LmC5oi ziGQxCo5repp>#cVN{~sGB+ZPDK4!$H1pDQk2^lzg=>$DW-4uvbK6KRSgd#xvhG_X@lHeX^DMFN z_>48WJYxV=+wINep31SeJL8m#H>loN*4&DaCgN2zAz0$((o)>jp_CIR5~XeLCl-e^ z+?~Qy*>qHxqbsECro-n~EspD@Ld=1y+@@$%Y~7X(cs&A#4>e_cV{m9q--H1G_J#~x z7arnW<5|+psGuv3ciB-c<2+h8cIN=?P@b`;9G`SzeBIb74I^*o_2X?3WN4OpHUqZ6 z-^~3KCpnc+!Rfy8j0UdHUD?#pTrOmr!WNw2m@5C&p2^%)u%U=XzYfo?a-qwfVtXr> zfYbyJEv|{GIC?>;zB;x3PH|%Vuy+&Rft78`QP~npcP5%2yS=&8OTwEDiA0TelNdBz zhUM2|#*L(6Uz}rUj-5%+yC?-iEr`A}7bIH7vNtG5-oZj?{OEx}WCia}3y!~=lOz>Q zSk*#&aQwqe?WULk&YMwOwKQ^)T*?8D?{k6mpm2gHQ2I1R)l}=6v3>Z z0JXoq3WN2pvAyeE`8)aKxyulN1^!k^_ZaI5u{EdZZw0Su5?5q?xrI7%664EkJtxy= zOzOw_WZ0GLx#DSJ!a-TR!E=+cdlNFIs>5+Yn zrEG<%SYC`adoNaIAHnsizYF||Rt9GXfOmdoV%EycL>kXCRX0Khlp?4mW}av_K%tB$ z&8BSpN2%@_G1a*hp#DNYk1-?5q-#syI;AAkyQb>g6o_;b60OHDl0HAv&Tp^Y*uRqa z7A#@#Yi$oKAk1bQj$5L@wENo+w13jxki}+@eYNHQ zc5U3EEIwR(Yi3IJ0YY{{iT&^PcevjbIf(swrxqSL7h^`Bn9Z?_CR~5YRUsNB~1@hnrh0_NK>YSnljmJ%2XOKO*N%z)WZA6e5EyS zc7@}XEngu`Q*|n5&G@QQ>1$%OryBNmH|kveR~?$P|1i>c+4~xYfB!?<{!bcp6td*I z5&AAIXUl3qo475bMc+LIRz%RzvN~S(l|6!030r34G!aOtu>|~T;Q3+E4DU}<{2_Wh zhN5f-5;K>o3(eiV9N0VS56n?KP;EP1rtLhYUA(WHo2~0sPl9BBhv3AVHEHGjo#mT+ z*XD4j?bvT*H)J)mHcD9dBfmN2`j}Hg zbHbR+CoCKN;j_V@7=atR>iNyN79$HQHwbeyR*W&qjzh0YN|lq`Rkf@T!SPo^fmhv2 z#=-GRg?7t*{&}lTUN$_!S+MFwAaDkdCeUpX)?xg~8ztzBa~~(a-#y7cRumDu z7H>l14M2WZO(g5&$6)Xd$b>NtVIYgn6DG)z&;fJ1oPNlC(R^jD|TmEa2zI2Zb1}?8yX?!1` zvOtgf+Mx5;*XJ2ttk~0(=rg36+>xKRs!9rvcqmxj7@Q$B!L%}!3WpOLa#_Iy4}FF- z5(zJaaOrzh1E?&*E)D{j;Y!QOC?`IoEzBH~$tBbn;6QH6{IksJEffBj)H9GI^suyw zT?}2K$;(e*K*-BS`0K5-qa@i6sQ>RKd_{1T>y_N_!UxCFT=vwO7b4UiA;fk$5gVxN zAhK@Xare&=3XxT5XlW1*Vz^g_ex=4FW=nJUd^Y5m(f)rHvLU>m4aXNLo4Fx{wPo(>1n35abcmwj^Csp~u&5~2yu-mr+qNi5+m;tKGQ?IB0K71G$6E*A$97bEL##3(&IjWBks%69h;cxS8yO-~;gsUYOBvowDI){-!^H&Q@nkoC zWQf0;5EFoyFfzmoCd36mTre`k&w$ViQXP4z!%<2d8F;0c-GyX#;m8nqCd5TRTr@I7 zx(RVH5EqXOF(@RUAkC4NHXNn2k%8L*rj;%syGurf_>&1S5r~N+LwHSyNkB{*8RB1o zu%58)&pLwv2KOECWvuL_p8gXHe!&q(-Vv?(5gBEFG{6R(kGvxz=X9x*rGP#J z=);kL&T_6dfj$E0qmhB$i2!{J(8nVK{RJSsz;NUl!|?;hJY(bpnn;;D+@;(Ty zUy5jaK(Y@;O!m5&@7IzZ*Z-OTUrTme9ur^`lCzSnmtOF(Bk$wky*Uc`$tHoB>nJ2= zYy@Z&lH-SwW9Bpp$=MB%UJ!8P1%~4{3ieA z8;;*7G6 z`N2XTQRkkm{2PeU3l2N-4iBrvqQi9X;gKkhR#O z>nNnkI2EqMD5R<}0yGM#dJG`F;HV?-=y3c-AzSxEavgg*}tQZDzU_10*ykd&=d&)jY6uHEByK#d6@Q$>}hz}w&-hiO4Gu* zjz+5N5g0$US8ij0m!?`XRWv8?%0`#@J zBdum$1n6sbNLtNAfXelP!E#65pjJJWH-DZYIfop1hlZid%eF;_MkYD$!nZYha}>I? zH3BpWU3xeIGzwk%SAg__4;^_Q4#RKJC^TwzB-c^s()b9_D0Hd!^KeZ^p-Zm=q!;)d zdH&(}jY6Y-8Oe1Ny5x!gjY5}-B0!_ir7VTtr;fZ&hvPR2jXHfYTVvLYI;wK%>y50P-Sw!RL;=&xhf+ zXcQW?lLY4c7-c)PIRZ4w3GUtq&?sA|8x?-19C@d({S%=9qim-xisU*9UHbeuq+y3l zJ)D!^AAMWAwrxbNsjEcWb_0yyf)N?Pu9Di%NlJPqE1xZ;$KEYSc$OaiCoJEPvu&E@V~j1U-0|+ zth{@k&b6Fv@0q*<8!tx8zzfcz@>ixt28EXYl@0%8cWEJ!OV(DB$;(hE$lf z2fmP|s zvwh{76J0;&>*d-zdoo&Fsr$iu2<~N|bE5#zjGb_8kzgdcMj@PznI8U5UHqP_~W~3_sN)6;!uFd}bg5DhOO|yEn zJfG`RGT6cGWiSI}zt8Hy#}dMKCz04JrtpD`)2@`z*Btzdk;gl@pK$hQFTZ1L2<)Qr zr~XOTie z^)w`cB=ScuiR8}|B{ziZ-esf&cMO^+QK44ncx1NvH{z88i3dAY-_I(kB$3}aG*oQ_ zPsODzl2{jHwYx6F2gM*lKuAhAMXT79Ylv!h20-YON&OuNf2_!A5RK>QJATP!oAJhb zv<+?DC#5e-_EdIYQAkW71)T|48^U6czXA>$_ob;u615IlX3C$1NJ&f3zld+jxYTZ= z`~sO|Em+b(+{N@e>YC)uf6L=~t7tLKCq1qXgu0)AN$K95jK{A%aHqt)plFZGdj4rz z-8EbMEBr;+T!lw=A_j&a$$8p?D~YI!sSH!oD`LvL%eohr6nhJeVs5ERf~)ODir|Dpqc%Yc z%cD&sH_LcgGTNurz9=!LW3&gKCE&Hq#_r&QeiH;4r^Gc zbE(G0wduxPlu$7tumtFR#!~enllEudp-r0t|AO3d5o5N*7=UBISQM3Z$sLzF7UOcj zbxR;Ohlr~j&1*Bpa#GWOftgcUMUmcIh9R!PvrY4-qva=|5K=ze@PuZm z+?%JKV=HYUh+=^HTmdj7R&4z~8o}bsti$bO6fzTw%m~+KT=dY(rlMi!M?_x-2RB&T z+`U08OZaThSfZjfISoDcEGfTTeZ-lGP8~L>$MIvq+Ew6h-rS&+l7$jt?qDSJMI>}K z6jC$_snlU=9f~%gy@d!WO^<1^Bal;EvT4;nC3oa2^<$pOUiW})4LWEiQAP{I;AX+q zqir9vxQIC>K;rEeM=s9ANSI*`AbHlZj{0?5l?<{;MYjKBe-7g{SUS>Miz3e(RAt@!q6jx!94MSZ(*MRGjPr^RqJC; z;3C!9OJ)iYb^D$ME`kZ&{%*j!(29k&v!>(7MQ;rTL!E|@=n^r)hM?#k{Lf>8?Kxy6 zkkmjnG84A?_IlTxSH;*;xubbqT#1peS;P@;^FCmiYQ~+9e)l)%W?v2p`3&N*fw?6Z z;IkVRAOlyvWTy44+l?u&>|l|$y%U#lmDW&ow%xv5c(WX zLE-%K$*J*)mx&Si{}M-|^ZQVV+gIH^Znz|m%ch?Qd~;PTks)awdaY;G0( zdg`6;{BAd|`re(AWp;h9_UCx=vpG&+&8QJ`A%WPsMsat${Psqp25gxDsmQlT9tgIy zsH}<*SqZ+qym6+LQHZj zfy1Gl2rjt?kSaY}WQZ2AlDI-ABeB`S?W!d(&pr1oTFK(Nv|p^}S*n_2n`iFC&@~S{scW z@UlalX^wy|b!KynbfWx)d+uu5(O5(>HdC)kO`G%IV8$Xw--e$Qy^L1UixaKy>;I8# zeFwMKSX{b4WmB_2dh$Dj-rS$YEJS+OQ~4J5!Q~bW?^_2wmEAaN#TS{Uvd5L_&3{7# z>@NdV!5k;ut4b~R)SIddY?|It>wKc_$ZGHnu+E;;6Mrn0f=t<4aaq18ni5@BZM#pu zWXCv0bsxIEkUaKGZ|JYH4H&Nm?0)GHt>zBNPr-?gHd)O9onV4iL`7iPLc6}3ePA(L zn~vc4R2iKTwJ2m~W$w!qg#G|4qe}ZG)_KAjvkGuZs9uZc8na%DS8a&Y3wLP#L5hZ8 z>dg;EVQO>?f@taVwTT+-16JpUu!@dgrD`CWYmCI~1GAQI)T^I|_AbX>Cgq5HpBkRw+RO;orq8U+-l7UysLZz& zpjn$u zGi6jMe~ip3&TGY?^V)jRvVJid@1Su6wi0mN8n(gId)?EUoYy^Li)O-%*&%96t&Pt_ z)0@t3K4WK!5h=<|L?+wSo9I6AmJX$LjorcO%G-{I)ep_r+W7Fp$hF$Nc}(Y?>=xsf ze;b74=Rl>u(KQ(S(#J~qScGJmKc0`?!rlIPs3Jtt*OUG>K=d2V5-JklUIFeph*)HE z%voku>UY9&a4+@n>c4n#SYq;}`SN~qo2Ul<|9@X?fn?4`B}LJeJf2LRu{?b@M_YP$ z4)W~f+0FAL&m%n5Jmow^JlFE1^Ca_}C7*7d-8{eN`7zHbp4)f|c&_G|%wyp>TF5Vd z>u-#xd68!?kHIs* zQ$hMv%9+YDo2P*1R-RQn8+ab(d6MTZJoP+nJl%`ImvjqxuDCwh@*RGs@=WAO=85L% zyMc0e;4fJo!> zTWd)PjY`o9)uOih{hen{GLuHzd*Ao|-}m14f0Ez%Jilk(&vTYJhuJ|S5D!QIWH=-q zqJ>O{rZKGvEINE8Z z%dixK7`FUrG8Qvk#x(upOlBo#*)8QPH(kycp17{QX%5JkEKSa0T2*tygu^6_v6Im* zhIWOtE2W*p4!d0171K`E6uU6m#R#S@WTIUb?FwbsRnRWXmrS%P4k0xZI}Pn}X~)7K zwzSKqT_No%hGNIUA-1%W(9RQyBVpp%WjEXOe^d({jigzVV9duc9XEn zo=oag?DA>nu@F19h*a8TFTt*uc7+GYM7vVj(WX)RutG@80-2yEo{-b?wM-A9w24e6Qq&mWuUjgUS`n!3=A|d2&D2v8}!j2#S>%Dl?Emn?2`=C7^nwv zGR86t`V<4R44eT{er6f;xduJkpywF$r3QV4fw=~*H@N2+xW&MH0}Bk?W8eV;3qi_P zk%1=+_NNUjHt-Ti^;&A+1CZ+T2}t$APRSV!eS45}XRtBo1v-JzU`sH=pf5G(dkp#& zupQiAf)1elH*zKcy}{PtXs`n~10?rcupyWaQvM4-x(sr_V1F8<_$ol^N6$dYzvXE; z{V}}6-|E?g_mkgx*#DFb~{EPif>`!8Qi~V2<+JX9`J?f9@e+t6W z+&gw`tU{+!QWjy3Q>x;UK#f8dr%2*<_y!fsq>LS_PgIiwl(A#u6*|3Wo~Vk`r)mw} zD4rxy89P>`h*gt+UAj76KbC%YE1J6-nR^Ikjb7W!6Yg4-POnwR>B(Qy?7>XbKFS;ob$}2I&gHYpao$ z{>V#z)&b@zkTH;$iSc?xY?7)Y!p2ODH8NApb!0L6OpPivo}5`sQYtJZI=w=#j*}#$ zDYQy%PZg7rz|9k>?N9@-OQQyWZ47S;UM=uw>I!$IDqfM6q?g2{rij5LaY?DVG%e2; zwXZ_^_V9-Ijf1Q8ynZQDRq0p8ZtPs?&a+-~EHM++T74QyuT<+aNs2gCvMNO{Nmgj6 ze5ypr-()skD;#RIP-{F1ji7 zstmQ>s9TFZ;HR=@VUq8L#?8txBO3hpjmONZPiQAll0L2t8q} zB3+@zG*@Zo-G2a+L}DJ0_^EVpTD67`YbNobq3)Zc(CLg^BnrL6#l%gn)haSkUk0y% zY79+nTw zAol0vOu;lvjoCAq>99|SIR%VksZ7gMFvY_r7ACFWp2WJrjtl$SusW8B*y8ZZbT`IB zc8AG>vdzW|ai(CO#Ck9~f>e)BuoBiv0ZBS}6555^FwrhRt5OM8w4Q>WfhzrQMN*oH zU4&>y$4v4!sIp7?YSB3{C?im7>eLkf3>NL>gGIayYj_zOmNGsiH9bY5%7{~G$Q@yG zjof3=EsWi)uv(_$K5Z>wrnM+-i;c|lV5Va>ZFC7pG~?8w(wuE%rYl0CpO1~qbZtnq zr|V6knJzhrW*ekI>m^zXQQL|ws$%X;i8`lsfN!eSA7d~~n;NIm>0p5^yHF;u*ueaz za-3UWEi_XmNU@n5Cm}euY$nHSrB6bEe{ z@K2j9NOpxtD;FZV(>kuEt-3w6Y4xopJE{lTs)rYKbGR9YqqZYKoBoNg672tulqQ82 z(ffUUnGSidodmuE5nmn+{AQ6l702` zM8DNZ{=~W^R@ceDu{-J2H_9W06U%0-i}uE(@TAiwhN-S|*D7~W7yVVI=q^%puO7eX z{&)7GzklbFE1m-Z0q zuk|6+FKEp}Z9!`wY9k`q5$PHtxzSopbfb7FERn7?QM|SgvZHG{jSWKfZ6IXd6GFP& zp!*thx_C`t!VUHj21bDtKV6%qv}yfC@gzee5Dml$qJ>aivmlh$X$DRQ$^Lx^#WMp! z;ju8S+nC7|hX-7)T;S1Id77 zL1sagLh>McAg3XvkP3+94P48E*g>2i&Jb6KC&U}#2MK~iLPkTB5ItlTBnLwNb0J$G zdmtwuC6EUYb`$YJoFL8+FGvjJCF(dLErkW+3?mZfE>XBX7MYmF0<>xtq0p0?Qjy8X zT_F7E$N+yS+{DPgRs@Ol&MLuy1m5Uw(!Xf!Tr1 z#e#8$N{oa~8HvJPDd>r?K|Nq3slf=GrP6sh52u7CBx49)rSo`nh=cp!@zMEdm0cvx zWYdH*VbPbEPLKIRvvji{#;b?vc z#aSGcB?M)k2>QW34&?Et39(W`ZroTVqgAZrUn*Gt7x%1{P$X@vICpn82FIl>5`KK#Z zu3DYDX6?H58#aEH_xYyHTefc7p8v&;odvsgf4OJxzWoQjI{0O`#uU@}lX66=_R<&wd*Qs02rhbElwv8G$v1{6_xqS--9gNKkM{D845W5jkvlRP>0Ey#C+r z{@G!d3Jr<+y%nVbEyKQX*<3IADnjYE*x zx`z3`u^GkhQR!lbX8~*(x3z^?G?A}=SYU&<>i+*(X{&tMvMMt-nVHc+p!hD<m~=-u{x01ltO?`bD+%Ez33*TpD`YW!il^IuC>~np=@2L7 zj!I}8vjD;=X~j!9rFAu}Efb-ukY6PviuQ@cKBI>G{gZf%OGtU7nltU)#&xg6xe>2L@xCJEO1$S)bN!O72IDmq{^*XR z8)}B?pr%nwJrfUQ8|q(7!?Yay`jK3#3Q#Ah+krWtGq?h553UD0fLp+hU;#*#cL1boc12(p@HFTOUIN{~5|EyM zDg%3g55V5w6VMBM3HAXkaqVdUXahSUf^0O$=41pUB4U=TPM zj0A^(qrnhR35J46U>K+ehl5iylRBX|NV}1*{F0f%IdF3a}3N608SW<65Q-Xb08@C13;46>JE4fwrI@*a)O+pp8Mg z2HFHn1nodQ*c8kHn}KsddvGb(0$dL|fcc;!cmQ+)Pk=4KOJFOo6l@JX043lvunlNw zkNyDKg6%*j&>3_A+k>892T%rf1cSg%U^LhnRDxYV4d@C^0o}kHuq&7gb_chBp5Pv^ zCs+jb0*k@kU05@U!7%U&7!H5%mSyfxe&w91nVdW|*h~K}#?atPRG1b-*OBE|>w@fHOb`FdOU# z=7HnE0uT#xRs>pt#b9l)1gry=gLT0tpbf~J5D#br_59<*!)dryo@uny=1)&*V2-V60bIw&LE8}&sx7)`ni^+h_UA^IVo z#6aYe7=(Nh!!SNU%PAP2paXaSq`AWJR!>0Map&wqod4z!LBpSPp&xJ^^z< z*4l?H0Bpc>pgnjQbOtYg9^mJoH@FuJ1dG5(ump?&=@^g%o(40(ufZAM36PFCbWF;I z{wbITjt6sKPsg}I=y4z&+s(k!&`|~K3OE%k1L@ur9rMk>m(cw|I_6t~))IcKu>)s9 zr!{~TD1nYD6xIT?HgJW$7W4uaf_@+!o5H}I;An6&NXHmDwk1NJ1JW9T))X1g=@>?9 z32Sf$^mSl1cm`YnZUOValVAb(F<1!h15blT!7E?^SO$IzR)EFeOK=Ei-NuI%f_C6V zkk%}9Kne8opey)2=mpX}IzR9f7zXYFM}vpJMDP-*2iJpH;8t)hcoSR-egdusOTm0_ zKX?E<3!VUPftSGRU@3S5d;nerpMl3fT7$F$E!*;AG_5UY?O_W&38b}12ha(6B1mhm zx}Xd6AduD??w}`h9Y|}kdY}w?4j2S(0HeWepb`uQHQ;V=3V48scB8e*EapL8EB1X1Li`fwTC6#X$`amx*iNdJ}~vLJeL-*N)5#tF8-cV&l7fM-pAANX5ug+7yMs#TnP36@w+A)Q6TmIdoxv&4dxf#u*Z&c<^roOU^e8Y8Tna{l zb3rAz8q|Y%pf~(C17|>w2K}Hn2eYA%0BJ4i4X%Lx0hkAV1{Q!5!9s8a7>RH#z|+u2 zfh(Z51+PF?f@R=A@CkSXv~2Ig@FDkU_SKa-~sR*&>7|E0iJ+938Xc&47>#WBQOZM7gz>83yg%`2dscT z4NOA3PT))EV?gT;J~-b+J;J^vXa{{P=!tMWK?(FJ;9R&%Kv(Fo;1uYsKriSqWDozI zpda*A;An6%SOk9^K|ORem<6VSb3qGm1$Z0W0%n7Iz%^hIxC1N(H-RN!09X!cK*N@0 z>&MR}p=SvlwX9yGx!`CU=Qif}cq{HvcJF6>K=BIK)ihTy#cz8zf4zS6kw=~4sLZJC zU9UW?iZ0BI|M+>+eo2UuHruP&svK9J*ze5z+b(b3p<^iZH<|gO+J|NjcXs;hkE036 zCv+vrTkmVl$orwQ%8qN}PT8-2*naZ)Gn$7t$G0AEZ{=7jB$^}T^^r53?F>>$hF!?Xn}j4weOBiQykjNm!19S*Lr7*W^}A~f69@Z_FD(hpprR! ze=TzL>8}+dN;YpEecZ;+>bHbu2hN9Y|MEqrc?)84ItyvEvH56!^Y!z$5C44iz*@a_ z40)$?Y)Mw#O|L6tSLf#yXE}^-7l)F11$AG1JnH?+K7-2?Z9l)--`4vp?XPzh>YI)z ztiQ44gQTmSZ7=?a_-sFDHA)ehqV7D|-syIjWf#xyyZ+KKENWDr_=s3qv994_gi~?>L02t5jWC5T`S3tc>eXV&FyoB z1pi>krnwz?@uawIc$iDWFMoa9B&vy>Ps!AG`h2^r#fWE4ULUh>?RI{+H+{n-#p^E{ zk5k~NQP!yCyo4jeC9QM%&goU>Q){*6tAnbElKVv+K3!R!&@`n^)0JHwcc0K?XyC!K zYw!4nzegj^c7I0BQ}aT-<6QPmMSj|`>z~{0y?XnmwxKF$h)a62(%+L0FZ3OH?&PA5 z!#3akw&Ra^D>i=7pDI!|xcI@7idpU6JDOZKI@R)P-y`q1JqxsM^o!Go+&^3@o}7_x zaBq=2;oAc<{nS&eJAmxi6-yiw7;nt{}M!T|D z{H{)ta~F<&H#J$h!72F5GdDCVul1KV`!eKI@r0#K%D&6)D+|3X-TXW?J-EY<+v`#< zv+Xu)bDay(HXFa`pcp>$eW!7Up3F>}SnlU~ZeXr_m6i3$0k!t!mY+CxcUa*cw|4lM zJ4_y`%MacXZ_h8^4p=w$qm~)?lJt%@Fs`aFZu4i%Z&Sr zFQ)e!)M}es>wyxlt&7i}{o|wSXKsXiSeQ6$%{%HHv%MQTPw>Zgvzgqf!-$_^+`*^{wZFSef__H&+O@TbqBmTa#3dYjqgjjn^$i@t8v$*Ji0 zrVE#^Og!G}yv?v{pJcB*zC7=nA?@uaR@AREEAOe^wsqf^jRNm%ogX*C_U<=J%GKX> z7@#dH_;6G9sJ4B2tVpXpX~@E#2D+s`Pg#CfRTi{Pe7C@|fkby(V92k$F}-@mR*{y>~K0 zEON?(sROUm`RUT=q|~WppWeON@A;b4*z}>EbB>jd=yD_ahWEMXHvw&%PKnAJk#@I3 zL_&J=xG#UaKeq0xAJl!yd-Tk;%A2{j+wZ;&TerFHJMzln-yAouZ6QsxLz#4_rqB69H`)$p8g+B~M`Q2&mOkgy-F2hFyIS-Qz3WpK@!~3N-L{)5L|+4S9%pj*}}Hhh}KW_g#<@fySL<4d-WEDvgx_BMxl&3v&r!>-fz zp)dO^x!=C2N0*>_4J_95^M38J@8zuYXx~+P)|d6y9ex}zWofK_LBlpJu5a7D&V7en z*rs`#``q*KT)gD-n>Wo~U;On#mU(7wZ^Ul)>58yNTT70+Ztd^YJNTHcI3f97t-Mc% zK6`9YwDwkbZr^sc-+iw8z;EBx#^)^TXKFs}TM}L#zT-~Fi{`bvZVWzn>$}Z$Lw}B) zJNd!hvu$MYm)QGF*Zi3F!|BJJy3UwJ*)MCJu6*&^#R>f<=Kj>xZfbnclOFDm_WU}( ze#0i}oFH9Z!wxaBCT8AsXH7WW#%)9IhbOOnRM2g|k38u4w@FrWPV6zWx;^FRCE-Pi zBe9#VT$_30UXMMB#EP7+mY;4i?((5|ZbFB#ZBpKA(y>*Ynmws29rWwDjT_R}wL7?e z`lz+1yJ~HFzUQ~{;GQOj^Fq1@o!r`E)q#Dlw@mEbCdj?}nI;1Y7QWW>x;lFQ2TSaH zUOAi&c=o;5j~8avRyRLy?(q82t^wON?>&+&*J->VLHv(~!z|9yP_%t6zAKfjwf_{UdmmutQ)eR%Ga)pzUXeU-$N zKkO_C*^pwLw`P_90{w}|`7fi6=}%uevd1SS?rPNOQM-En^twrsVrY@W^?i$Wtqggz zChYF>k_qp{)$X#o>y4FdDLrO8`>QeD!+WRy;I;0X{-LWbOmkiJbiMxg^CL^{I*s!x z8@px2oL_7I@nF{FEr&)9ID2~g2O4SXl^2uU%5ChYHc!n_kGbVKUm4hE&7wx74Lzg1 zn-}E#9@z1#s18%yugyuz&CcrAWcPKq?7(x$?^>2QFAEdWG)EI!VEtoRhnZQ{XJ%H7 znOQA+W?rimGq2sAnd2*=7S=tPMV$f60+%H$>xMJSdhakx8#S}CNn=*^r!%Vt+03fp zYF4Y^Hdf2_Agk5r9IM@^l+|whnAL7#WoF&P-psnGo0)YpU$Z*RMwr!Ut}&})Kg+DH zeXd#E7JJR=I$SiX>-dXVJ;yrc^_<$9*K0}hhZ0hbBbF@|hPfY_F_}+8mbDnG`&++R zu~WCCwyN?}*%_NWe7PpF;tkg;-s8uWDn38ElE;%Ce{C&y|2k>XNJD*ArkN#_ zh*XrWc%Xq zn`bda>iBf1o0k>+0{zDBl~Hq~Rvs^2*BS5CA07V2HcgJS<*kJ_887?5eZYM8Ys;k$ znv_-N9r0=S!VyoeY|WLf!Q4z7KPefh7FXv@P+G)=y(}R=cIE zr><#t?nraQH+Ww9_%EfCQgR!YTR0(nSxCKa_eiG)g`B>_P#&3c+kzhZq&=e&JT;Gc zA^c~NyBF=39uJx5Jf=aX{;c#T^A7h8NWb~UeR`+!_q|ze(xC#MgVLXZ=l$C74t{|j zGwutkd0$IEOaF51hWYq~zQp_Jr^$s<|37a1GH+UUs?RM0>K~GBt9bt#|BqVrXT|!a z(mjWym)?Bxs6{<9=%H4xhaQ#|rn|`MY z)H-BJf5LdoEr`%}Dw28*IdHVjS*nlMe>k5ik`7J$(Z^Tf-k*8YX`B{&RO;4z)6NGs z5uW|-;PCjUw25MXo8v<%^z3G9=N*$4FI)W5HRoqL!iIm0WN$@s|Lj_j@3G_|Y%fH&WduWsv#_ z($hSj^5d*;q}tq!w(Z`azGUwEeyDX?I_0ZjXPSOP^_O&M<-*fa?Uo;N0(Lj(&$62+ zUNkx*t+#mF!t0(6{h95n%_CNyk?NAZJJ4=2@)z@Nc>iW+rDdPEKUS~ohWwkCxGp~{ zo%v{5_HXy$o}Du>y7f6J%Wi(}{`T5P|9b1JW#^=ciyuFos%wk#)bSrxw^;gK`-pa< z#-qN=U-ZrjES4T?ym#*>78~Mprw^%aiymZ-Uw>FuN(f^~jcud-L zUi$8gH?afGp+5HXZF;@py!2)Rn|AKe9s9FDpU?&!E=ZrZUhiW2Zd1h9Xk%>b1!?0J zHtr7jjr+4JzJ(K(UXW(?UbRKt2XBYSsyFe_!~V~9+FX=&zp}f< zvX3#IYntDUjt|1jFw}6oFtDQhnlQ78EY0G&$imiK7nytTs^DK_);nAm8D_zCk&@4^ z2>wNu4&%B=mp9)D?jmEhb6w=pv0N8f+>q-cvyOZ#gcq4NiR&VZyK!Bl=D}sbzewB9 zxGvH~#dVRgc3c-(Ty{waFEVB$*F`3d=eo#eu3Q(H@!LfqyvVG5To=jS=eo#eAzT-k z(cBQ;K+O*qczFyg-NAK{SKj5i$iNV;i`>(a>mqZ}KdGOHoOOijA~lP-E;3rpb&-3d zTo;+!nCl|hPsKv~BCj0gx=62OTo-Apz$P*)mFprU zow+Wuuk?SHozC9y^7g@N3>mtKOa9w1n1J^~y{BT+bFS24i z*F|cQxh|4*<+{kMr{4(SMY2M!SCW-V=^A%oZGNq_2&5}j1t4AP-2=7;StEX}+!|~H z)fS}FmJ@In?pc6z@4+7Sw5pMSbe;1G?CF}U3v{|{=n6_e50I{VdV+Kh#v7z->wX|z z3l0M5ns+2f_Z~)r_Mj4^YwAfLotx`HI-QvUwga<3S|iT@+k>+}x^R;XV%bEug&3Ai z0`&^6D_|gdVt0_1SG2Su>o}G06C)O%lq$UJCteXNthtg?mGKlNNy(RzW8=8P*u+dt zDpq6h@IdbIT7!G02OFE&4U)pfVo?_hx3MXz480;AZ@M9$;#TpV=^Bw`xws6v=q0}t zy7R&HWlhFx`_LbEal&yv5RRJc3mJy17w$xWkl+Jw1s?&(raz-sm|@a*H!O zWv7OTo`#bMPmWbRUB|;oaF{U-FX97&QYB|$Ir4T>WoFepkMHppEmys3(@ z;r`(fgF-_D{h@5+loz=#hs+#Y__%cMAM?q-@iqi^j>B=*=8rp%^jw}h?lcDpcOh{# zGHybeTC0i(kHQO5#`|Z)C8g=q6ICo6ubsjhS^i`%##<;F{tsv$gL}UyH=SbATYPED z2b0KB_ju&)#pABug z%5w4iB?)%`F~1nnk2mCZB4VH|QMl7e^+|W!s9t!!k`3`HggdSDZW~^I^kgw@iH7j> z9v0fh8~l$q#HSPPq*DHPym~|W8bdq@hIHu3SK8<)UE0Q@25C#KtQYQ{Y_P{!tl&=d zO(mzXPc=f%m_%VDOz;fA?qAk5T1+%Sr0!1F#5DcpOd{z3g^B7xF16BzT$>h!UZ$EOK6x74?WlJhC6qIU{C#p%;bmqj2mo&1alh9fx>-c>N{jl zd7%D7=17>S54qtYr}#5dt&y zQOaj9%+ycGJQDjLXnI4xpnfa1FZEqF_@;c4yBmB{-ZF%8aq}d>KQ~h!7yVE_r?#ei z@~7wng!-iZPI;sJBnxE;7V=C-HL+dz`=)}0d-60Ms2!4J^+OsLWTyCOY*4*b zFAI$ovF&1o{PT2a?1*`w@gtgP3{{zV-=loeSQ71%g|-m$PwmLfI-{^O#;D#XY@Cqi zfoMsFQAF*?#||HNVrVK4jX!GT5%4LFLyCo3m!XI8Qt~HJs2|f9;Psv?)M==Wxmg&DVTBr#?ja<9VX_no@~|A3pZ@Ggf?D(flutA8wx_gcZjR)o+-g{dn%f zU}M~$_;}!B+qlp93gt~g>{NEj8y_>g?fG*$#_ekCU#ts%%r~_0GW!etf|of^=(D`c z_)R|i@G|3DU4og)%-eyNnbud7kE${lr^oZh%OIv5CiFZp?I5Amc-j#{K6u(ehV-bu zc^bUF2O7pHrN`@-&n^7F4L&bW8oUqiG<=2e&&$X2 zLBG+VIYo}WX&dqMcngT>1q*e;>ur!wS{`qhV9(RPj8OLSX2Fbz*GtFZtdal^EgAHaX<8UFrRF9T;nSxSt?l9uUhORhc&L$=uHn;dsjZgbCwM z%pVq6=yU*aT~r*g1dOE zq_v_Oaz=6TH4A4SWuuy=YIr@~bk_DwMD3==WHBEzEz#lz`bz96JgeT|{%NkPqJf_}PGH zraW;o&mT9dg|xYOoX`f`oMIG@M(79Jp08lJnSLoH#>x8-x1S`mJ@1#)Lga_nV``;2 z4)rP8(>ZLFxynEN@=dho@5A8n@OtOw1ffp3IZk^(WUk~xV%h%uhT_AkjCfd_+i~6*BD~GGnV;$Mb(QFc`V$`ye67mU;^!;;Oqidq#0q8P zW`1TYu21MUm9&XE@BLLe@44c+4nLdb=Q{k1ny+Vhx_te^`yV&+d~!4G>3Bfvbbj{D z&sXRdwETEVJy|@Dr{gQ>JQR1QwT`hn>BjDKJm#r$|5Q6wI-PsbwKH-P`z6h$w4N5% zmsR%0b1?U>LqDs@KespaPv=`=J&;)}JDrQrTWcu~d>zk^G<4p?$1(K}{>x}yr~KTI zUzOnJR=mCG`h**N(sd~l-BU-@|) zPnRFxX)Mz0Bc9unPCd9vr*d$=i9-6^Ouvp5?RCOfQXDZr)a`$ID3D!qS zujtd*pF2Wk2<_>MROl_p^h;&ENnBNa{+xbIaYi6M`faub*B-^#@t)nc#$U7lDCA)z z)?~DW3H3nNf$56zaKuV)V&=boCU?44%-^zXY^EGk)d%%P{<~@Z-e3B~JsnNy=tO_dLR8ppZDzqp_Y{x)il~^uA_Ar&EpE8A9Tf4e`-LQspw5tO5v9TxF3OEV33Q% zuowMto!r|BE^QH3^h@!N#xG3VgQR9WrAv$Mv0e{!>^>VjL9G| zkU~f)#M6a-*Ff*J(!d9m!)@z=l#I*WNI7gYJ*3=J@J0LUu!V9{*&d)C2MLM92Wxit$kximyBT zQp{cm-veeh`0New6yo!OJEhVcIjD+{^5cb2RO;Rc;g4`$$QQ-u0aE$p2;&P|HzB_P zaPdd_Jz&=xE^_>d@%2J#zCs=;-;}Ff(7Oq-P`SF}kK*%%TQ8V;3%;mUsa~mOJi*?T z@pVHwa?}C&pcs8|8`m5!G1_A%9d~9`M%bOYjS8x@o^Yd@qaHwQO!eT2c&P7C?NJ+3Kc+rItxX}QuBm0k ze)9G*V9pfU@jrK*Pe$JVJ+A-zjBC^O=i~VQh4DKX$90-*X@-x*Sfe{73iNMJ!Q=@u z&34?bw=n8x4EDs4j(qTWuqRBtD#sPg%Kv%e)U;k}o*QU=G{|I46N%Y}-nK+57rvIA zER5a%yzw4^xM?MdCqc2^P+?6HidiHWeEZ6f=6$}F^TRrhelJUJKB5(i@R)+&Hq25hgyUlAL}igO8keTde1Ra;iIp)wE) zKkLMEZGCk%PMyy-+Ij~+5%x>(PwCTEDC}8)4QHi3P?(A7Y%vM3)I4Y6sW#=akH4hL z5v=hS3&#=7Auf=-zW8RVJ84yJl!hIo7;eSY-0H)g-s4jSx3X$(6b4SrT58U$d+<1+ zR&GvGJX;6J)8Ti~hvf!)k4kqkoKD2C1V4 zQ9P8JBIw0NagiU2pEl#TjKh(eExr-TjGj%RFt*U`4R(S%dBkQMk8v1s%SL*EM)6Rd zqM<7xV)@8VO?fg7M{$+IUye~+7O-0ny`WlrfAcK!EX%(57BbAyU>Nw$M~p3lG@8TMhmhHjxsU~rOOO&sQ^eaIau0ErF9IP` z7c=%A;`gJpws@WvHz3+0-WRnPyNhseViysvKJ>^ncqaxr-rVhY zz7zIG=9{tpDDZwFC}Uy6*mHv*diD%5&^I!O@*{!S9iMg6J1d0?MH1L~Z8J02CDUa^ zp}y#z1^1UxnLgJu`NF8sjN#MF%pI??*DIWRQn;|gX2N?}+&w(v;_(<;G;|s_?!MuX z&e6Vd@-w=c9a+Yh+69q6rK#OWvP(3v8zjV+RLzboHP!6s=>$FOXdLsr3?eHw%haEc z##|G-AR&!xQ#-+5j)`5E;BP7HtSi$9i>5SkP5nia$MvRmLVS59c9BASTTJZ)fBCSJ zP`xL_tCc#;WkqI81G?iw0(u2wGB0zXk8|Cbx;Va>X3jFqL_f}2vMcUmE<8_c@Tbe* z!Px`YAAq~Teqw5pKA9Sv=2Ima(_lYo(j;XpJ*jAeIZOt%==8A>Ve! zc9Gnly@?(7=Oo(clTa%3S0mjSy0fu9l814D-2;phccBo@85GXW-<-)ncO`XD=k8<| z2|G!ZT@SL;!_KwJ&XeqNVYjr(t{2&P1(^8jO?FDyQQa8EfRM%s*a`iNj|(A<64)7y z6(J4!w)&O2m0^W6SPZ_&jt=B*Xp8JbbCyN%la6MyRU6ut>q7mwz|OKB{KOi*4wq$; zPhniLY{WxpX?5B-XOC`(l4UK&cjH%8+6j3_7kS&3MGmhq(GpJ^Ncy_>W$a4`ZKC@X zBlis-$k-mp+uREm$kc%wjt0tN9zbh5K8!S;p2uxJDuI z4i&h;yO4j8+=aiBgvx#e$MCjRp9pWsD2JKso`J?2*@@{J=X1hZ8B2rECb}EfL#uT% z))qor{wB^TGJXu31+ItCM#pa2#5~%-EHn5Q*9gXLS#YZ4*a!y$ya~vKCPO&aebs8ma)kY+QjsX-O1kmj*ReD_wGObwfp;j6aGpy z_v+;z@$#?Z-}uzj-MBv>+w8uDoKTtn>hasv{%iNxMt|*ov$?7JfoknH&B4^YdipYZ zQ}^oiKf%$|y`WnBEuBo=tH+<&-_*T&{qza=YxiFQP2G+2cLaz3n|ujSS-;iew;yL3 zzIy&TO*C~kj(=}9zB>ew2`$KX@k`{D<5C=hqO%zcBu;)+bBHUY{5 zfV(+tvO$^$up|@K)Z|G_hvtE7q)(d|?g0GL`uH>`h9!4e8`EYSw%CyOj}PKo>6qgv zU*!l_(?)J29!<q_{gtbg%>TW6v?3}^vY&>-EuoK*!U)E21U_Vx9A4q(6m%| zqp#@dU>8d6_$T+?f;-;7tV&Tv;rAZ6>qyZIS7~+l^~<1?_|yn|1UU+|538YRDs86l zrCNlvuE8&?|F^d2fmL-^8f?y{6q+$w7l@md76`C&!n#1*f+EbB4C}p@fmL=(VJCr| zt&zVX*m=UPrp-3QoDE0ZJ`iU}1IX{8ST8|JAZH;(koAxp$P7q4Bn&bDB7xXKULu}S zND*WsdzWF#a2;t6RBVUS-?KH8=XF=uljOCj4J2Ot+9<&Y;37KFBkNFW{%A4m`+ z8lr?ugv^4hf)qf`Ldqe}Amb3;9_hG1`a%LAk&r~lyO3DFgC1%r z42>|A79R`8H@o9hzNu*`^iw@YbBrk8G_4jNkT<;9e2|j0HnNQrzSIp%_JC|7GIh8$ z8L3WI$#pam$;9Fo|!3rOheyj6EA$44D*QaS{+()t5h~JRsRBZrenl!CSOlLCr4o+33 zC8+|1T8BGeVF$7N0rKWM5EusHAeG5>0}$k>eIYsS3Q(KydfZKs|Y& zOmV47RamN8=p#zYaD`fjsoT(Aj9sQ!BJ@gLwfJsgttfo+TX-iwOD8v8^VB~=@G^bG zsW3DCAoipxKRgN{T}R8%G`%Jbk1?UoVE$L9Bp_s<1v-<4f2KT0Fbl=Pxp4p*$Tulf zr(*94<^VOmWJGBSu`LknLU7T9tfJTg^cK{VTIowgQzWR^E;Hm*?i`Gre=wPg z$ksQEx){4Em{XH6WKcsXDmlAW%_fTdY7v~4q*wc7>haNlD78}Mn}~Zt=Cwp)WNM{5 z>r3hQr%Y69Q&W;L9C#t=n737ErJ9^VsK*)A^-zj~iWHftQL$^J@n}d#OuH~m8a)I}WG_zH^>L#U-> zQ?+go!i^GqQOANO7DFY)r|i?v!!!L&o#<6m+UI*BZ~cGPW|Rj`8K5d$}E_EZybwOO~ca5 zk6M&$B2-E7_~kBsTRc&)9b0oQg7{C1hmb2bN2qaxm~U2f9FQZQ3ykzXXNA7EifKq5 zhu*Ww%rH8Ic(RSdA>t31Z>lDfoW=|JQ7d_(;VR*`$4EKJU>}hdD^J7cIH<93t+(bn zFtnW)g+VrHsN<+8{G7VH<`6=Q$o+=n2h?JZ{L0X`#YZhO(FR#oG^$cjgSyl-EtTti z)0qi=ENexjruXvnQ|lD??YCeK(1oj%!V{aqFS_?zhO3hCWl!UnpVKH5pSMFSo6G`) zJO^VA$6N**YaVa7YNAT3uOV80ih%|qo-NX+l_Q=h6npTB1I9cDsQ6X-8 zRXOYp$1OgRmkOm4UCIA9!cj!D!Bu$^A5mQ@{(rF;aDZEOHLrKy2{*?)Knf!y7N$T!&1~Kc*OvYb~MLh-c5}}r3R~#g&H3Ny=t7& zJdLT{(5cVlAOs^wd=BnCVIdGyK1| z|G70l*GY`ep2_@0i?97cew6K}S`n|Kh&3d^^%K!do8e(JS8i0aBiUw|@ZhUzEXrQ8I7s2DUr{Z|w$>e^CbiHOc%x-W|-$=}Skr zKa7jIG1D`rw=vY(#+%%*W&`K6)uk`VBzreYU>^J*T6b-ogggIfKkYTQk{P(`ANrRY z{O^YUb8E}iI=ec_OrP0F$rdyV=8rGQiZ+EGDINs4UR(MC3Os7}M@Qh(K zHvtfDT<3dALtCtwVS!ymnaAPbM|(RYKg8i2(hEw0Kz zdk~Xq7J)WPVUK43LHio)X@2zhlYK2=UJisk&9gC}kS<8^WrH}ZvOJLTQ3wLUCZ=~B zhATDL`&BSPl=WC#K>4#PBK^2U{BxoM0Hkf+>4p;-A~)M*h}7G z@9`FU*<0+x{$xKKZ`Mb>!rmOzfEHjDXbEP6R$wkj-z&`r>3gMxpfy+wQh7?jdSC@; z12QxQeXrCOr0A*(2T0#5m4U+dN3gLTPy%{@Z9o~=77PQO!5FYTr~&Ca zrCA_-r!*Vv1m=RB!F3gFxa10m*D!>>}32ML;Fbm8Av%zU#F1QfP2Q83BAxNw8VvxQ& zS_;y4M=L=3z9_x+lfEx%3)1&RC7?Ix0s4b7kiHKZ2GaLIV?g>Is0O6(b7m2pkUr5F z=@VU$J}7R;4mLh+3g2R1T!TG@Duuo6r{-+ngXXM^N5rCMIxsSd`e^7bm<&XH#w-Vx zi2g0=59T?ra!g91ekt35<)Lh%K4lsFqd$sz*=o3Bk`(p1+Z<@VtJDu{cVIh=bj=qI zEX7DK-Qgg7uTc!YVy6ScBhZ!MNAGfAy^Qpn-45(yBi&_>seWLu1M6VqUbfGH%{9_B z2OMy(t}1@dgAUBvNcaBQ0e1ze-17<@n8rxAKkUFx80n=)9B_rV%Kw!j2UcpN?>Xwg z+>QK~A2ZF5{sijN$i3vG1M@M`CEuWajr0?z9oP&bJ?N|h!z1LC^;LS#fn^!#G3OoF za3g)s1qa;stn%-5$$ru~K8H%%}4fdlh0ia+~j)B3Z0=pa0AE%x786%K5Gk^d8q99UZ;{lR0? z`jk8|?H^&kIk1sN{SZ5>M>9uM3 z{oa_4Up?M{J=n;9p_wDCb1K{ain$}Liz@X(3r8j~axb-XWMM|SM=eK_{*ztXkwqH0 zmsvY9ZzEk&*U@DBW!H0L8;#t(>N^VGeH82e8Fb_EFKK9+zQ)!O_ZzG7%NjeHjF0Fh zrvCHn98KD@ys4?~(%e)}w0AU_UtAp=P3k|!(UE;*l>P%J)AYPsIkG86?t5B0vPnj| zXB$UG57~?5&4ylx#iOW)wRdD&FyV{38S}w2cLVWj)llq%@aw8!_zjF7qc=4ABc69~ zSYkn!K7x@pj|Gpu+H$S)t2y5`+5Kut`OKTEKYDj$tB<$$nKiugJE5^pKIu{xvaz7{ zxKVw|4xL;&xFU3Kbg!wuf7g1svQdeAWx)#en&IAmD}4+|6_RR@f`~fb(DTT>86v@qK7drZsx3Vn4A1d`or0* zvyFw>KKl@DdD1e+f%DJLQ{3{qeD*i(j~g7m^mPyDyg(KDrsLOl-r2C&=GNjfZJz&F z@Q1qN50gH+{e|SSpTF4HB|4%>z26^YH0ZRk$M0j`99=#5_R||r>&73NKe1_%`CQ-p zsf%vBTc>kma$wvySEJut_8+OQbKq%aY1iXlIiI#T_VS2F>C;cXY`v<}Ck@&!ZT#7< zuMgb29&+rn(obU>$fn2dyjY;=Agg~wlG|2QawJ}}^2LVO`ZEr7Ix?_t;OdNNE@ge@ z9Enw5?B4EF;vY>O^%`Zha`qn;U4zYk`C#&j=il}U`=yuP%AkqIB@cHEaoLtR;rFq! z>oZ2}uj4j!Qp3;vRyDJ_{q3@^8tnXhd)l$UdJXF3+k_}yNy}mn??2)a^l9DKuKQc| z4w)2w&-LNZert!8t1s`n{(SVJfxp~3Q?a_d!JDVu(_j4jPKs(lAB(8&FRx9l`*=}N z+V7b?tn+uJc;7#nS^Kw5BQ?LZyx7?M>Q|?(mUt!jyJXKQN?(;}9^2{mvt85LMcuVh zyv*8~t$1h^RNuCn-1W=VTi^fia@RBcJ02LBUoc_ob-NK0KG`*&=Cb!sQ>(s*1zvAn<&yTLXGH6+L#_kU8eJ!ti_>s!1%SX@7%$n4H;J{6? z2&u=587~!6)9!^n&8rt(?@g}t;GrWV4cde_d~??;G<#|54|a9-=<;kJ7H^5hTN4;g)Z)x!6VJQ-0UxiiG6O{3c0aeaHmP00_`ws0kckaW<-Zp>qsZ^=F7dq+W>#`rX-nw%0b@+y?D=z@R@T$4=G1NQ!=^WNzdQ1xWxy|wp6p967&^D{V3*C0 z8h+%`{(0ccDd(R!6(q!mo&T_;+y0`0h!5X;Q7Tv4e>kkIR-OIXd%fG#S$NW;@6SDN z_4w}2=LdR;z)4JrL3HB_g-IJP_7T&6yLLCZ?J7y8}o(@XTO{ld#kPb{GRl} zWUtU~=Kns@@ALZiK5JxgA~di?mxSXVbbfN=RThg(`e9*%SLZ_)O-<61ymzk8 z={xM%%ruMNio!n`f5vWHqdvVYTBj{O5;=Xrr4sj+87G>|U8(+c?1S3&xyOq(Y+k%< zMA5kR-v#&29qc!z@%;ji{hgM3}5ePU5((wMI-?$rIhgqGtbU?bN6FiJw84(&3E-@U0NJ(6?)5gU$@3ZikU6iHY+?oXY#7E zr*4lu)ojh`8v{-ZiEp^1PoiX7TgQk&cV};J*{4HB_R(E)Mm^K_yxQRGlC{eZ6?_&n zqvwtevv1kn_l}kgIrRSb*P0A`@vDdJ{qj3Qm3>b?2=8wfcJIo>ho678_bbnrx6g;= zx43BLlfB9+Y4D|n)4o$o?Q`IRjnVrXFE-2GGOC~b=?%4m5}Uo6`k?X2{72L7W&YGh zHR|5Hkfb;5_l8|kF3G>M!}{tUDYN>f4LZ2kX^-WykL9Ir<~SC(c7Lb)_z&-={Whst zYu^vFv&K2KciJ?tTb*wo?rmGRUo!Gh>#MyRrmdT?YwO-;3j=rG{&3!tW`jFqf3fB3 z^&hs5_}DikYiHxSdk^TB`*&y_HAbEq`E2BX&4b2FeSW%0#dOD^n$uIJ9%#67#SbT3 z3%l*=^ilB`r?49zZELe|P6~{nB3gu3wFi%pbJQtH+N!@PR--?SqG_rVY(~ z@VFNau``@V_dDrGnpZ@6u4Nv=+Z>l})(&C7A)_2Mru35E` zf0JN4``q?Bcb{x_IlpAwhE%gdvnBq~d-k%JK}pf;UhNvc&pZA`VZ`R2M(nUl>l^<| zO3uRzps(Iapk3?<0rJ`ZYSXR+qGe7eBb+)wx&X;b-Hn*4ynl z-Dd0e&;KZxAJAdwXO1VY>jH{i9h*PBEY7yyjaRc)oNBf~EhUWEhKpO4Gi&R{Ei2z=q=Pukd)ollh`gCAAU&u&8NMlq?V>_H@SN)=!(s-E3DVZ?a<-sR`}(+1aXi z82P>418O#~@8A?8)TA^XhuocGH`^vdwOCkB9PZErF zR(#KWdgX_87eTt*8iBV?Z1f(x%~*}MBu4b3%rIVUzkFj+*O|zeX7b2U$-Kp2FZ^b> zj|8b7*=v{MRh=3MC9WBSUM$=k+7y%!!QZ9{!e?VAH=o@oRugnH;(KuI z#WMUm8XhZp5V*pMgfmsrn^QeA-p|#d(E9)OfuKj3Xi{9>s=Lw@pLe;3UlC3j6EoO$ zaYhp->x^aDMd|AWvl~pAO=*}(k>Mo0^Bsjz%OOE<$w)cIyaBMGq5|v6pZ=L7&g!D9 zu97JCy^@h~Cy99`fP!<^QLhXm?&q)T5yF0*?+J zdNJAi7PLGPXTr??fYE5M4Bg~*R7oPz1e3wiTjPX75g13WpHoDw-|jQwkku)_;nBYKQ!nSQF;@RQFDb zlj~i!L5z7j1hZ5_%!3aM71B<4)HyQTt(Df>aJo>l_i0Zp2px9V_zF1C7S*X;!ZJW4 zuw0v+u7VJ%Xw~qh7X6Sr3`Z`4D;^C%PH5jd!i%85lLNgsvN;9aV1s^vi10@ zvLcKtXWD-v_3tRK-K@$UdxBYtcTK9V-0(~Nqvr9*eX+mcCBdw$=WUSE$>IaQe4DMp zrtaX9N1)R?CuPbfC68VZ%armDZin-}%b&G7_%10UY%0lGRH^s^Ez}Y@o!A%Omv6*t4%sy2QUSSk>vq0j#kj zH3XUNff;`^(IfJ96Bn#5MXM06Jp%0LxYgo$|8zjE~{#`swHZ)n^vQ!?S8fQQ zunueZBS~iSn8g-=ago+xzaW{Se_FIzK9gD6s-+irrU78Iz*^Ah60+-r@uXNfQWzi6 zs%yyyU}?Mg$PQyQexMlFwGekxh?=YyF^X@csmi!tR%$J0kR5j;Z3wDRpjz6<{)GFh z<#aMRJG>TEuauiWb})abj1?QW@@AoSKSbvcl-EkS`M}_aQa~~I%I8GQM5%>y^fx`D z`S^>tRFzDdBxJC`e@C?OI7%A=>e~~Vh3JJ2;6!y;H*Q>lnTKzabGMN3YCyVkjuA=z zha};Q;1O8kFhT(Ld?|Dx!q-BFbaX(>YaEeW8oxT9cb&fk;f4sdk9hsi66*@*b#@6h zU{k*LNo9Oh%osb>MWmFEj)eEfh22Z*GW)gV1{7AQoRpOP7QttSv2&cak#y+pOhXo5 zag4OikBT)-P};wfH?9PK6NfPJz7fi>pjE5OA+5+!#F?N)2HHxVu+g3)f);UN7qnu{ zoumXFaoP(bgP?KLR9|!g8n+z>=Q=saVdOh|Wi5JWskQ|kd+%ZL<^OaH88JeS1xJa+ z!wylAVYzn7p9>@!c~Lr8xSImL-!Fz?Dta2KvX_VvVc(%HYM|8{TWSu%BT*xxS!@d? zpxl$JoMt#m{t&;Z`@WBcM6Jt64ID9Hk6X!z;ehERDvhxm1`v1@*<-?MFPLC%uu&Rt zq8^u}?iIiZu;`iKz%0( zBS_X;U9H=5j{L}Lvre^WO#y4c-D1i@$*fH8P+`GKud4c*e_=G;MmMw8VW>CrY>4n~ z$)}sh#bdz|JsGr4o>Z{DV1!55q$-ii$^?Q>B<>UNs;I~!+JY!uPK|qvFfU-I7FK_o zV@8$Dw2$|gD}8-eo##7Ls9vq@nB4MLD~1Gx60X`jE4zwJQ|S!Iz}-=WMFp`p3oj73 zp)g2qF^Bf2eu=Qo1X-`P%do_tt+7auam@ERF4Z$kgs<@kK}-*=G& zk-_8yLLfkeVB%cy7%?xjC-|8Xsu6Q97G5=O=h%Tn8xa={?r6OTanE^TOo0-!k4!j; zKO9uypv|q%ZJUZ>lP~!b=@O>YBw*l&9bL1?=30g$Y1B-mVWq|;;e9R)9l|78WHkjs z3VZth=dM8rI$lcIT=JSwo1L|dm2H=MtY=HwqSvHkuEu{}09!z3@DMNtPVFgu^ZNF0 z77WqqRgg*<)2idicCbdeUw3=*@Ud++X{6Y^9Dg5mNaG-;9t#Yv5hy8;UA=wU)>z_R z$Hp42R!f0Q3l=n|;;kYMMuVXG;}^Vj2aNg1UvdR(fa4d*L4rS`Ze$4cziVv5zRNC+ zZ$m>%{YuR?-Rp;=Ez$8!J!^7yqH`~S9!FOye<~D;Z4;Y&23YXHB|d2R{5(V&EF6D* zU6XPF*#N7-Di!(m!X|~Qy&CL>j=E*|m4Gj|kO|hL>tG@OqGEd5u@%9{I?$1h*C!NE zVL7NXVU=+KcBPn!zNBh|EgCRLj$sPhd1&UHJS6d6=f7mLGz40=&ajbH+>0H7yqU1s zkc}5@5=X`{7Eq8dN4>SXx2NW);)yo;?{{nLWJ5hwmP$Mpkm)g@|G(Y|iVDSwrlLfFr~3ZF-THl)=cy5$@qf|CVw> z9b%_)rV#DD;rdc%x-q`ll!NFmcY?vYYCYQh8fSHw{T=NxED4o41ev=EKF-PH=kI5} zc4$J2X#lqp*N<9k03;(-*oFrkuU!zsvZBhB$BQf2qWd9!NaRRJsK7`GQmbq{2=r;+ zlE)J*7|}?{$|5!7!7Feoe(jGvY0{$DA&=*s6lB!ONGJuCuG|a-p96Xh5eVGp@o5pX zgLy{Ku_rnjq<6THJOBhU^e|A@wL0x3O_aY-2r;yVrb&@7ydQ>-11J)NwIwok<=7)1 z+F0L~^sE&^hhIV7G2&84A2z^Oo7n1tr$e5Jg#o`j`p2XAx zFJUi+-n1S6Y(UxhmPXsS#3G&qaS8Mlfj_)rmxf;(tNSWjz3S?eKV3#qsXq;-&jg6Z zlYj5MMmfd@?$JCugeH~bZ?X1fu{aB6x-2R|sFp@pwEu0d{LgXxZQ8X=xuuFhJ5>q5~mRZq~F`e}ZdjN0HCM1{KjDQy^l;bUaX-M6cRlpc+ z7GR?#U|}^!jT!|qgrOOFY<)=1x>r+^z(+*#(Jkr@B`<(Fa4r2~8+_G}n}~Ls zX-~`cy{~0@HMZs&o;%a-y^|?k5SunPCvU}vmfH1Z{NB)TG3+)CMzi)A!^ynV2zq{s zdES!i#w0*B`(Z%VOtYhQ;O3QV8N$_iz-0AraSc`33>l1D(84c3qEKkOZnxYlUE;1p z6wYkX=#{4KFgm(;%Y_0^9LYMNi(tPZS>63sMnv=Rhj*rf3E4kS3u0(i=%DbQob%t9 z;qoGl33f}fYoO$f(Em+(TZo}ruXc<-nU=IN6Vg;tUQ$sC)Y6ke8(nrd0r#O$xM9Sm z-iz-eVd5mK4iE?h^CfY~VZnEFQ9K7A#1x)_ZEpLMHDszqV52 zzER?WxL^V~HQ0NEz#X&zPlTd*0c)z_<19=6@cfZvO+@=ro^L4)M51VngJvMNUWh>- zs~X(KX$w#GF$GPw{&7wi{fiR4uxs|-j$F5fvZxY5CV;mVOagwx$oda(-V?-LQ#GqQj#T$$ff~CmBrQV=SdMA*Cm}T^p-p8x2UkT9^~r z`_8}S?!dP&4#dy)m$GIa<9U?L=l6vF`NbG+N61-ANk-vBp>lYu-COh{_7uH2WpLpA5saK*eP@oc zs1U7Z?hoqlKX<>TmY3w9CpJiYBX3(Ckr?_UZi>T?B`4kqX@v50u4^t)2|zVEq?&R~ z+(3RA#}vqkM^=?)8GHn*AXZ%I@;z&e+%AaT<@PsBm$1V#RY^aQICpaPd*OV!r(zNU z7dA+6XY~LhI?)cdPhvH?plzU+kE2Hv+MWr^cF`Dl-L4M@BR~{S`&?bFdAk=r zu_71bSP)VtAir)!m^zG;id!2&JO?;{v%f&>`tkPYZO_NFVyinfNUw(w55Lk$`TM8i zLSSv{Eq>#K3N(4M$s*%q&lMy zi4<^$wqQGzwi=zX%SWvwi2e_B(4QOpagoI*${vrw$fnxjegf%mVjJ@gGYjEB@UZzU zSq2Vk_ltZw6_<&@|Bvq)?j)6>e}=2 zc)7AAOHxfrpeKV%$5MnMs5Y0bwtR4!H{WdiwsZ(|2;iMc$}?Zu`|rV?bq)*u-yBOy za*OayXhlF0BWrc*f_7c1)H%x z{H$bNV?o7|()Z5RlOv3Y#i}$RJI}F`Dx4Q(f+!iFep$c4!({cT#9^L;KYd93xB(vx zUiUZsC|b*nbi4Os=P}_F3VwMl7PD|Ywa#M<>B~E+D6v!G>IGdoBich*I0|pl`7&Qg zj%3-u%?v7!#g(~9q0pIz>7fBOii7gl5->kCEg|h+ zYK@WqHe2%F2KC=+c%g5rw{5RK?rbv8Sl7E-ghu&(?7+Sm6Q*RVSXhhwMXQ2 zj%#{TIp$UB?2y>Rk(`0b7wR*WJj6wIa3xH~a+`RD!|p|NRn;n?cC45VarCclu;q{y zwtIE~(TpM+7Dj(Vd#i3(@&j5Iq zoT;bG1xf0OY*v!ROApZVQV7~Fb(dx|PY&3es9d|I#Zj=T%w*JUvYYlpy=u+9%1g;( zVGyofBQZm`0~?cBZ;reSD>R(Oe@GX=UDdKFqAte0 z6q%(igU~A1q_x8O(ZNVGlC(Li#)OEQ0P>AYoe=pfQRW7)vzZs>SmMu=@DH(*hGQVi z3VAcrsM*4qNV2`o09+@Tbkm*kWet~tB*sabX~^+cV15?BWWTUJ-{e;8RK@aP9rZK+ zqaez2{&)BqAMjM51@;l*)}{tFV?RGew)O8fWnjjdzcG8#jCus_IfBFC}jxkEg`#I-7&ZJT_Y{?}D%Sg?s7`yx35hIU>0gy16Fcfwm*>{yX3P6o6~yV5*8S;xcOO$-sGw6q=`3W{(E zG%d87+A!!~wNJJY^QQV_wA?eYwb%ZaPfuUK?=o#yEYJ97t2+~q>cM|w8Sh2o5scs~ z70=Y&#HZ4qY2L_0Mk(VT$hsWHb`9!I*;Yd<2p~>i5-J&sFsNhgAq+96)JvmGJb3xG zefgMj3R5BUaLgNC@`H%U|MpcsMS|}2(MH-`zm0+wQu9FoR!i#&T#x=@aN%B}m z#-YZ#a-YwegC5;*ki+~}sYRoCbc13ZB(%9wiGAFoVONtdfVcVgDV?mc?ernQZ&&9VH|UgFZ!Cp&#^aN6* z0w}%X%dt~kDwbh^A$yE(dK@pu40NPb(q^@)0@$8wm0FuIqrXuXH@!FdnSN@9kM2Z- zK4TK>#xK8e7yln0u-;ysT`4E}M0VF`E#!@mL#Cz(H)1hMF4WiIXQ zWC(yx+nEPd(Bd#Q$PF_-~m*Dg^Md_RS^Qx~3?Em1=S{z~P+4cZD7YP5(u z3iX=g3!JAYpMc^R!vP9}`+}6u09k8fxH>{wq@hH^TjqEC!~Y%0Ei7qzm!sxe_`}s1 z5*4il;hh3n%O3tSu)5-Cw_+f@dOfCa$s-PzWmunF@+O3EHjebfN}vFoG%-#Wq;wOE zXjX!XwJYwswz!<8Qn+OAvdi1$g0I_u*q|J4TE;^0NgYMl=qbDkq>4&{C6uz3FH*(Q4> zYL~QZWjN!z3U@-?@QJ6}Mg+l#cx;EN551fG=(k})YVp}LboAVTM#J1L8bG{uIWB~f zwAoZ$6>^vm$S54n%~WakWHbVVzYlttm7S2j#b!%r)|RjHga4H7s&N||Cg6W# zf%(HDomyt?HYqwC|3*0*e%4O*n*RY^QO3l`UGZy`F6AVr6zYjb4o^CaN8&alsO)%n z`e>E2v6uyVmzD*-g#_5{wDy%)*ak_8xQnNt6=GNu*kjQsE-!g>9z16+-T96R;81N1 z^_&iYhRDsw#m=n!x}#Z)-&J8?&ElSb(Zb)g%dscjBP<3ES?*4a1$y0i{WB2s-T3Y8m`ne{>_<-hAEC{gd9`iVDQ*_4HW1u`RWMulaP{aW39xmlxDVxh5}ptkqx_Uz&&{=bdQ_FBU zeOUZFV7!3_CzKgN3e8^o2YBN>G?xcOr=p_M`f39Hy0VWAASk?kb#(<-Q(vfWG3eKK z0)8z6aX4GxHBx#+aY6#H92GZo#3oze$jpkU6_qDFB3r53z2d zCui7W=er=PgYzjtys@HJABRH6xP>=SjlM~! z`tw-x$2q_9$5#- z+@9`8#D}R}m~MeXNs0= z+>*0vDel)hW<;sCg045M&QXk0hV5XMb~$bD*AxX&d0iYjKAyUnqmk*SaId0>Sg*rm%@7Au`J%i!!HoYI3Yj>5}O1mDY8 zY|kLWmdaglE6@K(=JyMhj$zzropFW5bI7W2sdf`WS@?9EFXvn^h;N#Wau0J*t^to3 z!_@g-pWJ;!7`ewHj*i#GU?YmJc738 zHRtJ^Bz=F~A;9hUmd7ZIVejolfX8+dLBuMy*&yv-ieMl7n?5XmQM%1+5*yNF)j>(w zAc)I^(KGaYGH@E9#`*UQ#O_7W;)F=1Yci8G&$zzd2hR@@Mz=6*gOn_yCsR@qo*h1~ zQ5ko7zrf`kmfS|&N)=@_NS|V;s(XD|z9%8YDrMnNQ7vkQ`)r99M*eskZa2=a^I9CDo67#DzJYwn(!BdPkE;q_mnrk3Ujdd2+x*Eir3N5Yu*%l zM!+vLe0;cePq|IzP~a)~)52=Q6}LwZ>o;n^1G`YyFZuyq>+OibBPs`=teSkXo^Bkb zV}R3&qBuCx{HDeBye2Z#$!+ZS_X|C9i5@6dO9N>_zba1I1!Gy8dQ_*(c4iAdObp`1S{_giqH3ddIRPH?J0%693y=uAo6>+So zom~t0j>2O35rTV<6KV7yd<8kECB4T>V7YTs#G%fn(GgEG(uo>B~WT($fZM2h$2 z1$azxMafT&A*)FkU9iv|Ws-4tgOCSPH6)Ya8F`&7DvcII`5<%K9AywpT922Kh~q3_ z9fhXEY)J^}(1nUL=0*VpKTF+UiF*Jy^OS-Tr-iAJNx+Lkkyr0W8EcPyT&@3Mx6ODb z@;oDTVSl}I`_vYyQ)zqw`#JwbD;+in;5#>F>+kAF?rVlY&2)%Xs`UQ~d7_S7uZ6{i z5n;duK}ISG6BtWT_>?txM=@{X#OnZjht_(F_;(ex!Oh0FrZt^*E_ z#DRLSBfct=!5te;g;1(pt5`v|9&;03?B|UsjVrm$H1?kg15kO@Ow3Mhpk&6M*T>WC zF8(77Cn!@syjB9l>Z0x%(E0}jMRb_;D{arYuNaTU%@b(#ah;Irl6=(iq$C%@MFSJ; z-oDX%gxS#9;!ns>D+vt|Wdmcxt@ZL)%8zfSBUQ_&(1EC7QN_g$vtW9Kg7!gvlyVoo zdFrE{%V;=(hOC}80~O+cKYL^h>+0IW$afprWRagf@(W4sGz0L1b&k!mbw`XuPr5_; zy*WMv)F~_n5WS)X*D5XeHfO8+lsIHbul?#E8wIJmMK(`!ReGRJTf0rpUyp2WUv~Ch z_*hfqNBa!0KS;r^Gm|;@G96`0k>7;!osKy468T}dgLF$OGM#j8-M$I+-kh}f<50)b zhh=x^&EB!Xv#UvfVYZjjNY#7I`4CtqX?s-?=8Q>1A7h{F7du-%2{Et}GW&ddWSc}G zLD~YW!wu}|%uj5#bxHqZ@xnGnlXoDfQ6q$Grz}vN5>^DlptB;hz(@Vm;QVf7)@B_t zC>#f1X^CX>#KWU$Af&*j0Uh{2EizVq*ECp}70~6kCL-4My^Hj8lsn zS%)814?C%IyY-mF4pGT|1B%lQ_2{2zPN}uC7USMn`@9oFKA3{&F(_W%la{$GXy?Ej z{?1Q)xW=}~lAy@`k>jY$Ew#8{JHw4rYxYt|?ZD|LTSU@s&zkJ7@hUy`veDmV+oWnH z3fUm?u1STP=~UuiRKC&^!J5>Ztxa9}Y1mYH8haWP(Mc&X?>UbP*nsnW;BaMQd}3Ox zc1@FGg%=G;y0-#A>Kl%tq|*V{GyWs| zYHV>kA!!d$#xWS(WbDK2G-XQWYEPSoVbz4e61;7k#C}jMS01=TP$T5k4wVf$-n!p< zne$PZ&ekAjRhpKyBnyy(ZH%cO8B-7Jndbq=(HgCFqRN+`HOS${$th|e#N z7f&qjU~Lgv76+TeQ?LfN`}du$bvE{QCdL^K_!TTQa{~lS2%WeV71W?LZ~;y3967s_H4vbST>Sc>)$e`?{$`&YHpCUAMn;25SG0W4kQRTAn$Uy0CEN;KaHOa zGtkUJrjPfVx0rdnuH$GkY$&=g^&{B`J7@Q!wDLnZKOLw=;k`5TXeOhvWZxN07rqD? z+3TD7p<=8t>i;oR@AxV08=G>>joMn}tGDYL%iat2)n>7^{zINg!JcNFXL8RY&W{B{ z)+uqGNrQ~sYx+^pvUUi6GzHzo>GWVGKTt1wC7rhab!p)vqru|O;NGGf5X8u)tMrG4g)e{Sn73-7Kw(uY z<_r~9=8dsU-K=v`^OyZ$aodmD9DF}ht{Xaybva@|EndirF#c?0*^CZMnOg$x4Y#2z zdtwYJ-@X1FU|}n~xuY_60xT@D(GC&~->m68JfaYx!mxpoEEyg+J>!ea<0#je9FN{p z8{yK4u6VYOn1jZuuTi$R_Zy>@ba5Vsi;QVu$m0H-susFd)flzP0bQ&xKk^W>OWd@mdn{;BWxN_|=%0&t zeam=w#J;>ODuvOt@hbwR{@3TO;7q4C_%+xgTx@+GQvdY@dQ%5$W)(qNP0dXsx1wm) z#Jo1_Ra={(Lq_i%jGoNq&5?-2H%~Sw3Eo=P^ca*ju69CyWUP|8lT_PfbQZSKuUqbQ-QUv) zN4b4_i)%ui$+qtp*o(vv-Mu5$nM$-R{!Yg=SxIz%bm>*MwTokHLFz!*Se-$LTdo31 zg$T_NP;v~CYNy07S;Qy735nZ9GQq2(>d;Cag1`j9<9;4dss}@ATsg4%fkO$k*>>qL z0oLT;R^pP5;`9}nfNY%d@QhW}fQqDq2o3C1nQNdto!*A%bv=bpj?(>k*+N0D+E)N$ zyjqAvh0va|-XOX@OM%C&sT(k3f=dvI>RRitS(g>SOg z!p-UTsLUONK$&uQi-DTnyMD=-&v8%CdknbyV{=3M3EHj3!0R%+%PU0Q=(Q#8`tlq- z|CKN(rPxsZ!d{`>V01qGli95%@2s9%3k*Xfh-Aj~qY+;i>K|MU<<8YKe0u(;0LqYS z!2anr`PE3cplla03rfQnwDi!TWNJlrw;=&VXCE742yuXErk=)&hS`bfvAqM$=anGi zIJ3Vf4dshLbbiMZCqg*T1a^q^kr>0+W;y4D>IVN}u6h+-VdUicUrtqmh7fUV^d4JI zwp6_g`p1x1`!&HSlg=6S-RRjDpL@ioHH#39BH0!=!Gd+gOWsxw|kp`Sa z?zR}Ypsn?(Tqdgd?0$FoW0iD3HIpMAmXVC1VM46d*43D_@b(o$i?T`@F;Ol#UH^qy zAbc}aPNfxrZW)6U;gYpUxr&NtB$NGx_%$gRKh>BV4=9%+U@=~gdW;HVfaT+N{&om= zoN4adG&Hxlvyo?qNfED+!T%k8o9`OT&%jC)H1SwxbJ_kHA_ZQxtDd)*(ktz0|2gCO z@mXTWHcJ@n$Sv<@rg5wN2*dY3MWbA7(Uu4^XDjy!BHs}xxfJ-)_SS=ZFkU2Gw-iv`%o(}8ccwGP~SH*=K`T1!Nr;=n&H*ThhvAiLr1i!&h}_^_9`@al4y z!-Ed`-m7(3EDUQyUhN}gs)h}%s9z5B6445)MoIzNzb?ZxK|zl4FHI^tER4d*xStx9 zsTMZ@ME1N0?&0eb{W;s93je>1uMLZLH+0#u6Y%;;e9G|43F4jvZr<<4x6rF5N zNksJgAJA6^zm7+)STWLN!7q4<=Z3(7^765?0894juSX>~psdLVCa;LIZH;dls7Nds zl8f`h3anIIjxAfD0&)Sb)b`<<5*7@6IG-B^`g+Vcqy744m-2>&xtr0RV| zzF}q{S*Z)!r;WRsFx5}8k*agb3d6G|{|`n@$JV%}pggDm@(9s_C4S#3%2^lpg}`d6q@-aP^eCC@PN?n4zZ?zZt#Kcp}F` z!YWL`S+Xq@U=Au2{RJ!LMhBEC2YhpKy0dDFFc9tit8DkY+LSq+|79`%-b<`@5h&)W zFw(z%hla|O>WK=y=_@_IYGan%ZRzvUW@U>F7%Jf%R$xa>P>Zj z+J=;oLDiU2&XQStTfFaq)OcEaS~W$0^!7)vzcnjNo0?U~0{b_tCjg~nr)swqYAPPF z!zh+AD<&egAmMHlIsZx?zniS|=4Yd=S0lnv4U4Vjav~eDsHt~WmM?DsZk$mWmpMG@ zkUI!)<9$#kq{k@iK&ql9T>qKO0l?pc#-vJ4U@s8Tp<1iI%3i(US_dJnYtZ$r19*?a zG1fHO%tLliko}@_PqfP;jObifz%yNWl+%O3tX+yW39^?&`N3M-@8*p>3z{CTW!fYd zH$p{Z;XY|nKOusu01^VBWPgvehf@bnT>90buJdtF>xT|wL)_eWOKy4|Sj$Ox;CIqJ z1k{tz#m5s?&#<$x$06g0#AC)b1nNpfaWbx?^R#@?u7#0{Gh#sm*<8x`vl^+op0Kq> zYI(l+IC|5mzPZtCZ!dZ5^bg1)btSHb;b*6)(U7ffi82b3i;WR^teYVaFksAryu+SY zBh@cwtnMDO2wEB*iy&*UvZNW`U7GeUcz@?YN`WzfnHm*9rLy+Ob{sa)i-L<43iuq^ z4Xvft0)_UquJZ0StH+J0vL+QZw>7~$dF%h6tm5emY3YK9(#B!xtI<#M)A zMyGN=GGMin(1uds6WUPG;U`Qxn{bdoGn@36=lq_G9QBofwxC-%-J#cykH3GEj* zWkrg~EUOyPxu!mcuiQuo7H_L321B*g<7o^e@QED|qYvJ`#>HGKO9!Q5OF8nT%8@Dr zXRp1v;&CSIoHF2=JS0CV;)7;#PtQ=f`S0p(4$QM$Mgml!CJfPB`J-Pgzs)Z%&!`HX zr$>ZmUW*%%Kq&+To$gSW%^G6HrcH&78`P!#jzUtVBIT>;EJuq#B4sgH=|L~F}L?ZI~D5q z;#JFRr9<&qM+qobC(*smOw?D%-UY>1Vd;J{C%vSBrA@Uu(!n`b6)uAFuK(uy#!?ey z#$)tUJy|xOEeMBRBAV`RNiTO-+lEF+bqA^s$)vAtEV zLy{+#8`|`Dy!CI3mw*~Y9n~)&3i0`wT`<%UNr(63lvT{NqD3WS3O2raZItw~kJ+Ud zJ}TyaZi7?Khq3u$RyA$&P6|3MBrO^#@uY)X;@nWkzyb(=p9vtkUKaTdw@oHTYlO>B z=|TjAnBJ`;6o#xfQy?ptPpprzfW%Rl(Y&E9!sqXy@7DOi3(7*^G z|DnSYWk1lSHVq4eO5IQo$Kts%6)N|vJJLa`e5Fca=`3oPojS>nYgArmY?x|Z^6aup z;Tl^Lxg}cqB_Y~Ij?@XefzjV)F0jat*yr5?qCQ=Jz86JFf2~+Im>u09CS+}!r_Su! zf!KF_@bf8Q+-ruTeTSMn+x%@$XondGpg)t!e34FKR9g!|muvL5?&R3=8=w(|3ze_`h{cZx zGS_G83IBYNY%Ew+17Y$A_2f(>13JjX4v>k$Z!~df9J6h2wtV zi<_|TWMdwh0%MVFU95@~h?T=%_dLC_0|uHsg8eDiXx`A7#jjS7CIq4m3FjYtXct5$*^a9Lw%z~4sX-uL$}@Zf+2Km`p?%DSLn3dOc;c|<^24xkiO}US?V6}8rWC1 zQBX1yH;{1$C(Nodn{cvJ(CJ)b8jV#Q(Z>{VI(npyLhEzQ|C{l-+u?ov&gcwc|1K9a zw(Y%1?#MGq)PeG_M{P9ZmVf`cVijHF)Y0_vpKOuD4FYF=yX2zjroEJYLZ@?}GCd@b zwGyG83h3-yGk^bL$x#F*<~qX{$aEF4CPxk!E5aSOYj{IagtG0gY_E_ll;NLz)oeOn zmBs!^m#vXfFA3cZHbR)pK6PcylwMBB1LuRsWR`it(CT36QRP+L*=820Y2t_b!-{x! z0eAs{%-E*7g$etI$18En=LkG#S*i=U+}?ao?atVhlO_)7Cp)95G9Rb~tyq3TI%wO5 zaxDQ9?OXB_l_WeY<{y)O6#)5kmdjBq8ZJGBj3`m z!)`e%+aZevgRf?L?1+R&$W5P(+wCT3$q*;0{dRYRyr8Atd0y6Z#pX-8b7l)j`?*#t z+Mg>hf$=6`LpK#Ea~D{iMwQEwZu|7gqY8cMJL*@QM`FgsPq^ifnYrZ*i)Y2wBm7k%9LN@vf(c8k5x;Rg_g^rSiada8I!b}sjB z-9Ht)%^k0qDU;|jj!+l1(0Sa0p;)uZB*Jm~=GXI5AiQs-2Jlr|Yku}(#{ZRcgc%J^ zq`sLVav&=YB%P(TKe)m@yU0)}58HZrW$)s(w@FWaT@4}}F;2T@7B5TwcvT{~#Om|W zC=b--Okw{^?*`lpSvmenL-pB(cRnOQTZB+f-nBt`T6CPIC!?&@P-_bxmm)>%a;a;H z?#afPu61^6Br^~zg%D` z)r!EdAXm5>)W?AN?EJQ^zfLHA>I4#Y-sn`>k7S6IVSl>GowR9n*2g6`-Fms=m^1S= zU=d?}C{du%(cAYZSfU(SI^mpakD9(r@(IqWON~15|jMLbX zUZLIGUgwK7I}AwT?|09HrS@q9ov8|%L7EshZ)C)bG72wR{ytM~Wq(x~A7uhK+nWHNBwAho?=<)qeqI&J>E=HdW`9Tkh!kmOQTOPvOZxJxj5|o{`Kp+i;5yY`Km`OOwg5w}aG9{vq)6z}o8Op;cVibc@5Nu2^CP zRC43Nu$9lFOmv@kGry1NONyVas~Hqmm!g)S+#`yAY+B6bp_NA5PzN* zX7 z7r&VR+Y3!z>}i9;hIL|xzy3{aVrN-eXHlDTQ8tiC^=K*@5$4qB)90OfaUT2Epf_}X z8=q&(Fu3Rwd#HbpR1?#b^Gh5qu{mT&7#vV&HcG zS5wt{a`gu@9+7bm8@Mo%$4_ymde_u+(=28{LzAMgnf>kZmMuZ0W^JRgzAPt705w3$ zza2(L3kOA*O=p?~&Kmx@a1d>#`CF2Yw@|6kG(#HYi(c&Fxio4aC}t_a^FVx#Y;n;Z zbO$(URiQ4&t=~WSCs`#}pK=u(4TJ_VwgK76pp7SPy z?&Ry>A+Ua8k4H!f{APx26QhKzWfSG>2jk={^q$&|4iw;#vk+m2`QzF)gEQ_IO725d z-I=xE0^qDl5}&{HuhA`xmP~B!5TEPK8I#z<9BHs%G9e@h#mP*gunIZ9+%+4g_P& z<4F+W+p<|1wr<UAQU=@yYp^t+P{htz@%~sKRPc(?oM@1uR#H{bP zMIwUAUs%8)VHHdX@^`d;>!avLRdqqylI$5JFM1~tXi_50A_C;UBYVog(uKw27+@e4 zu-iy=OVX~MOxA|Rp67Us9!@?`#h#t_SC>s|s#88XfA+nO_6+{8*(g0iw3v>}~c?pbD?covF!)zJRHuD@)*6jKXiq z6&kmQNeXHxc9$A{*l8UGf@d)oUYs~Z6Tdqx{;%4J!CCxug{Y!RYrty@$0l*Z$dH6f zivOmm?k>seY$wy53K7O(^646CFgrw|ErgcJX1FiY3s zjBB1(Kz6l+6i-bQVMDwEC>K_7xT-QTm^rZ?fBiryv5$^@T_d40ogn}bf)!({X{JbG zQ*%W6D_poB5ivZ(G8lb{I8IAL|L#oy~$dBDZ5xMU^l) zz!Eg&;11JCUM^?vb5#vWJRy43pOZG7g1O8o{yaRrIflz{L33rIU8>7}bpB!oB-C-^ zwEo|Gg%-V11Id@;p>Q6ml^RXxjDYnM2}3 z`?_4Lr)y3JwdTi(DAm(2&}XUZ4xH-*QC1<%SM(IC7@s=wVK9-Lq;mq?Q{Vj{ycVm0}g} zY@vUuPH@o`7F(0W#8eLwQ4?9t{_)R(YDP~~R^mAG^EVH}!H_N1CSvRSG(3cMASEC?B3YGM z8W#VL{L1lbdPfKgWTC?bmWif%YPz1LY4wCOrnaNBm85-O;Iw-1I#U zYK;C_|L`h5x795&97ztprsM44wvec}pHBGaI_`kG&Y!eeRb*Tq`8;!4y19Sc>BZ&ulV02f)oL$Dd#1K?bakLK-W4=fst#SZQr4+-vV-P# z=ZY#247ZrD_vlYYvtA+jHS|Rv4+{ffw^@)$4u?XENEj`Q=K_1Nzu$tf&diDEQd=%Y zU)Au~?a_HmmGef3M8W`k5<8N=Y1a$e3<~4dOgYi#j=S4j8`G6dkGl-=phTT_R_5n7 zlW&%m3ayhGWOsvCk<^=YRE#wzF4+Bp_nmR`g0XZtN~HFeyisS_LOP&_?U`J_;Hk4Py+e=3;pK=It<&)B% z?Izq#Xgez9@giV~r2c_}o7ff2=$s;G$uOY$*{6-LXU~V{QUq>iT~O313a56AZv5ij zD!MXZbmRQ|U#8|A@kKVAwN^ZNup$%P9RPa}=fH@7SME#_!4ENU(l+SOrFFMKCp5%2 ztShx5qc`!Y?CMp%Gd^o$a>MM5Obkgg3r25gCByex9giU2q=fq|N!=Vh2I|G!8i