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 0d0363e..0000000 Binary files a/media/edited_media/5/eye_e_v2.jpg and /dev/null differ diff --git a/run_player.sh b/run_player.sh index 3991e4f..a4f8007 100644 --- a/run_player.sh +++ b/run_player.sh @@ -1,5 +1,13 @@ #!/bin/bash -# Start Kivy Signage Player -cd "$(dirname "$0")/src" -python3 main.py \ No newline at end of file +# Start Kivy Signage Player (single run, no watchdog). +# +# linux/run_linux.py is the Raspberry Pi entry point: it sets the Wayland +# session environment and injects the Pi-specific display and web-link +# behaviour. Running src/main.py directly skips all of that. +cd "$(dirname "$0")" || exit 1 + +if [ -x .venv/bin/python ]; then + exec .venv/bin/python linux/run_linux.py +fi +exec python3 linux/run_linux.py diff --git a/src/edit_popup.py b/src/edit_popup.py index 70ad990..788e62b 100644 --- a/src/edit_popup.py +++ b/src/edit_popup.py @@ -324,10 +324,10 @@ class EditPopup(Popup): shutil.copy2(output_path, self.image_path) # Force file system sync to ensure data is written to disk. - # NOTE: os.sync() is Linux-only and raises AttributeError on - # Windows — that used to abort the whole pipeline before the - # metadata/upload steps. Use a cross-platform fsync that is - # best-effort and can never break the save/upload flow. + # NOTE: os.sync() does not exist on every platform and used + # to raise AttributeError, aborting the whole pipeline before + # the metadata/upload steps. Use a best-effort flushes that can + # never break the save/upload flow. try: if hasattr(os, 'sync'): os.sync() diff --git a/src/get_playlists_v2.py b/src/get_playlists_v2.py index ae5a268..e01c045 100644 --- a/src/get_playlists_v2.py +++ b/src/get_playlists_v2.py @@ -7,8 +7,11 @@ import os import json import requests import logging +import subprocess +import sys from player_auth import PlayerAuth from ssl_utils import SSLManager +import media_state # conversion flags/markers, shared with the player # Set up logging logging.basicConfig(level=logging.INFO) @@ -340,7 +343,25 @@ def delete_unused_media(playlist_data, media_dir): referenced_files.add(file_name) logger.info(f"📋 Current playlist references {len(referenced_files)} files") - + + # Names of the normalisation artefacts that belong to files STILL in the + # playlist. Computed from the referenced files with the same naming + # helpers the normaliser uses, so the two cannot drift. + # + # This has to be a per-source decision, not a blanket "never delete + # anything containing _kiwy": a converted output is not itself named in + # the playlist, so a blanket rule would keep the 1080p copies (and their + # metadata) forever after the item is removed from the playlist — an + # unnoticed disk leak on a device that runs for months. + keep_artifacts = set() + for ref in referenced_files: + ref_path = os.path.join(media_dir, ref) + output = media_state.normalized_output(ref_path) + keep_artifacts.add(os.path.basename(output)) + keep_artifacts.add(os.path.basename(output) + media_state.MARKER_SUFFIX) + keep_artifacts.add(os.path.basename(media_state.converting_marker(ref_path))) + keep_artifacts.discard('') + if os.path.exists(media_dir): # Recursively get all media files deleted_count = 0 @@ -349,20 +370,47 @@ def delete_unused_media(playlist_data, media_dir): # Get relative path from media_dir full_path = os.path.join(root, media_file) rel_path = os.path.relpath(full_path, media_dir) - + + # Normalisation artefacts belong to the item that produced + # them, so they are kept only while that source is still + # referenced. Their filenames are not in the playlist + # (the player derives them at run time), which is exactly why + # this check cannot be the generic one below. + if media_file in keep_artifacts: + continue + + # A leftover artefact whose source is gone: it is derived + # data, safe to delete like any other unreferenced file. + # ``.kiwy-converting`` is excluded — it may belong to a + # conversion running right now for a file this pass missed. + if (media_file.endswith(media_state.MARKER_SUFFIX) + or '_kiwy' in media_file) \ + and not media_file.endswith(media_state.CONVERTING_SUFFIX): + try: + os.remove(full_path) + logger.info(f'🗑️ Deleted stale normalisation ' + f'artefact: {rel_path}') + deleted_count += 1 + except Exception as e: + logger.warning(f'⚠️ Could not delete {rel_path}: {e}') + continue + # Skip if file is in current playlist - # Normalize paths to handle Windows backslashes vs server forward slashes + # Normalize path separators so server-style paths work normalized_rel = rel_path.replace('\\', '/') if normalized_rel in referenced_files or rel_path in referenced_files: continue - + + # The 4K original is still referenced by the playlist, so it + # is kept (harmless: it is no longer what gets played). + # Delete unreferenced file try: os.remove(full_path) - logger.info(f"🗑️ Deleted unused media: {rel_path}") + logger.info(f'🗑️ Deleted unused media: {rel_path}') deleted_count += 1 except Exception as e: - logger.warning(f"⚠️ Could not delete {rel_path}: {e}") + logger.warning(f'⚠️ Could not delete {rel_path}: {e}') # Clean up empty directories for root, dirs, files in os.walk(media_dir, topdown=False): @@ -386,6 +434,76 @@ def delete_unused_media(playlist_data, media_dir): +def normalize_oversized_media(playlist, media_dir): + """Start conversion of any oversized video in ``playlist`` (non-blocking). + + Called after the playlist and its files are in place. Conversion takes about + 31 s for an 18 s 4K clip, so it is launched as a **detached background + process** rather than run here: blocking the sync loop for that long would + stall playlist updates, and the sync runs on the player's asyncio executor. + + The child writes the ``.kiwy-converting`` marker before it starts, which is + what makes the player skip the item while its file is being rewritten, and + removes it when finished so the item plays on the next lap. + + Failures are never fatal: an unconverted video simply means the player keeps + skipping that item. + """ + 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.debug('Video normaliser not present; skipping conversion') + return 0 + + started = 0 + for media in playlist or []: + if media.get('type') != 'video': + continue + file_name = media.get('file_name', '') + if not file_name: + continue + + path = os.path.join(media_dir, file_name) + if not os.path.isfile(path): + continue + + # Already converting (or a fresh marker says so): leave it alone. + if media_state.is_converting(path): + continue + # A finished conversion is reused automatically by the normaliser. + if media_state.normalized_file(path): + continue + + oversized, width, height = media_state.is_oversized(path) + if not oversized: + continue + + logger.info( + f'🎬 {file_name} is {width}x{height} — above 1920x1080; ' + f'converting in the background for Raspberry Pi playback' + ) + subprocess.Popen( + [sys.executable, normalizer, path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, # outlive this sync + ) + started += 1 + + if started: + logger.info( + f'🎬 Started {started} video conversion(s); the player will ' + f'skip those items until they are ready' + ) + return started + + except Exception as exc: + logger.warning(f'Could not start media normalisation: {exc}') + return 0 + + def update_playlist_if_needed(config, playlist_dir, media_dir): """Check for and download updated playlist if available. @@ -445,7 +563,12 @@ def update_playlist_if_needed(config, playlist_dir, media_dir): # Save new playlist (single file, no versioning) playlist_file = save_playlist(server_data, playlist_dir) - + + # Convert any oversized video to 1080p in the background. Doing this + # after the files are on disk (and after the playlist is saved) means + # the player can start skipping the item immediately. + normalize_oversized_media(server_data.get('playlist', []), media_dir) + # Delete unused media files delete_unused_media(server_data, media_dir) @@ -465,6 +588,11 @@ def update_playlist_if_needed(config, playlist_dir, media_dir): server_data['playlist'] = downloaded # Re-save playlist with updated URLs if needed save_playlist(server_data, playlist_dir) + + # A version match does not mean the media is ready: a fresh install + # (or a failed conversion) can still have an oversized file waiting + # to be normalised, and this branch is where that gets noticed. + normalize_oversized_media(server_data.get('playlist', []), media_dir) return playlist_file except Exception as e: diff --git a/src/main.py b/src/main.py index 11d8825..652ee04 100644 --- a/src/main.py +++ b/src/main.py @@ -11,14 +11,15 @@ import platform import signal import subprocess import sys +import tempfile import threading import time import asyncio from concurrent.futures import ThreadPoolExecutor -# Set environment variables for better video performance -# Use setdefault() so that a wrapper script (e.g. run_win.py) can pre-set -# Windows-compatible values before this module is imported. +# Set environment variables for better video performance. +# Use setdefault() so that the platform entry point (linux/run_linux.py) can +# pre-set display/audio values before this module is imported. os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer') os.environ.setdefault('FFPYPLAYER_CODECS', 'h264,h265,vp9,vp8') os.environ.setdefault('SDL_VIDEO_ALLOW_SCREENSAVER', '0') @@ -31,7 +32,9 @@ os.environ.setdefault('KIVY_GL_BACKEND', 'gl') os.environ.setdefault('KIVY_INPUTPROVIDERS', 'wayland,x11') os.environ.setdefault('FFMPEG_THREADS', '2') os.environ.setdefault('LIBPLAYER_BUFFER', '1048576') -os.environ.setdefault('SDL_AUDIODRIVER', 'alsa') +# NOTE: SDL_AUDIODRIVER is set once above ('alsa,pulse,dummy'). It used to be +# set again here with a hard 'alsa' value; because setdefault never overwrites, +# that line was a silent no-op and only obscured which driver was in effect. # Configure Kivy BEFORE importing any Kivy modules from kivy.config import Config @@ -94,6 +97,7 @@ from kivy.uix.floatlayout import FloatLayout from kivy.uix.slider import Slider from playback_trace import trace # always-on playback transition logger from video_safety import suppress_kivy_video_blocking_unload # bound Kivy's blocking video join +from media_state import resolve_playable # video conversion flags / normalised paths from weblink_session import ( WeblinkSession, WeblinkSettings, @@ -103,7 +107,7 @@ from weblink_session import ( # Load the KV file - resolve relative to this file's directory _kv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'signage_player.kv') if not os.path.exists(_kv_path): - # Fallback: relative to cwd (for PyInstaller bundled runs) + # Fallback: relative to cwd (used when launched through a wrapper) _kv_path = 'signage_player.kv' Builder.load_file(_kv_path) @@ -815,7 +819,11 @@ class SettingsPopup(Popup): Logger.info(f"SettingsPopup: Testing connection to {server_url} (HTTPS: {use_https}, Verify SSL: {verify_ssl})") # Create temporary auth instance (don't save) - auth = PlayerAuth('/tmp/temp_auth_test.json', use_https=use_https, verify_ssl=verify_ssl) + # Use the platform temp directory rather than a hard-coded + # /tmp: it is not always writable (e.g. systemd PrivateTmp) + # and honouring TMPDIR is the portable behaviour. + temp_file = os.path.join(tempfile.gettempdir(), 'temp_auth_test.json') + auth = PlayerAuth(temp_file, use_https=use_https, verify_ssl=verify_ssl) # Try to authenticate success, error = auth.authenticate( @@ -826,10 +834,9 @@ class SettingsPopup(Popup): # Clean up temp file try: - import os - if os.path.exists('/tmp/temp_auth_test.json'): - os.remove('/tmp/temp_auth_test.json') - except: + if os.path.exists(temp_file): + os.remove(temp_file) + except Exception: pass # Update UI on main thread @@ -993,8 +1000,7 @@ class SettingsPopup(Popup): # ── First-run configuration ────────────────────────────────────────── -# The player is shipped WITHOUT any server credentials: `app_config.json` is -# not bundled into the exe (see windows/build.spec). On first start there is +# The player ships WITHOUT any server credentials. On first start there is # nothing to connect to, so the player shows a notice after the splash video # and then opens Settings automatically. # @@ -1039,6 +1045,12 @@ DEFAULT_CONFIG = { #: Seconds the "not configured" notice stays up before Settings opens. SETUP_NOTICE_SECONDS = 5 +#: Media extensions, defined once. The normalisation gate and the playback +#: dispatch must agree on what counts as a video, or a file could be normalised +#: and then played through the image path. +VIDEO_EXTENSIONS = ('.mp4', '.avi', '.mkv', '.mov', '.webm', '.m4v') +IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp') + def config_is_configured(config): """True when ``config`` has enough real values to talk to a server. @@ -1077,21 +1089,14 @@ class SignagePlayer(Widget): self.playlist = [] self.current_index = 0 self.current_widget = None - self._weblink_proc = None # Mirror of the live weblink browser process - self._weblink_preload_proc = None # Deprecated: pre-warm handled by WeblinkSession - self._watchdog_stop = None # Deprecated: idle watcher owned by WeblinkSession - self._weblink_watchdog_thread = None # Deprecated: idle watcher owned by WeblinkSession # Unified web-link controller. Owns launch, verified start, idle/max-dwell - # watching and teardown for one weblink item at a time. Platform wrappers - # (e.g. windows/run_win.py) inject their adapters via + # watching and teardown for one weblink item at a time. The platform + # entry point (linux/run_linux.py) injects its adapters via # `weblink_adapter_factory` before playback starts. # # CRITICAL: the factory is injected as a *class* attribute, so do NOT # assign None unconditionally here. An instance attribute would shadow - # it, play_weblink() would silently fall back to the generic adapter, - # and on Windows that adapter's find_browser() (shutil.which) finds no - # browser because Chrome/Edge are not on PATH — so every weblink failed - # with "launch() returned False" and the item was skipped. + # it and play_weblink() would silently fall back to the generic adapter. self._weblink_session = None if not callable(getattr(type(self), 'weblink_adapter_factory', None)): self.weblink_adapter_factory = None @@ -1106,24 +1111,20 @@ class SignagePlayer(Widget): self._video_eos_pending = False # guards against duplicate EOS callbacks on one video self._last_advance_at = 0.0 # monotonic time of the last next_media advance self._advance_generation = 0 # bumped on every advance; stamps scheduled callbacks - self._focus_keeper_event = None # Clock interval that keeps the window focused during video - self._focus_keeper_elapsed = 0.0 - self._focus_keeper_duration = 0.0 - self._focus_keeper_misses = 0 # consecutive keeper ticks without foreground - # Continuous foreground guardian — re-asserts the Kivy window to the - # foreground while media is playing (images AND videos), so focus that - # is lost overnight (screensaver, update, dialog, restart) is recovered - # even when an image is on screen. Skipped while paused or a weblink - # browser is showing (focus legitimately belongs to the browser). - self._focus_guardian_event = None - self._focus_guardian_interval = 5.0 - self._focus_guardian_misses = 0 self._video_watchdog_event = None # Clock interval that watches video progress self._video_watchdog_stopped = False # self.should_refresh_playlist = False # Flag to reload playlist after edit upload (DISABLED - causing crashes) self.consecutive_errors = 0 # Track consecutive playback errors self.max_consecutive_errors = 10 # Maximum errors before stopping self.intro_played = False # Track if intro has been played + # Intro-loop fallback. When every remaining item is still being + # converted, there is nothing playable; rather than showing a blank + # screen (or a frozen video) the player loops the intro until a + # converted item is ready. See _maybe_loop_intro_until_ready(). + self._intro_loop_widget = None + self._intro_loop_event = None + self._intro_loop_active = False + self._waiting_for_conversion = False # Card reader for authentication self.card_reader = None self._pending_edit_image = None @@ -1160,18 +1161,11 @@ class SignagePlayer(Widget): Clock.schedule_interval(self.update_heartbeat, 10) # Update every 10 seconds # Start screen activity signaler (keep display awake) Clock.schedule_interval(self.signal_screen_activity, 20) # Signal every 20 seconds - # Start the continuous focus guardian. It re-asserts the Kivy window - # to the foreground whenever focus is lost while media is playing, so - # the overnight scenario (focus stolen by a dialog/screensaver/update - # while an image was on screen) self-heals. It skips itself while the - # player is paused or a weblink browser is showing. - self._start_focus_guardian() def _update_size(self, instance, value): # Keep the screen-size properties in sync with the real window size. - # On Windows, Window.size is the TRUE (DPI-aware) resolution; without - # this the content_area could be sized from a stale/pre-fullscreen - # value, leaving a black strip and wrong image/video scaling. + # Without this the content_area could be sized from a stale or + # pre-fullscreen value, leaving a black strip and wrong scaling. self.size = value try: w, h = value @@ -1250,8 +1244,6 @@ class SignagePlayer(Widget): - Window close (X / Alt+F4) is blocked by _guard_window_close until the password exit flow calls set_allow_exit(True). - Ctrl+C is ignored so the console cannot kill the player. - Platform wrappers (e.g. run_win.py) may extend this to also swallow - Alt+Tab / Win / Ctrl+Esc via a low-level keyboard hook. """ self.config['production_mode'] = bool(enabled) if enabled: @@ -1387,7 +1379,7 @@ class SignagePlayer(Widget): """Show the "not configured" notice, then open Settings automatically. The player is shipped without credentials, so this is the expected - first-run experience on a brand-new .exe: tell the operator what is + first-run experience on a brand-new install: tell the operator what is missing, wait SETUP_NOTICE_SECONDS, then open the settings screen so they can enter the server details. """ @@ -1419,7 +1411,7 @@ class SignagePlayer(Widget): """Called when Settings was used to configure the player. Applies the new values immediately and starts playback, so the operator - does not have to restart the .exe after entering the server details. + does not have to restart the player after entering the server details. """ self._configured = config_is_configured(self.config) if not self._configured: @@ -1726,6 +1718,16 @@ class SignagePlayer(Widget): if self.is_paused: Logger.debug(f"SignagePlayer: Skipping play_current_media - player is paused") return + + # ── Intro-loop fallback ───────────────────────────────────────── + # Guarded by ``_waiting_for_conversion`` so this costs nothing in the + # normal case. It fires when every item is an oversized video still + # being converted: instead of a blank screen, loop the intro. The flag + # is only set by the skip paths below, so a genuinely empty or broken + # playlist still follows the normal error handling. + if self._waiting_for_conversion: + if self._maybe_loop_intro_until_ready(): + return if not self.playlist: Logger.warning("SignagePlayer: Cannot play - playlist is empty") @@ -1753,7 +1755,7 @@ class SignagePlayer(Widget): type=media_item.get('type', '?'), duration=duration, after_weblink=_after_weblink, - weblink_proc=bool(getattr(self, '_weblink_proc', None))) + weblink_active=self._weblink_is_active()) # ── Leaving a weblink: close the browser safely ────────────────── # Weblinks are torn down by the WeblinkSession. `close()` is @@ -1838,6 +1840,48 @@ class SignagePlayer(Widget): self.consecutive_errors += 1 self._skip_to_next_media() return + + # ── Video normalisation gate ───────────────────────────────────── + # An oversized video (e.g. 4K) cannot be decoded in real time on a + # Pi 4 — measured 0.90x realtime — so showing it produces a frozen + # frame. Instead of playing it badly: + # + # converting -> 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 9a95f3b..0000000 Binary files a/windows/app_icon.ico and /dev/null differ 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 e10abbc..0000000 Binary files a/windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe and /dev/null differ diff --git a/windows/webview2_runtime/download_runtime_installers.ps1 b/windows/webview2_runtime/download_runtime_installers.ps1 deleted file mode 100644 index 65426c4..0000000 --- a/windows/webview2_runtime/download_runtime_installers.ps1 +++ /dev/null @@ -1,65 +0,0 @@ -# Downloads the WebView2 Runtime installers into windows\webview2_runtime\. -# -# build.spec bundles: -# - MicrosoftEdgeWebview2Setup.exe (~1.7 MB) always -# - MicrosoftEdgeWebView2RuntimeInstallerX64.exe (~203 MB) only if present -# -# The bootstrapper is the small online installer (it downloads the Runtime -# from Microsoft). Run this script with -Offline to also fetch the standalone -# installer for machines that have no internet access — note that it makes the -# built .exe about 200 MB larger. -# -# Usage: -# .\download_runtime_installers.ps1 -# .\download_runtime_installers.ps1 -Offline - -[CmdletBinding()] -param( - [switch]$Offline -) - -$ErrorActionPreference = 'Stop' - -$dest = Join-Path $PSScriptRoot 'webview2_runtime' -if (-not (Test-Path $dest)) { - New-Item -ItemType Directory -Force -Path $dest | Out-Null -} - -$bootstrapperUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' -$standaloneUrl = 'https://go.microsoft.com/fwlink/?linkid=2124701' # x64 - -function Get-Installer { - param([string]$Url, [string]$FileName, [string]$Label) - - $target = Join-Path $dest $FileName - Write-Host "[INFO] Downloading $Label ..." -ForegroundColor Cyan - Invoke-WebRequest -Uri $Url -OutFile $target -UseBasicParsing -MaximumRedirection 10 - - $file = Get-Item -LiteralPath $target - $sig = Get-AuthenticodeSignature -LiteralPath $target - $sizeMb = [math]::Round($file.Length / 1MB, 1) - - Write-Host (" {0} {1} MB" -f $file.Name, $sizeMb) - if ($sig.Status -eq 'Valid' -and $sig.SignerCertificate.Subject -like '*Microsoft*') { - Write-Host " signature: Valid (Microsoft)" -ForegroundColor Green - } - else { - Write-Warning " signature: $($sig.Status) — verify this download!" - } -} - -Get-Installer -Url $bootstrapperUrl -FileName 'MicrosoftEdgeWebview2Setup.exe' -Label 'Runtime bootstrapper (online)' - -if ($Offline) { - Get-Installer -Url $standaloneUrl -FileName 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe' -Label 'Runtime standalone installer (offline, x64)' - Write-Host '' - Write-Host '[WARN] The standalone installer adds ~203 MB to the built .exe.' -ForegroundColor Yellow -} -else { - Write-Host '' - Write-Host '[INFO] Offline installer skipped. Re-run with -Offline to include it.' -ForegroundColor DarkGray -} - -Write-Host '' -Write-Host "[OK] Installers are in $dest" -ForegroundColor Green -Write-Host ' Next: rebuild with venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm' diff --git a/windows/webview2_sdk/Microsoft.Web.WebView2.Core.dll b/windows/webview2_sdk/Microsoft.Web.WebView2.Core.dll deleted file mode 100644 index 23bfe7a..0000000 Binary files a/windows/webview2_sdk/Microsoft.Web.WebView2.Core.dll and /dev/null differ diff --git a/windows/webview2_sdk/WebView2Loader.dll b/windows/webview2_sdk/WebView2Loader.dll deleted file mode 100644 index 965aed4..0000000 Binary files a/windows/webview2_sdk/WebView2Loader.dll and /dev/null differ diff --git a/windows/win_card_reader.py b/windows/win_card_reader.py deleted file mode 100644 index 7cbf7b0..0000000 --- a/windows/win_card_reader.py +++ /dev/null @@ -1,720 +0,0 @@ -""" -Windows Card Reader (Raw Input API + LL-hook fallback) -======================================================= -Drop-in replacement for the Linux `CardReader` class in `src/main.py`. - -The Linux implementation reads keystrokes from `/dev/input/event*` via -`evdev`. On Windows there is no `/dev/input`; instead HID devices (such as -USB card readers that emulate a keyboard) are accessed through the Win32 -**Raw Input API** (`WM_INPUT`). - -Design ------- -* A dedicated hidden message-only window + message pump runs on a background - thread. It registers for Raw Input of all *keyboard* HID devices with - `RIDEV_INPUTSINK`, so it receives `WM_INPUT` even though it never has focus. -* Device selection mirrors the Linux priority logic: - 1. A device whose name contains "card" / "reader" / "rfid". - 2. A USB HID keyboard that is *not* the PS/2 system keyboard. - 3. Any keyboard device (excluding obvious touchscreens/mice). - A config override `card_reader_device` (substring of the device name, e.g. - `VID_08FF`) takes precedence. -* While reading, only key events from the *selected* device are accepted, so a - physical keyboard used for maintenance cannot pollute the card data. -* Card data ends on Enter (`VK_RETURN`), mirroring the Linux behaviour. -* If Raw Input registration fails (or config `card_reader_mode = "hook"`), it - falls back to a low-level keyboard hook (`WH_KEYBOARD_LL`). - -Config keys (in `config/app_config.json`): - "card_reader_device": "VID_08FF" # substring of device name (optional) - "card_reader_mode": "auto" # "auto" | "raw" | "hook" - "card_reader_timeout": 5 # seconds (optional) -""" - -import ctypes -import ctypes.wintypes as wintypes -import os -import json -import threading -import time - -# ── Win32 constants ────────────────────────────────────────────────────────── -WM_INPUT = 0x00FF -WM_INPUT_DEVICE_CHANGE = 0x00FE -WM_KEYDOWN = 0x0100 -WM_SYSKEYDOWN = 0x0104 - -RIM_TYPEMOUSE = 0 -RIM_TYPEKEYBOARD = 1 -RIM_TYPEHID = 2 - -RID_INPUT = 0x10000003 -RIDI_DEVICENAME = 0x20000007 - -RIDEV_INPUTSINK = 0x00000100 -RIDEV_DEVNOTIFY = 0x00002000 - -WH_KEYBOARD_LL = 13 -HC_ACTION = 0 - -# Virtual keys we never treat as card data -_VK_SHIFT = 0x10 -_VK_CONTROL = 0x11 -_VK_MENU = 0x12 -_VK_CAPITAL = 0x14 -_VK_ESCAPE = 0x1B -_VK_RETURN = 0x0D -_VK_TAB = 0x09 -_VK_LSHIFT = 0xA0 -_VK_RSHIFT = 0xA1 -_VK_LCONTROL = 0xA2 -_VK_RCONTROL = 0xA3 -_VK_LMENU = 0xA4 -_VK_RMENU = 0xA5 - -# ── ctypes structures ─────────────────────────────────────────────────────── -# ctypes.wintypes does not export LRESULT; it is a signed pointer-sized value. -# Use ctypes.c_long (standard ctypes workaround; fine for WNDPROC/LL-hook). -_LRESULT = ctypes.c_long - -_WND_PROC = ctypes.WINFUNCTYPE( - _LRESULT, wintypes.HWND, wintypes.UINT, - wintypes.WPARAM, wintypes.LPARAM, -) -_LL_KEYBOARD_PROC = ctypes.WINFUNCTYPE( - _LRESULT, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM, -) - - -class _WNDCLASSW(ctypes.Structure): - _fields_ = [ - ('style', wintypes.UINT), - ('lpfnWndProc', _WND_PROC), - ('cbClsExtra', ctypes.c_int), - ('cbWndExtra', ctypes.c_int), - ('hInstance', wintypes.HINSTANCE), - ('hIcon', wintypes.HICON), - ('hCursor', ctypes.c_void_p), # HCURSOR (not exported by wintypes) - ('hbrBackground', wintypes.HBRUSH), - ('lpszMenuName', wintypes.LPCWSTR), - ('lpszClassName', wintypes.LPCWSTR), - ] - - -class _MSG(ctypes.Structure): - _fields_ = [ - ('hwnd', wintypes.HWND), - ('message', wintypes.UINT), - ('wParam', wintypes.WPARAM), - ('lParam', wintypes.LPARAM), - ('time', wintypes.DWORD), - ('pt', wintypes.POINT), - ] - - -class _RAWINPUTDEVICE(ctypes.Structure): - _fields_ = [ - ('usUsagePage', wintypes.USHORT), - ('usUsage', wintypes.USHORT), - ('dwFlags', wintypes.DWORD), - ('hwndTarget', wintypes.HWND), - ] - - -class _RAWINPUTDEVICELIST(ctypes.Structure): - _fields_ = [ - ('hDevice', wintypes.HANDLE), - ('dwType', wintypes.DWORD), - ] - - -class _RAWINPUTHEADER(ctypes.Structure): - _fields_ = [ - ('dwType', wintypes.DWORD), - ('dwSize', wintypes.DWORD), - ('hDevice', wintypes.HANDLE), - ('wParam', wintypes.WPARAM), - ] - - -class _RAWKEYBOARD(ctypes.Structure): - _fields_ = [ - ('MakeCode', wintypes.USHORT), - ('Flags', wintypes.USHORT), - ('Reserved', wintypes.USHORT), - ('VKey', wintypes.USHORT), - ('Message', wintypes.UINT), - ('ExtraInformation', wintypes.ULONG), - ] - - -class _RAWINPUT_UNION(ctypes.Union): - _fields_ = [('keyboard', _RAWKEYBOARD)] - - -class _RAWINPUT(ctypes.Structure): - _fields_ = [ - ('header', _RAWINPUTHEADER), - ('u', _RAWINPUT_UNION), - ] - - -class _KBDLLHOOKSTRUCT(ctypes.Structure): - _fields_ = [ - ('vkCode', wintypes.DWORD), - ('scanCode', wintypes.DWORD), - ('flags', wintypes.DWORD), - ('time', wintypes.DWORD), - ('dwExtraInfo', wintypes.WPARAM), - ] - - -# ── Win32 function bindings with explicit signatures ──────────────────────── -_user32 = ctypes.windll.user32 -_kernel32 = ctypes.windll.kernel32 - -_user32.RegisterClassW.argtypes = [ctypes.POINTER(_WNDCLASSW)] -_user32.RegisterClassW.restype = wintypes.ATOM -_user32.CreateWindowExW.argtypes = [ - wintypes.DWORD, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD, - ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, - wintypes.HWND, wintypes.HMENU, wintypes.HINSTANCE, wintypes.LPVOID, -] -_user32.CreateWindowExW.restype = wintypes.HWND -_user32.DestroyWindow.argtypes = [wintypes.HWND] -_user32.UnregisterClassW.argtypes = [wintypes.LPCWSTR, wintypes.HINSTANCE] -_user32.GetMessageW.argtypes = [ - ctypes.POINTER(_MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT, -] -_user32.GetMessageW.restype = wintypes.BOOL -_user32.TranslateMessage.argtypes = [ctypes.POINTER(_MSG)] -_user32.DispatchMessageW.argtypes = [ctypes.POINTER(_MSG)] -_user32.DefWindowProcW.argtypes = [ - wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, -] -_user32.DefWindowProcW.restype = _LRESULT -_user32.PostMessageW.argtypes = [ - wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, -] -_user32.PostMessageW.restype = wintypes.BOOL - -_user32.RegisterRawInputDevices.argtypes = [ - ctypes.POINTER(_RAWINPUTDEVICE), wintypes.UINT, wintypes.UINT, -] -_user32.RegisterRawInputDevices.restype = wintypes.BOOL -_user32.GetRawInputDeviceList.argtypes = [ - ctypes.POINTER(_RAWINPUTDEVICELIST), ctypes.POINTER(wintypes.UINT), - wintypes.UINT, -] -_user32.GetRawInputDeviceList.restype = wintypes.UINT -_user32.GetRawInputDeviceInfoW.argtypes = [ - wintypes.HANDLE, wintypes.UINT, wintypes.LPVOID, - ctypes.POINTER(wintypes.UINT), -] -_user32.GetRawInputDeviceInfoW.restype = wintypes.UINT -_user32.GetRawInputData.argtypes = [ - wintypes.HANDLE, wintypes.UINT, wintypes.LPVOID, - ctypes.POINTER(wintypes.UINT), wintypes.UINT, -] -_user32.GetRawInputData.restype = wintypes.UINT -_user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT] -_user32.MapVirtualKeyW.restype = wintypes.UINT - -_user32.SetWindowsHookExW.argtypes = [ - ctypes.c_int, _LL_KEYBOARD_PROC, wintypes.HINSTANCE, wintypes.DWORD, -] -_user32.SetWindowsHookExW.restype = wintypes.HHOOK -_user32.UnhookWindowsHookEx.argtypes = [wintypes.HHOOK] -_user32.UnhookWindowsHookEx.restype = wintypes.BOOL -_user32.CallNextHookEx.argtypes = [ - wintypes.HHOOK, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM, -] -_user32.CallNextHookEx.restype = _LRESULT - -_kernel32.GetModuleHandleW.argtypes = [wintypes.LPCWSTR] -_kernel32.GetModuleHandleW.restype = wintypes.HINSTANCE - -_WND_CLASS_NAME = 'KiwyWinCardReaderWindow' - -# The single active reader instance (the Raw Input / LL-hook callbacks are -# invoked from a background thread; a module-level ref avoids GC of the proc). -_ACTIVE_READER = None - - -# ── Small helpers ─────────────────────────────────────────────────────────── -def _log(msg): - """Log via Kivy Logger when available, else print.""" - try: - from kivy.logger import Logger - Logger.info(f"CardReaderWin: {msg}") - except Exception: - try: - print(f"[CardReaderWin] {msg}") - except Exception: - pass - - -def _get_config(): - """Read app_config.json (next to exe, or project config in dev mode).""" - try: - data_dir = os.environ.get('KIWY_DATA_DIR', '') - candidates = [] - if data_dir: - candidates.append(os.path.join(data_dir, 'config', 'app_config.json')) - # Dev fallback: /config/app_config.json - here = os.path.dirname(os.path.abspath(__file__)) # windows/ - candidates.append(os.path.join(os.path.dirname(here), 'config', 'app_config.json')) - for path in candidates: - if path and os.path.exists(path): - with open(path, 'r', encoding='utf-8') as f: - return json.load(f) or {} - except Exception: - pass - return {} - - -def _vk_to_char(vk): - """Convert a virtual-key code to its character, or None if not data.""" - # Numeric keypad 0-9 - if 0x60 <= vk <= 0x69: - return chr(vk - 0x60 + 0x30) - # Main-row digits - if 0x30 <= vk <= 0x39: - return chr(vk) - # Letters (uppercase) — card readers typically emit these - if 0x41 <= vk <= 0x5A: - return chr(vk) - if vk == 0x20: # space - return ' ' - # Anything else (symbols) via MapVirtualKey - try: - res = _user32.MapVirtualKeyW(vk, 2) # MAPVK_VK_TO_CHAR - if res & 0x80000000: - return None # dead key - c = res & 0xFFFF - if 0x20 <= c <= 0x7E: - return chr(c) - except Exception: - pass - return None - - -def _get_device_name(hdev): - """Return the Win32 device name (e.g. \\\\?\\HID#VID_08FF&PID_0009#...).""" - try: - size = wintypes.UINT(0) - if _user32.GetRawInputDeviceInfoW(hdev, RIDI_DEVICENAME, None, ctypes.byref(size)) == 0xFFFFFFFF: - return '' - if not size.value: - return '' - buf = ctypes.create_unicode_buffer(int(size.value) + 2) - if _user32.GetRawInputDeviceInfoW(hdev, RIDI_DEVICENAME, buf, ctypes.byref(size)) == 0xFFFFFFFF: - return '' - return buf.value or '' - except Exception: - return '' - - -def _enumerate_keyboards(): - """Return [{handle, name}] for all Raw-Input keyboard-type devices.""" - result = [] - try: - count = wintypes.UINT(0) - _user32.GetRawInputDeviceList(None, ctypes.byref(count), ctypes.sizeof(_RAWINPUTDEVICELIST)) - if not count.value: - return result - buf = (_RAWINPUTDEVICELIST * count.value)() - n = _user32.GetRawInputDeviceList(buf, ctypes.byref(count), ctypes.sizeof(_RAWINPUTDEVICELIST)) - for i in range(int(n)): - dev = buf[i] - if dev.dwType != RIM_TYPEKEYBOARD: - continue - result.append({'handle': dev.hDevice, 'name': _get_device_name(dev.hDevice)}) - except Exception as e: - _log(f"enumerate_keyboards error: {e}") - return result - - -def _choose_card_reader(override=''): - """Pick the most likely card-reader device, mirroring the Linux logic.""" - keyboards = _enumerate_keyboards() - if not keyboards: - _log("No keyboard-type Raw Input devices found") - return None - - for dev in keyboards: - _log(f" candidate: {dev['name']}") - - # Config override (substring of device name, e.g. VID_08FF) - if override: - for dev in keyboards: - if override.lower() in dev['name'].lower(): - _log(f"Using config override -> {dev['name']}") - return dev - _log(f"No device matched config override '{override}'; continuing auto-detect") - - exclusion = ('touch', 'mouse', 'trackpad', 'pen', 'stylus', 'monitor', 'video') - - # Priority 1: explicit card / reader / rfid - for dev in keyboards: - name = dev['name'].lower() - if 'card' in name or 'reader' in name or 'rfid' in name: - _log(f"Priority 1 (explicit reader) -> {dev['name']}") - return dev - - # Priority 2: USB HID keyboard that is NOT the PS/2 system keyboard - for dev in keyboards: - name = dev['name'].lower() - if 'hid' in name and 'vid' in name: - if any(p in name for p in ('pnp0303', 'pnp0c0e', 'pnp0320', 'acpi')): - continue - if any(k in name for k in exclusion): - continue - _log(f"Priority 2 (USB HID keyboard) -> {dev['name']}") - return dev - - # Priority 3: any keyboard that isn't obviously a touchscreen/mouse - for dev in keyboards: - name = dev['name'].lower() - if any(k in name for k in exclusion): - continue - _log(f"Priority 3 (any keyboard) -> {dev['name']}") - return dev - - _log(f"Fallback: using first keyboard device -> {keyboards[0]['name']}") - return keyboards[0] - - -# ── WndProc / LL-hook callbacks (called on the pump thread) ───────────────── -def _wnd_proc(hwnd, msg, wparam, lparam): - """Handle WM_INPUT / WM_INPUT_DEVICE_CHANGE for the hidden window.""" - try: - reader = _ACTIVE_READER - if reader is not None: - if msg == WM_INPUT: - reader._on_raw_input(lparam) - elif msg == WM_INPUT_DEVICE_CHANGE: - reader._on_device_change() - return _user32.DefWindowProcW(hwnd, msg, wparam, lparam) - except Exception: - try: - return _user32.DefWindowProcW(hwnd, msg, wparam, lparam) - except Exception: - return 0 - - -def _ll_hook_proc(nCode, wParam, lParam): - """Low-level keyboard hook used as a fallback capture path.""" - try: - if nCode == HC_ACTION: - reader = _ACTIVE_READER - if reader is not None and wParam in (WM_KEYDOWN, WM_SYSKEYDOWN): - kb = _KBDLLHOOKSTRUCT.from_address(lParam) - reader._on_key_event(kb.vkCode) - return _user32.CallNextHookEx(None, nCode, wParam, lParam) - except Exception: - try: - return _user32.CallNextHookEx(None, nCode, wParam, lParam) - except Exception: - return 1 - - -# ── Background message-pump thread ────────────────────────────────────────── -class _RawInputThread(threading.Thread): - def __init__(self, owner): - super().__init__(daemon=True) - self._owner = owner - self._hwnd = None - self._hook = None - self.ready = threading.Event() - - def run(self): - try: - hinst = _kernel32.GetModuleHandleW(None) - wndclass = _WNDCLASSW() - wndclass.lpfnWndProc = _WND_PROC(_wnd_proc) - wndclass.hInstance = hinst - wndclass.lpszClassName = _WND_CLASS_NAME - if not _user32.RegisterClassW(ctypes.byref(wndclass)): - raise ctypes.WinError(ctypes.get_last_error(), "RegisterClassW failed") - hwnd = _user32.CreateWindowExW( - 0, _WND_CLASS_NAME, 'KiwyWinCardReader', 0, - 0, 0, 0, 0, None, None, hinst, None, - ) - if not hwnd: - raise ctypes.WinError(ctypes.get_last_error(), "CreateWindowExW failed") - self._hwnd = hwnd - self._owner._hwnd = hwnd - - # Register Raw Input for ALL keyboards (sink = receive w/o focus) - rids = (_RAWINPUTDEVICE * 1)() - rids[0].usUsagePage = 0x01 - rids[0].usUsage = 0x06 # Generic Desktop / Keyboard - rids[0].dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY - rids[0].hwndTarget = hwnd - raw_ok = bool(_user32.RegisterRawInputDevices( - rids, 1, ctypes.sizeof(_RAWINPUTDEVICE))) - self._owner._raw_ok = raw_ok - if raw_ok: - _log("Raw Input registered for keyboard devices") - - # LL-hook fallback: use it when config says so, or if raw failed - mode = getattr(self._owner, '_mode', 'auto') - if mode == 'hook' or not raw_ok: - proc = _LL_KEYBOARD_PROC(_ll_hook_proc) - hook = _user32.SetWindowsHookExW(WH_KEYBOARD_LL, proc, hinst, 0) - if hook: - self._hook = hook - self._hook_proc = proc # keep reference alive - _log("Low-level keyboard hook ACTIVE (fallback capture)") - - self.ready.set() - - msg = _MSG() - while _user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0: - _user32.TranslateMessage(ctypes.byref(msg)) - _user32.DispatchMessageW(ctypes.byref(msg)) - - if self._hook: - try: - _user32.UnhookWindowsHookEx(self._hook) - except Exception: - pass - _user32.DestroyWindow(hwnd) - _user32.UnregisterClassW(_WND_CLASS_NAME, hinst) - except Exception as e: - _log(f"Message pump thread error: {e}") - self.ready.set() - - def stop(self): - try: - if self._hwnd: - _user32.PostMessageW(self._hwnd, 0x0012, 0, 0) # WM_QUIT - except Exception: - pass - - -# ── Public drop-in replacement for the Linux CardReader ───────────────────── -class WindowsCardReader: - """Windows-native card reader with the same interface as main.py's CardReader. - - Interface used by SignagePlayer: - read_card_async(callback) -> starts listening; callback(card_data) on - Enter, or callback(None) on timeout/cancel - stop_reading() -> stops listening - """ - - def __init__(self): - self._device = None - self._device_name = '' - self._reading = False - self._finished = False - self._callback = None - self._card_buffer = [] - self._last_activity = 0.0 - self._timeout = 5.0 - self._mode = 'auto' - self._hwnd = None - self._raw_ok = False - self._thread = None - self._lock = threading.Lock() - self._last_device_change = 0.0 # debounce WM_INPUT_DEVICE_CHANGE - - # -- config ------------------------------------------------------- - def _load_settings(self): - cfg = _get_config() - self._mode = str(cfg.get('card_reader_mode', 'auto')).lower().strip() or 'auto' - try: - self._timeout = float(cfg.get('card_reader_timeout', 5)) - except Exception: - self._timeout = 5.0 - if self._timeout <= 0: - self._timeout = 5.0 - return str(cfg.get('card_reader_device', '') or '').strip() - - # -- public API --------------------------------------------------- - def read_card_async(self, callback): - """Start reading; callback(card_data) on Enter, callback(None) on timeout.""" - if self._reading: - _log("read_card_async called while already reading — ignoring") - return - override = self._load_settings() - global _ACTIVE_READER - _ACTIVE_READER = self - - self._callback = callback - self._reading = True - self._finished = False - self._card_buffer = [] - self._last_activity = time.time() - - # Choose the target device (raw mode only). In 'hook' mode we capture - # from all keyboards via the LL-hook instead. - if self._mode != 'hook': - self._device = None - self._device_name = '' - chosen = _choose_card_reader(override=override) - if chosen: - self._device = chosen['handle'] - self._device_name = chosen['name'] - _log(f"Selected card reader: {chosen['name']}") - else: - _log("No dedicated device found — will accept any keyboard input") - else: - self._device = None - self._device_name = '' - - # Ensure the message pump is running - if self._thread is None or not self._thread.is_alive(): - self._thread = _RawInputThread(self) - self._thread.start() - self._thread.ready.wait(timeout=3.0) - - # Watchdog for the 5-second timeout - threading.Thread(target=self._timeout_watchdog, daemon=True).start() - _log(f"Waiting for card swipe (mode={self._mode}, timeout={self._timeout}s)") - - def stop_reading(self): - """Stop listening (also stops the timeout watchdog).""" - self._reading = False - - def shutdown(self): - """Stop the background pump thread (call on app exit).""" - self.stop_reading() - global _ACTIVE_READER - if _ACTIVE_READER is self: - _ACTIVE_READER = None - if self._thread is not None: - self._thread.stop() - - # -- internals ---------------------------------------------------- - def _timeout_watchdog(self): - while self._reading and not self._finished: - if time.time() - self._last_activity > self._timeout: - _log("Read timeout — sending None") - self._finish(None) - return - time.sleep(0.25) - - def _on_raw_input(self, lparam): - """Process a WM_INPUT message (called on the pump thread).""" - try: - size = wintypes.UINT(0) - _user32.GetRawInputData(lparam, RID_INPUT, None, ctypes.byref(size), - ctypes.sizeof(_RAWINPUTHEADER)) - if not size.value: - return - buf = ctypes.create_string_buffer(int(size.value)) - got = _user32.GetRawInputData(lparam, RID_INPUT, buf, ctypes.byref(size), - ctypes.sizeof(_RAWINPUTHEADER)) - if got == 0xFFFFFFFF or got == 0: - return - raw = ctypes.cast(buf, ctypes.POINTER(_RAWINPUT)).contents - if raw.header.dwType != RIM_TYPEKEYBOARD: - return - # Only accept input from the selected card-reader device. - if self._device is not None and raw.header.hDevice != self._device: - return - kb = raw.u.keyboard - if kb.Message in (WM_KEYDOWN, WM_SYSKEYDOWN): - self._on_key_event(kb.VKey) - except Exception as e: - _log(f"_on_raw_input error: {e}") - - def _on_device_change(self): - """A HID device was added/removed — re-select only if the actual - choice changes, and never more than once per second (the initial - registration/enumeration triggers a spurious change event).""" - try: - if not self._reading or self._mode == 'hook': - return - now = time.time() - if now - self._last_device_change < 1.0: - return # debounce - self._last_device_change = now - override = str(_get_config().get('card_reader_device', '') or '').strip() - chosen = _choose_card_reader(override=override) - if chosen is None: - return - # Only re-select if the winning device actually changed. - if self._device is not None and chosen['handle'] == self._device: - return - self._device = chosen['handle'] - self._device_name = chosen['name'] - _log(f"Device change — re-selected: {chosen['name']}") - except Exception: - pass - - def _on_key_event(self, vk): - """Handle a single key event (from Raw Input or LL-hook).""" - if not self._reading or self._finished: - return - if vk == _VK_RETURN: - data = ''.join(self._card_buffer).strip() - self._card_buffer = [] - if data: - _log(f"Card read complete: '{data}' (len={len(data)})") - self._finish(data) - else: - # Accidental Enter with no data — keep waiting - self._last_activity = time.time() - return - if vk in (_VK_SHIFT, _VK_LSHIFT, _VK_RSHIFT, - _VK_CONTROL, _VK_LCONTROL, _VK_RCONTROL, - _VK_MENU, _VK_LMENU, _VK_RMENU, - _VK_CAPITAL, _VK_TAB, _VK_ESCAPE): - return # modifiers / control keys are not card data - ch = _vk_to_char(vk) - if ch: - self._card_buffer.append(ch) - self._last_activity = time.time() - - def _finish(self, data): - """Deliver the result exactly once (thread-safe), on the Kivy thread.""" - with self._lock: - if self._finished: - return - self._finished = True - self._reading = False - cb = self._callback - self._callback = None - if cb is None: - return - # Prefer scheduling on the Kivy thread when a Kivy app is running - # (the callback touches Kivy widgets). If Kivy isn't running - # (manual test / non-GUI context), invoke the callback directly. - kivy_running = False - try: - from kivy.app import App - kivy_running = App.get_running_app() is not None - except Exception: - kivy_running = False - if kivy_running: - try: - from kivy.clock import Clock - Clock.schedule_once(lambda dt, d=data: cb(d), 0) - return - except Exception: - pass - try: - cb(data) - except Exception: - pass - - -if __name__ == '__main__': - # Simple manual test (no Kivy): run for a few seconds and print any card. - print("Windows Card Reader - manual test (swipe a card within 10s)") - - def _cb(data): - print(f"CALLBACK: {data!r}") - - reader = WindowsCardReader() - reader.read_card_async(_cb) - try: - time.sleep(10) - except KeyboardInterrupt: - pass - reader.stop_reading() - reader.shutdown() - print("Test done.")