Port the player to Raspberry Pi OS Trixie 64-bit (Linux-only branch)
Replaces the Windows port with a Raspberry Pi / Linux implementation on Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows code is removed here but preserved on the Windows-Player branch. Entry point ----------- linux/run_linux.py replaces windows/run_win.py. src/main.py stays platform-neutral; all Pi-specific behaviour is injected from linux/. Five bugs that prevented the port (all measured on real hardware) ---------------------------------------------------------------- 1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh symlinks the system SDL2 over the bundled filename. 2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough, unlike wlopm. This broke every systemd/cron/autostart launch. linux_display.ensure_session_environment() detects and exports it. 3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a different name crashed the player ~20s after a successful start. 4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the display blanked after 10 minutes. 5. The launchers ran src/main.py directly, bypassing every platform patch and resolving the data directory one level too high. Web links --------- - --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it aborts. The platform is now chosen explicitly. - The keyring password prompt is suppressed via the ENVIRONMENT, not the flags: launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot reach gnome-keyring-daemon. - Teardown kills the whole process group (needs start_new_session=True); previously it silently fell back to terminate() and orphaned children. Video normalisation ------------------- A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at 0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip), triggered by resolution only so already-playable files are untouched. src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker makes the player skip the item while it is being rebuilt, then the converted file is played instead. If nothing is playable at all (a single-item playlist whose only video is converting), the player loops the intro video rather than leaving a blank screen. Also fixed ---------- - network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses NetworkManager; ifconfig and dhclient are not even installed). - Removed the Windows-only focus keeper/guardian from main.py. - main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings "Test connection" now uses tempfile.gettempdir(). - config/app_config.json: credentials blanked so a fresh clone runs the first-run setup flow. Verification ------------ linux/test_media_state.py 18/18, test_linux_patches.py 21/21, test_linux_browser_flags.py 27/27. Verified live against a real DigiServer: image -> weblink -> image -> video with correct durations, zero leaked Chromium processes, and no throttling over a 10 minute monitored run.
This commit is contained in:
@@ -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.
|
|
||||||
@@ -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 |
|
||||||
|
|---|---|
|
||||||
|
| `<media>.kiwy-converting` | in flight → the player **skips** the item |
|
||||||
|
| `<media>_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.
|
||||||
+25
-18
@@ -25,12 +25,16 @@ wheels/
|
|||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
env/
|
env/
|
||||||
windows/venv312/
|
|
||||||
|
|
||||||
# Kivy
|
# Kivy
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# Media files (optional - remove if you want to track media)
|
# 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/*.jpg
|
||||||
media/*.jpeg
|
media/*.jpeg
|
||||||
media/*.png
|
media/*.png
|
||||||
@@ -41,9 +45,20 @@ media/*.avi
|
|||||||
media/*.mkv
|
media/*.mkv
|
||||||
media/*.mov
|
media/*.mov
|
||||||
media/*.webm
|
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
|
||||||
|
playlists/server_playlist.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
@@ -72,9 +87,6 @@ logs/startup_crash.log
|
|||||||
logs/console_out.txt
|
logs/console_out.txt
|
||||||
logs/console_err.txt
|
logs/console_err.txt
|
||||||
logs/watchdog_test_*.txt
|
logs/watchdog_test_*.txt
|
||||||
logs/.webview2_install_attempt
|
|
||||||
|
|
||||||
windows/venv_build/
|
|
||||||
|
|
||||||
# Player credentials — live auth_code/player_id/server_url. Never commit these:
|
# Player credentials — live auth_code/player_id/server_url. Never commit these:
|
||||||
# a bundled copy made fresh builds boot "already authenticated" against an old
|
# a bundled copy made fresh builds boot "already authenticated" against an old
|
||||||
@@ -82,20 +94,15 @@ windows/venv_build/
|
|||||||
player_auth.json
|
player_auth.json
|
||||||
src/player_auth.json
|
src/player_auth.json
|
||||||
working_files/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/
|
||||||
|
.kiosk-profile-*/
|
||||||
.webview2-profile/
|
.webview2-profile/
|
||||||
|
|
||||||
# NOTE: windows/webview2_sdk/ IS tracked on purpose — build.spec bundles those
|
# Monitoring output (generated per run, not source)
|
||||||
# DLLs, so a fresh clone must have them or web links silently fall back to the
|
logs/monitor*.csv
|
||||||
# Chrome/Edge subprocess engine. Only ~860 KB (2 files).
|
logs/monitor*.log
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -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
|
playlist item type (display a live web page / URL instead of an uploaded media
|
||||||
file).
|
file).
|
||||||
|
|
||||||
> **Status: implemented.** The player supports `weblink` items on both
|
> **Status: implemented.** The player supports `weblink` items on
|
||||||
> Raspberry Pi (`chromium` subprocess) and Windows (embedded **WebView2**, with
|
> Raspberry Pi / Linux via a Chromium kiosk subprocess.
|
||||||
> the CEF and Chrome/Edge subprocess engines as fallbacks). Sections 1–4
|
> Sections 1–4 describe the original design plan; section 6 documents the
|
||||||
> describe the original design plan; section 6 documents the shipped
|
> shipped architecture and the interaction model.
|
||||||
> 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
|
- On Wayland/X11 the player already sets `SDL_VIDEODRIVER`; verify Chromium
|
||||||
launches on the same display/session.
|
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
|
Cleaner UX (stays inside the Kivy widget tree) but fragile and poorly
|
||||||
supported on Pi/Wayland — only pursue if option 1 is unacceptable.
|
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. |
|
| `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()`. |
|
| `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). |
|
| `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). |
|
| `WebInputSources` | Reads `/dev/input/event*` to detect viewer interaction. |
|
||||||
| `webview2_browser.py` | **Windows, preferred**: embeds WebView2 as a child HWND of the Kivy window. |
|
| `linux_browser.py` | **Raspberry Pi / Linux**: `LinuxChromiumAdapter` — Chromium kiosk on Wayland (labwc). |
|
||||||
| `webview2_runtime.py` | **Windows**: detects the WebView2 Runtime and installs it silently when missing. |
|
|
||||||
|
|
||||||
Platform wrappers inject their engines through
|
Platform entry points inject their engine through
|
||||||
`SignagePlayer.weblink_adapter_factory`:
|
`SignagePlayer.weblink_adapter_factory`:
|
||||||
|
|
||||||
* **Raspberry Pi / Linux** — built-in Chromium subprocess adapter.
|
* **Raspberry Pi / Linux** (`linux/run_linux.py`) — injects
|
||||||
* **Windows** (`windows/run_win.py`) — engines are tried in this order:
|
`LinuxChromiumAdapter`, which adds the flags Trixie needs:
|
||||||
|
|
||||||
| Order | Engine | Renders | Notes |
|
| Flag | Why |
|
||||||
|-------|--------|---------|-------|
|
|------|-----|
|
||||||
| 1 | **WebView2** (`webview2_browser.py`) | child window **inside** Kivy | Preferred. No subprocess, so no background/z-order/hand-off/leak problems. |
|
| `--kiosk` | What actually makes labwc give the window exclusive fullscreen. Windows needed `--start-maximized` instead; that is not sufficient here. |
|
||||||
| 2 | CEF (`cef_browser.py`) | child window inside Kivy | Dormant: `cefpython3` has no wheels past Python 3.9. |
|
| `--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. |
|
||||||
| 3 | Chrome/Edge subprocess | separate window | Last resort only; retains the old drawbacks. |
|
| `--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
|
`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`.
|
`--user-data-dir` + `--kiosk`.
|
||||||
|
|
||||||
> **Do not give the factory a class-level `None` default combined with an
|
> **Do not give the factory a class-level `None` default combined with an
|
||||||
> unconditional instance assignment.** `SignagePlayer.__init__` originally set
|
> unconditional instance assignment.** `SignagePlayer.__init__` originally set
|
||||||
> `self.weblink_adapter_factory = None`, which shadowed the class attribute the
|
> `self.weblink_adapter_factory = None`, which shadowed the class attribute the
|
||||||
> Windows wrapper installs — so the platform adapters were silently ignored and
|
> platform entry point installs — so the platform adapters were silently ignored
|
||||||
> every weblink fell back to the generic adapter and failed. It now only sets
|
> and every weblink fell back to the generic adapter and failed. It now only sets
|
||||||
> the instance attribute when the class attribute is absent.
|
> 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
|
* `--kiosk` gives an exclusive-fullscreen window under the **labwc** compositor;
|
||||||
API surface, bundled in the exe from `windows\webview2_sdk\` (~860 KB);
|
* `--user-data-dir=<data>/.kiosk-profile` guarantees a *fresh, trackable* browser
|
||||||
* the **Runtime** (`msedgewebview2.exe`) — the actual Chromium engine, shipped
|
instead of a hand-off to a running instance;
|
||||||
by Microsoft and **verified/installed at start-up** by
|
* `--ozone-platform-hint=auto` selects Wayland natively and falls back to X11.
|
||||||
`windows\webview2_runtime.py`.
|
|
||||||
|
|
||||||
If the Runtime is absent the player runs an installer silently
|
Because a separate process cannot be introspected portably, visibility is
|
||||||
(`/silent /install`) and **unelevated**, which produces a *per-user* install and
|
verified by combining "the process survived the health grace period" with a
|
||||||
therefore never raises a UAC prompt on the signage display. A ~1.7 MB online
|
best-effort check that the PID actually owns a Wayland/X11 socket. A launch that
|
||||||
bootstrapper is bundled by default; a ~203 MB offline standalone installer can
|
dies sooner than `min_healthy_alive` is treated as a **failed launch** (hand-off,
|
||||||
be bundled instead (see `windows\webview2_runtime\download_runtime_installers.ps1`)
|
missing binary, instant crash) rather than a finished item, so the playlist
|
||||||
for machines with no internet.
|
never skips a weblink silently.
|
||||||
|
|
||||||
Install success is decided by **re-reading the installed version**, not by the
|
Stale `SingletonLock`/`SingletonSocket` files left by a crashed Chromium are
|
||||||
installer exit code — Edge Update returns a non-zero HRESULT (e.g.
|
cleared before each launch: our profile is private to the player, so removing
|
||||||
`-2147219416`) when the Runtime is already current, which is not a failure.
|
the lock is always safe and prevents Chromium refusing to start.
|
||||||
|
|
||||||
### 6.1 Interaction model — web links are not passive media
|
### 6.1 Interaction model — web links are not passive media
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ if [ -f "$STOP_FLAG_FILE" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if player process is running
|
# 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
|
if [ -z "$PLAYER_PID" ]; then
|
||||||
echo "Status: ❌ NOT RUNNING"
|
echo "Status: ❌ NOT RUNNING"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"server_ip": "192.168.0.110",
|
"server_ip": "",
|
||||||
"port": "80",
|
"port": "8080",
|
||||||
"screen_name": "DESKTOP-NJLBQKH",
|
"screen_name": "",
|
||||||
"quickconnect_key": "8887779",
|
"quickconnect_key": "",
|
||||||
"orientation": "Landscape",
|
"orientation": "Landscape",
|
||||||
"touch": "True",
|
"touch": "True",
|
||||||
"max_resolution": "1920x1080",
|
"max_resolution": "1920x1080",
|
||||||
@@ -20,6 +20,6 @@
|
|||||||
"max_dwell_factor": 6.0,
|
"max_dwell_factor": 6.0,
|
||||||
"min_max_dwell": 300,
|
"min_max_dwell": 300,
|
||||||
"launch_timeout": 15,
|
"launch_timeout": 15,
|
||||||
"prewarm": true
|
"prewarm": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.)
|
|
||||||
@@ -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/<pid>/` + 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?
|
||||||
@@ -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/<pid>/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"]}')
|
||||||
@@ -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")}')
|
||||||
@@ -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())
|
||||||
@@ -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/<pid>/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 `<stem>_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 |
|
||||||
|
|--------|---------|
|
||||||
|
| `<media>.kiwy-converting` | conversion in flight → **skip this item** |
|
||||||
|
| `<media>_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)
|
||||||
Executable
+166
@@ -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:-<none>}"
|
||||||
|
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
|
||||||
@@ -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/<pid>/cmdline`` rather than shelling out to ``pgrep`` so
|
||||||
|
this works without procps and cannot match the wrong process.
|
||||||
|
"""
|
||||||
|
if not self._profile_dir:
|
||||||
|
return []
|
||||||
|
marker = self._profile_dir
|
||||||
|
own_uid = os.getuid()
|
||||||
|
killed = []
|
||||||
|
try:
|
||||||
|
for entry in os.listdir('/proc'):
|
||||||
|
if not entry.isdigit():
|
||||||
|
continue
|
||||||
|
pid = int(entry)
|
||||||
|
if pid == os.getpid():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if os.stat(f'/proc/{pid}').st_uid != own_uid:
|
||||||
|
continue
|
||||||
|
with open(f'/proc/{pid}/cmdline', 'rb') as fh:
|
||||||
|
cmdline = fh.read().replace(b'\x00', b' ').decode(
|
||||||
|
'utf-8', 'replace'
|
||||||
|
)
|
||||||
|
except (OSError, PermissionError, ProcessLookupError):
|
||||||
|
continue
|
||||||
|
if marker in cmdline and 'chrom' in cmdline.lower():
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
killed.append(pid)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
_log(f'profile scan failed: {exc}', 'debug')
|
||||||
|
if killed:
|
||||||
|
_log(f'terminated {len(killed)} leaked browser(s) on the kiosk '
|
||||||
|
f'profile: {killed}')
|
||||||
|
return killed
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module-level helpers ─────────────────────────────────────────────
|
||||||
|
def _detect_wayland():
|
||||||
|
"""True when the session looks like Wayland.
|
||||||
|
|
||||||
|
Uses ``linux_display`` when importable (it also fills in an unset
|
||||||
|
``WAYLAND_DISPLAY``, which Chromium needs for --ozone-platform-hint), and
|
||||||
|
falls back to a socket probe so this module stays independently testable.
|
||||||
|
"""
|
||||||
|
if os.environ.get('WAYLAND_DISPLAY'):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
import linux_display
|
||||||
|
|
||||||
|
linux_display.ensure_session_environment()
|
||||||
|
return linux_display.is_wayland()
|
||||||
|
except Exception:
|
||||||
|
runtime = os.environ.get('XDG_RUNTIME_DIR') or f'/run/user/{os.getuid()}'
|
||||||
|
return os.path.exists(os.path.join(runtime, 'wayland-0'))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_getpgid(pid):
|
||||||
|
try:
|
||||||
|
return os.getpgid(pid)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_process_group(proc, pgid):
|
||||||
|
"""SIGTERM then SIGKILL the browser's process group."""
|
||||||
|
target = pgid if pgid and pgid != os.getpgid(0) else None
|
||||||
|
try:
|
||||||
|
if target:
|
||||||
|
os.killpg(target, signal.SIGTERM)
|
||||||
|
else:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Give Chromium a moment to flush and exit cleanly.
|
||||||
|
deadline = time.monotonic() + 5.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if proc.poll() is None:
|
||||||
|
try:
|
||||||
|
if target:
|
||||||
|
os.killpg(target, signal.SIGKILL)
|
||||||
|
else:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=3)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _window_exists_for_pid(pid):
|
||||||
|
"""Best-effort check that ``pid`` owns a visible top-level surface.
|
||||||
|
|
||||||
|
Two independent probes, because neither works everywhere:
|
||||||
|
|
||||||
|
1. ``/proc/<pid>/fd`` — Chromium holds the Wayland/X11 socket open once it
|
||||||
|
has connected and started creating surfaces.
|
||||||
|
2. ``/proc/<pid>/task/*/comm`` — the GPU/renderer children only appear once
|
||||||
|
the browser has actually started rendering.
|
||||||
|
|
||||||
|
Returns False on any error; the caller falls back to "process alive".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fd_dir = f'/proc/{pid}/fd'
|
||||||
|
socket_links = 0
|
||||||
|
for fd in os.listdir(fd_dir):
|
||||||
|
try:
|
||||||
|
target = os.readlink(os.path.join(fd_dir, fd))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if 'wayland' in target or 'X11-unix' in target:
|
||||||
|
socket_links += 1
|
||||||
|
if socket_links:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
task_dir = f'/proc/{pid}/task'
|
||||||
|
for tid in os.listdir(task_dir):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(task_dir, tid, 'comm')) as fh:
|
||||||
|
comm = fh.read().strip()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if comm in ('Chrome_ChildIOT', 'Chrome_IOThread'):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -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 ``<name> <on|off>`` 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))
|
||||||
@@ -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())
|
||||||
@@ -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: <root>/linux/run_linux.py -> <root> is the data directory and
|
||||||
|
# <root>/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)
|
||||||
@@ -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/<pid>/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'(?<!^){name}(?!\s*=\s*\[)', source, re.MULTILINE))
|
||||||
|
check(f'{name} is referenced', uses > 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.')
|
||||||
@@ -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'<error: {exc}>']
|
||||||
|
|
||||||
|
|
||||||
|
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.')
|
||||||
@@ -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.')
|
||||||
@@ -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())
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 386 KiB |
+11
-3
@@ -1,5 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Start Kivy Signage Player
|
# Start Kivy Signage Player (single run, no watchdog).
|
||||||
cd "$(dirname "$0")/src"
|
#
|
||||||
python3 main.py
|
# 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
|
||||||
|
|||||||
+4
-4
@@ -324,10 +324,10 @@ class EditPopup(Popup):
|
|||||||
shutil.copy2(output_path, self.image_path)
|
shutil.copy2(output_path, self.image_path)
|
||||||
|
|
||||||
# Force file system sync to ensure data is written to disk.
|
# Force file system sync to ensure data is written to disk.
|
||||||
# NOTE: os.sync() is Linux-only and raises AttributeError on
|
# NOTE: os.sync() does not exist on every platform and used
|
||||||
# Windows — that used to abort the whole pipeline before the
|
# to raise AttributeError, aborting the whole pipeline before
|
||||||
# metadata/upload steps. Use a cross-platform fsync that is
|
# the metadata/upload steps. Use a best-effort flushes that can
|
||||||
# best-effort and can never break the save/upload flow.
|
# never break the save/upload flow.
|
||||||
try:
|
try:
|
||||||
if hasattr(os, 'sync'):
|
if hasattr(os, 'sync'):
|
||||||
os.sync()
|
os.sync()
|
||||||
|
|||||||
+135
-7
@@ -7,8 +7,11 @@ import os
|
|||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
from player_auth import PlayerAuth
|
from player_auth import PlayerAuth
|
||||||
from ssl_utils import SSLManager
|
from ssl_utils import SSLManager
|
||||||
|
import media_state # conversion flags/markers, shared with the player
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -340,7 +343,25 @@ def delete_unused_media(playlist_data, media_dir):
|
|||||||
referenced_files.add(file_name)
|
referenced_files.add(file_name)
|
||||||
|
|
||||||
logger.info(f"📋 Current playlist references {len(referenced_files)} files")
|
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):
|
if os.path.exists(media_dir):
|
||||||
# Recursively get all media files
|
# Recursively get all media files
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
@@ -349,20 +370,47 @@ def delete_unused_media(playlist_data, media_dir):
|
|||||||
# Get relative path from media_dir
|
# Get relative path from media_dir
|
||||||
full_path = os.path.join(root, media_file)
|
full_path = os.path.join(root, media_file)
|
||||||
rel_path = os.path.relpath(full_path, media_dir)
|
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
|
# 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('\\', '/')
|
normalized_rel = rel_path.replace('\\', '/')
|
||||||
if normalized_rel in referenced_files or rel_path in referenced_files:
|
if normalized_rel in referenced_files or rel_path in referenced_files:
|
||||||
continue
|
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
|
# Delete unreferenced file
|
||||||
try:
|
try:
|
||||||
os.remove(full_path)
|
os.remove(full_path)
|
||||||
logger.info(f"🗑️ Deleted unused media: {rel_path}")
|
logger.info(f'🗑️ Deleted unused media: {rel_path}')
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
except Exception as e:
|
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
|
# Clean up empty directories
|
||||||
for root, dirs, files in os.walk(media_dir, topdown=False):
|
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):
|
def update_playlist_if_needed(config, playlist_dir, media_dir):
|
||||||
"""Check for and download updated playlist if available.
|
"""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)
|
# Save new playlist (single file, no versioning)
|
||||||
playlist_file = save_playlist(server_data, playlist_dir)
|
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 files
|
||||||
delete_unused_media(server_data, media_dir)
|
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
|
server_data['playlist'] = downloaded
|
||||||
# Re-save playlist with updated URLs if needed
|
# Re-save playlist with updated URLs if needed
|
||||||
save_playlist(server_data, playlist_dir)
|
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
|
return playlist_file
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+307
-237
@@ -11,14 +11,15 @@ import platform
|
|||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import asyncio
|
import asyncio
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
# Set environment variables for better video performance
|
# Set environment variables for better video performance.
|
||||||
# Use setdefault() so that a wrapper script (e.g. run_win.py) can pre-set
|
# Use setdefault() so that the platform entry point (linux/run_linux.py) can
|
||||||
# Windows-compatible values before this module is imported.
|
# pre-set display/audio values before this module is imported.
|
||||||
os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer')
|
os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer')
|
||||||
os.environ.setdefault('FFPYPLAYER_CODECS', 'h264,h265,vp9,vp8')
|
os.environ.setdefault('FFPYPLAYER_CODECS', 'h264,h265,vp9,vp8')
|
||||||
os.environ.setdefault('SDL_VIDEO_ALLOW_SCREENSAVER', '0')
|
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('KIVY_INPUTPROVIDERS', 'wayland,x11')
|
||||||
os.environ.setdefault('FFMPEG_THREADS', '2')
|
os.environ.setdefault('FFMPEG_THREADS', '2')
|
||||||
os.environ.setdefault('LIBPLAYER_BUFFER', '1048576')
|
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
|
# Configure Kivy BEFORE importing any Kivy modules
|
||||||
from kivy.config import Config
|
from kivy.config import Config
|
||||||
@@ -94,6 +97,7 @@ from kivy.uix.floatlayout import FloatLayout
|
|||||||
from kivy.uix.slider import Slider
|
from kivy.uix.slider import Slider
|
||||||
from playback_trace import trace # always-on playback transition logger
|
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 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 (
|
from weblink_session import (
|
||||||
WeblinkSession,
|
WeblinkSession,
|
||||||
WeblinkSettings,
|
WeblinkSettings,
|
||||||
@@ -103,7 +107,7 @@ from weblink_session import (
|
|||||||
# Load the KV file - resolve relative to this file's directory
|
# 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')
|
_kv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'signage_player.kv')
|
||||||
if not os.path.exists(_kv_path):
|
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'
|
_kv_path = 'signage_player.kv'
|
||||||
Builder.load_file(_kv_path)
|
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})")
|
Logger.info(f"SettingsPopup: Testing connection to {server_url} (HTTPS: {use_https}, Verify SSL: {verify_ssl})")
|
||||||
|
|
||||||
# Create temporary auth instance (don't save)
|
# 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
|
# Try to authenticate
|
||||||
success, error = auth.authenticate(
|
success, error = auth.authenticate(
|
||||||
@@ -826,10 +834,9 @@ class SettingsPopup(Popup):
|
|||||||
|
|
||||||
# Clean up temp file
|
# Clean up temp file
|
||||||
try:
|
try:
|
||||||
import os
|
if os.path.exists(temp_file):
|
||||||
if os.path.exists('/tmp/temp_auth_test.json'):
|
os.remove(temp_file)
|
||||||
os.remove('/tmp/temp_auth_test.json')
|
except Exception:
|
||||||
except:
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Update UI on main thread
|
# Update UI on main thread
|
||||||
@@ -993,8 +1000,7 @@ class SettingsPopup(Popup):
|
|||||||
|
|
||||||
|
|
||||||
# ── First-run configuration ──────────────────────────────────────────
|
# ── First-run configuration ──────────────────────────────────────────
|
||||||
# The player is shipped WITHOUT any server credentials: `app_config.json` is
|
# The player ships WITHOUT any server credentials. On first start there is
|
||||||
# not bundled into the exe (see windows/build.spec). On first start there is
|
|
||||||
# nothing to connect to, so the player shows a notice after the splash video
|
# nothing to connect to, so the player shows a notice after the splash video
|
||||||
# and then opens Settings automatically.
|
# and then opens Settings automatically.
|
||||||
#
|
#
|
||||||
@@ -1039,6 +1045,12 @@ DEFAULT_CONFIG = {
|
|||||||
#: Seconds the "not configured" notice stays up before Settings opens.
|
#: Seconds the "not configured" notice stays up before Settings opens.
|
||||||
SETUP_NOTICE_SECONDS = 5
|
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):
|
def config_is_configured(config):
|
||||||
"""True when ``config`` has enough real values to talk to a server.
|
"""True when ``config`` has enough real values to talk to a server.
|
||||||
@@ -1077,21 +1089,14 @@ class SignagePlayer(Widget):
|
|||||||
self.playlist = []
|
self.playlist = []
|
||||||
self.current_index = 0
|
self.current_index = 0
|
||||||
self.current_widget = None
|
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
|
# Unified web-link controller. Owns launch, verified start, idle/max-dwell
|
||||||
# watching and teardown for one weblink item at a time. Platform wrappers
|
# watching and teardown for one weblink item at a time. The platform
|
||||||
# (e.g. windows/run_win.py) inject their adapters via
|
# entry point (linux/run_linux.py) injects its adapters via
|
||||||
# `weblink_adapter_factory` before playback starts.
|
# `weblink_adapter_factory` before playback starts.
|
||||||
#
|
#
|
||||||
# CRITICAL: the factory is injected as a *class* attribute, so do NOT
|
# CRITICAL: the factory is injected as a *class* attribute, so do NOT
|
||||||
# assign None unconditionally here. An instance attribute would shadow
|
# assign None unconditionally here. An instance attribute would shadow
|
||||||
# it, play_weblink() would silently fall back to the generic adapter,
|
# it and 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.
|
|
||||||
self._weblink_session = None
|
self._weblink_session = None
|
||||||
if not callable(getattr(type(self), 'weblink_adapter_factory', None)):
|
if not callable(getattr(type(self), 'weblink_adapter_factory', None)):
|
||||||
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._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._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._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_event = None # Clock interval that watches video progress
|
||||||
self._video_watchdog_stopped = False
|
self._video_watchdog_stopped = False
|
||||||
# self.should_refresh_playlist = False # Flag to reload playlist after edit upload (DISABLED - causing crashes)
|
# self.should_refresh_playlist = False # Flag to reload playlist after edit upload (DISABLED - causing crashes)
|
||||||
self.consecutive_errors = 0 # Track consecutive playback errors
|
self.consecutive_errors = 0 # Track consecutive playback errors
|
||||||
self.max_consecutive_errors = 10 # Maximum errors before stopping
|
self.max_consecutive_errors = 10 # Maximum errors before stopping
|
||||||
self.intro_played = False # Track if intro has been played
|
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
|
# Card reader for authentication
|
||||||
self.card_reader = None
|
self.card_reader = None
|
||||||
self._pending_edit_image = None
|
self._pending_edit_image = None
|
||||||
@@ -1160,18 +1161,11 @@ class SignagePlayer(Widget):
|
|||||||
Clock.schedule_interval(self.update_heartbeat, 10) # Update every 10 seconds
|
Clock.schedule_interval(self.update_heartbeat, 10) # Update every 10 seconds
|
||||||
# Start screen activity signaler (keep display awake)
|
# Start screen activity signaler (keep display awake)
|
||||||
Clock.schedule_interval(self.signal_screen_activity, 20) # Signal every 20 seconds
|
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):
|
def _update_size(self, instance, value):
|
||||||
# Keep the screen-size properties in sync with the real window size.
|
# Keep the screen-size properties in sync with the real window size.
|
||||||
# On Windows, Window.size is the TRUE (DPI-aware) resolution; without
|
# Without this the content_area could be sized from a stale or
|
||||||
# this the content_area could be sized from a stale/pre-fullscreen
|
# pre-fullscreen value, leaving a black strip and wrong scaling.
|
||||||
# value, leaving a black strip and wrong image/video scaling.
|
|
||||||
self.size = value
|
self.size = value
|
||||||
try:
|
try:
|
||||||
w, h = value
|
w, h = value
|
||||||
@@ -1250,8 +1244,6 @@ class SignagePlayer(Widget):
|
|||||||
- Window close (X / Alt+F4) is blocked by _guard_window_close until
|
- Window close (X / Alt+F4) is blocked by _guard_window_close until
|
||||||
the password exit flow calls set_allow_exit(True).
|
the password exit flow calls set_allow_exit(True).
|
||||||
- Ctrl+C is ignored so the console cannot kill the player.
|
- 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)
|
self.config['production_mode'] = bool(enabled)
|
||||||
if enabled:
|
if enabled:
|
||||||
@@ -1387,7 +1379,7 @@ class SignagePlayer(Widget):
|
|||||||
"""Show the "not configured" notice, then open Settings automatically.
|
"""Show the "not configured" notice, then open Settings automatically.
|
||||||
|
|
||||||
The player is shipped without credentials, so this is the expected
|
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
|
missing, wait SETUP_NOTICE_SECONDS, then open the settings screen so
|
||||||
they can enter the server details.
|
they can enter the server details.
|
||||||
"""
|
"""
|
||||||
@@ -1419,7 +1411,7 @@ class SignagePlayer(Widget):
|
|||||||
"""Called when Settings was used to configure the player.
|
"""Called when Settings was used to configure the player.
|
||||||
|
|
||||||
Applies the new values immediately and starts playback, so the operator
|
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)
|
self._configured = config_is_configured(self.config)
|
||||||
if not self._configured:
|
if not self._configured:
|
||||||
@@ -1726,6 +1718,16 @@ class SignagePlayer(Widget):
|
|||||||
if self.is_paused:
|
if self.is_paused:
|
||||||
Logger.debug(f"SignagePlayer: Skipping play_current_media - player is paused")
|
Logger.debug(f"SignagePlayer: Skipping play_current_media - player is paused")
|
||||||
return
|
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:
|
if not self.playlist:
|
||||||
Logger.warning("SignagePlayer: Cannot play - playlist is empty")
|
Logger.warning("SignagePlayer: Cannot play - playlist is empty")
|
||||||
@@ -1753,7 +1755,7 @@ class SignagePlayer(Widget):
|
|||||||
type=media_item.get('type', '?'),
|
type=media_item.get('type', '?'),
|
||||||
duration=duration,
|
duration=duration,
|
||||||
after_weblink=_after_weblink,
|
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 ──────────────────
|
# ── Leaving a weblink: close the browser safely ──────────────────
|
||||||
# Weblinks are torn down by the WeblinkSession. `close()` is
|
# Weblinks are torn down by the WeblinkSession. `close()` is
|
||||||
@@ -1838,6 +1840,48 @@ class SignagePlayer(Widget):
|
|||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
self._skip_to_next_media()
|
self._skip_to_next_media()
|
||||||
return
|
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
|
# Remove status label if showing
|
||||||
self.ids.status_label.opacity = 0
|
self.ids.status_label.opacity = 0
|
||||||
@@ -1860,11 +1904,16 @@ class SignagePlayer(Widget):
|
|||||||
item_muted = True
|
item_muted = True
|
||||||
if item_muted:
|
if item_muted:
|
||||||
Logger.info(f"SignagePlayer: Video muted per playlist (audio={item_audio}, muted={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)
|
self.play_video(media_path, duration, muted=item_muted)
|
||||||
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
|
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
|
||||||
# Image file
|
# Image file
|
||||||
Logger.debug(f"SignagePlayer: Media type: IMAGE")
|
Logger.debug(f"SignagePlayer: Media type: IMAGE")
|
||||||
trace("starting_image", path=media_path)
|
trace("starting_image", path=media_path)
|
||||||
|
self._stop_intro_loop()
|
||||||
self.play_image(media_path, duration, force_reload=force_reload)
|
self.play_image(media_path, duration, force_reload=force_reload)
|
||||||
else:
|
else:
|
||||||
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
|
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
|
Needed because ffpyplayer initialises SDL2_mixer from the FIRST audio
|
||||||
file it opens and reuses those parameters. Handing it a video with NO
|
file it opens and reuses those parameters. Handing it a video with NO
|
||||||
audio track (integer division of rate/channels by zero internally)
|
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
|
(0xc0000005) — observed when a silent 4K clip entered the playlist
|
||||||
after an AAC stereo clip had already been played.
|
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.
|
determine the streams, we report True and let the normal path proceed.
|
||||||
"""
|
"""
|
||||||
try:
|
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):
|
if not os.path.exists(ffprobe):
|
||||||
# Development run: fall back to PATH / the ffpyplayer bundle.
|
# Development run: fall back to PATH / the ffpyplayer bundle.
|
||||||
import shutil as _shutil
|
import shutil as _shutil
|
||||||
@@ -1981,10 +2030,10 @@ class SignagePlayer(Widget):
|
|||||||
# CALLING thread during unload. Kivy's own on_eos handler sets
|
# CALLING thread during unload. Kivy's own on_eos handler sets
|
||||||
# state='stop' (which joins the ffpyplayer decode thread) while the
|
# state='stop' (which joins the ffpyplayer decode thread) while the
|
||||||
# event is being dispatched — i.e. on the Kivy main thread. When
|
# 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
|
# that thread is slow to exit, the join parks the UI thread and the
|
||||||
# Windows declares the app hung (AppHangB1, observed after ~30-45
|
# whole player freezes (observed after ~30-45 minutes of looping).
|
||||||
# minutes of looping). Must run BEFORE the widget is constructed:
|
# Must run BEFORE the widget is constructed: the decode thread is
|
||||||
# the decode thread is created during play(). See src/video_safety.py.
|
# created during play(). See src/video_safety.py.
|
||||||
suppress_kivy_video_blocking_unload()
|
suppress_kivy_video_blocking_unload()
|
||||||
|
|
||||||
# Create Video widget with optimized settings for smooth playback.
|
# Create Video widget with optimized settings for smooth playback.
|
||||||
@@ -2016,11 +2065,7 @@ class SignagePlayer(Widget):
|
|||||||
# Add to content area
|
# Add to content area
|
||||||
self.ids.content_area.add_widget(self.current_widget)
|
self.ids.content_area.add_widget(self.current_widget)
|
||||||
|
|
||||||
# Start the focus keeper so the window stays foreground for the
|
trace("video_loaded")
|
||||||
# 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")
|
|
||||||
|
|
||||||
# Start a progress watchdog. ffpyplayer does NOT reliably dispatch
|
# Start a progress watchdog. ffpyplayer does NOT reliably dispatch
|
||||||
# EOS — the trace shows the video looping its tail for the gap
|
# EOS — the trace shows the video looping its tail for the gap
|
||||||
@@ -2087,170 +2132,6 @@ class SignagePlayer(Widget):
|
|||||||
pass
|
pass
|
||||||
self._video_watchdog_event = None
|
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):
|
def _on_video_eos(self, instance):
|
||||||
"""Callback when video reaches end of stream.
|
"""Callback when video reaches end of stream.
|
||||||
|
|
||||||
@@ -2385,7 +2266,6 @@ class SignagePlayer(Widget):
|
|||||||
widget = self.current_widget
|
widget = self.current_widget
|
||||||
self.current_widget = None
|
self.current_widget = None
|
||||||
self._video_eos_pending = False
|
self._video_eos_pending = False
|
||||||
self._stop_focus_keeper()
|
|
||||||
self._stop_video_watchdog()
|
self._stop_video_watchdog()
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=self._teardown_video_async,
|
target=self._teardown_video_async,
|
||||||
@@ -2403,7 +2283,6 @@ class SignagePlayer(Widget):
|
|||||||
self.current_widget = None
|
self.current_widget = None
|
||||||
# Reset the EOS guard so the next video can advance normally.
|
# Reset the EOS guard so the next video can advance normally.
|
||||||
self._video_eos_pending = False
|
self._video_eos_pending = False
|
||||||
self._stop_focus_keeper()
|
|
||||||
self._stop_video_watchdog()
|
self._stop_video_watchdog()
|
||||||
Logger.debug("SignagePlayer: Previous widget removed")
|
Logger.debug("SignagePlayer: Previous widget removed")
|
||||||
trace("widget_removed")
|
trace("widget_removed")
|
||||||
@@ -2606,6 +2485,208 @@ class SignagePlayer(Widget):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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):
|
def _kill_weblink_process(self):
|
||||||
"""Terminate the kiosk browser, idle watcher and any pre-warm process.
|
"""Terminate the kiosk browser, idle watcher and any pre-warm process.
|
||||||
|
|
||||||
@@ -2618,7 +2699,6 @@ class SignagePlayer(Widget):
|
|||||||
session.close()
|
session.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
Logger.warning(f"SignagePlayer: Error closing weblink browser: {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
|
# 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
|
# 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.
|
"""Pre-warm the browser for an upcoming web-link item.
|
||||||
|
|
||||||
Delegated to :class:`WeblinkSession`, which asks the preferred engine to
|
Delegated to :class:`WeblinkSession`, which asks the preferred engine to
|
||||||
warm its binary and page cache. Platform layers may override this (the
|
warm its binary and page cache.
|
||||||
Windows wrapper disables pre-warm because an off-screen browser there
|
|
||||||
interferes with audio and GPU resources).
|
|
||||||
"""
|
"""
|
||||||
session = self.get_weblink_session()
|
session = self.get_weblink_session()
|
||||||
session.prewarm(url)
|
session.prewarm(url)
|
||||||
@@ -3228,14 +3306,6 @@ class SignagePlayerApp(App):
|
|||||||
def on_stop(self):
|
def on_stop(self):
|
||||||
Logger.info("SignagePlayerApp: Application stopped")
|
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
|
# Close any kiosk browser opened for a weblink item
|
||||||
try:
|
try:
|
||||||
if self.root and hasattr(self.root, '_kill_weblink_process'):
|
if self.root and hasattr(self.root, '_kill_weblink_process'):
|
||||||
|
|||||||
@@ -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::
|
||||||
|
|
||||||
|
<media>.kiwy-converting EXISTS while a conversion is in flight
|
||||||
|
<media>_kiwy1080p.mp4 the converted output
|
||||||
|
<media>_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
|
||||||
+82
-55
@@ -6,15 +6,16 @@ Checks server connectivity and manages WiFi restart on connection failure
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
import platform
|
import shutil
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from kivy.logger import Logger
|
from kivy.logger import Logger
|
||||||
from kivy.clock import Clock
|
from kivy.clock import Clock
|
||||||
|
|
||||||
# Detect platform once so the ping / WiFi-restart commands below can
|
#: Interface used by the restart fallbacks. Raspberry Pi OS names the wireless
|
||||||
# pick the correct syntax (Linux vs Windows).
|
#: interface wlan0; only used by the legacy path (NetworkManager resolves the
|
||||||
IS_WINDOWS = platform.system() == 'Windows'
|
#: device itself via `nmcli device`).
|
||||||
|
WIFI_INTERFACE = 'wlan0'
|
||||||
|
|
||||||
|
|
||||||
class NetworkMonitor:
|
class NetworkMonitor:
|
||||||
@@ -104,13 +105,9 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
||||||
|
|
||||||
# Ping the server hostname with 3 attempts.
|
# Ping the server hostname with 3 attempts (Linux syntax: -c count,
|
||||||
# Windows ping uses -n for count and -w for timeout (ms),
|
# -W per-packet timeout in seconds).
|
||||||
# while Linux uses -c and -W.
|
cmd = ['ping', '-c', '3', '-W', '3', hostname]
|
||||||
if IS_WINDOWS:
|
|
||||||
cmd = ['ping', '-n', '3', '-w', '3000', hostname]
|
|
||||||
else:
|
|
||||||
cmd = ['ping', '-c', '3', '-W', '3', hostname]
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -135,9 +132,12 @@ class NetworkMonitor:
|
|||||||
def _restart_wifi(self):
|
def _restart_wifi(self):
|
||||||
"""
|
"""
|
||||||
Restart WiFi by turning it off for a specified duration then back on.
|
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
|
Raspberry Pi OS Trixie manages networking with **NetworkManager**, so
|
||||||
- Windows: netsh wlan disconnect / connect
|
``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.
|
This runs in a separate thread to not block the main application.
|
||||||
"""
|
"""
|
||||||
def wifi_restart_thread():
|
def wifi_restart_thread():
|
||||||
@@ -146,10 +146,10 @@ class NetworkMonitor:
|
|||||||
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
if IS_WINDOWS:
|
if shutil.which('nmcli'):
|
||||||
self._restart_wifi_windows()
|
self._restart_wifi_nmcli()
|
||||||
else:
|
else:
|
||||||
self._restart_wifi_linux()
|
self._restart_wifi_legacy()
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
||||||
@@ -161,58 +161,90 @@ class NetworkMonitor:
|
|||||||
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def _restart_wifi_windows(self):
|
def _restart_wifi_nmcli(self):
|
||||||
"""Windows WiFi restart using netsh. Turn off for the wait period,
|
"""WiFi restart via NetworkManager (the default on Raspberry Pi OS).
|
||||||
then turn back on so Windows reconnects to the preferred network."""
|
|
||||||
|
``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
|
wait_minutes = self.wifi_restart_duration / 60
|
||||||
Logger.info(
|
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(
|
off = subprocess.run(
|
||||||
['netsh', 'wlan', 'disconnect'],
|
['nmcli', 'radio', 'wifi', 'off'],
|
||||||
capture_output=True, text=True, timeout=10
|
capture_output=True, text=True, timeout=15
|
||||||
)
|
)
|
||||||
if off.returncode == 0:
|
if off.returncode == 0:
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF (netsh wlan disconnect)")
|
Logger.info("NetworkMonitor: ✓ WiFi radio disabled (nmcli radio wifi off)")
|
||||||
else:
|
else:
|
||||||
|
# Some builds deny `radio off` to non-root; a device disconnect
|
||||||
|
# still forces a reconnect without needing privileges.
|
||||||
Logger.warning(
|
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(
|
Logger.info(
|
||||||
f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes "
|
f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes "
|
||||||
f"(started {datetime.now().strftime('%H:%M:%S')})"
|
f"(started {datetime.now().strftime('%H:%M:%S')})"
|
||||||
)
|
)
|
||||||
time.sleep(self.wifi_restart_duration)
|
time.sleep(self.wifi_restart_duration)
|
||||||
Logger.info(
|
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(
|
on = subprocess.run(
|
||||||
['netsh', 'wlan', 'connect'],
|
['nmcli', 'radio', 'wifi', 'on'],
|
||||||
capture_output=True, text=True, timeout=10
|
capture_output=True, text=True, timeout=15
|
||||||
)
|
)
|
||||||
if on.returncode == 0:
|
if on.returncode == 0:
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi re-enabled (netsh wlan connect)")
|
Logger.info("NetworkMonitor: ✓ WiFi radio enabled (nmcli radio wifi on)")
|
||||||
else:
|
else:
|
||||||
# 'netsh wlan connect' without a profile may return non-zero even
|
Logger.error(f"NetworkMonitor: nmcli radio on failed: {on.stderr.strip()}")
|
||||||
# though the radio comes back on; log it but don't fail hard.
|
|
||||||
Logger.warning(
|
# Give NetworkManager a moment, then nudge the saved connection up.
|
||||||
f"NetworkMonitor: netsh wlan connect returned {on.returncode}: "
|
time.sleep(10)
|
||||||
f"{on.stderr.strip()} (may reconnect automatically)"
|
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: ====================================")
|
||||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
def _restart_wifi_linux(self):
|
def _restart_wifi_legacy(self):
|
||||||
"""Linux (Raspberry Pi) WiFi restart using rfkill/ifconfig/dhclient."""
|
"""Fallback for systems without NetworkManager: rfkill + ip.
|
||||||
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
|
||||||
|
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...")
|
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['sudo', 'rfkill', 'block', 'wifi'],
|
['sudo', 'rfkill', 'block', 'wifi'],
|
||||||
@@ -224,19 +256,19 @@ class NetworkMonitor:
|
|||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
||||||
else:
|
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}")
|
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||||
|
|
||||||
# Fallback to ifconfig
|
# Fallback to ip link (ifconfig is not installed on Trixie)
|
||||||
result2 = subprocess.run(
|
result2 = subprocess.run(
|
||||||
['sudo', 'ifconfig', 'wlan0', 'down'],
|
['sudo', 'ip', 'link', 'set', WIFI_INTERFACE, 'down'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
|
|
||||||
if result2.returncode == 0:
|
if result2.returncode == 0:
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ip link)")
|
||||||
else:
|
else:
|
||||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
||||||
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
||||||
@@ -275,7 +307,7 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
# Also bring interface up
|
# Also bring interface up
|
||||||
result2 = subprocess.run(
|
result2 = subprocess.run(
|
||||||
['sudo', 'ifconfig', 'wlan0', 'up'],
|
['sudo', 'ip', 'link', 'set', WIFI_INTERFACE, 'up'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10
|
||||||
@@ -284,19 +316,14 @@ class NetworkMonitor:
|
|||||||
if result2.returncode == 0:
|
if result2.returncode == 0:
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
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...")
|
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
||||||
time.sleep(10)
|
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: ====================================")
|
||||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
playback_trace.py — Always-on playback transition logger.
|
playback_trace.py — Always-on playback transition logger.
|
||||||
|
|
||||||
Kivy's log level is forced to 'warning' in main.py / run_win.py, which
|
Kivy's log level is forced to 'warning' in main.py, which suppresses every
|
||||||
suppresses every Logger.info()/Logger.debug() line. That made it impossible
|
Logger.info()/Logger.debug() line. That made it impossible
|
||||||
to see why the player skips/crashes at the weblink->image and video->next
|
to see why the player skips/crashes at the weblink->image and video->next
|
||||||
transitions.
|
transitions.
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
The bug this fixes
|
The bug this fixes
|
||||||
------------------
|
------------------
|
||||||
The player hung (Windows `AppHangB1`) after ~30-45 minutes of looping, always
|
The player froze after ~30-45 minutes of looping, always at a *video* item.
|
||||||
at a *video* item. The trace stopped dead right after `video_loaded` with no
|
The trace stopped dead right after `video_loaded` with no `video_eos` and no
|
||||||
`video_eos` and no `advance_after_video_eos`.
|
`advance_after_video_eos`.
|
||||||
|
|
||||||
Cause — a blocking ``join()`` that Kivy performs on the calling thread:
|
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
|
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
|
(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
|
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
|
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
|
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
|
depends on Kivy's own handler firing first and on that particular video's
|
||||||
|
|||||||
+57
-71
@@ -2,17 +2,14 @@
|
|||||||
|
|
||||||
Why this module exists
|
Why this module exists
|
||||||
----------------------
|
----------------------
|
||||||
Web-link items used to be implemented three times:
|
Web-link items used to be implemented in several places at once — ``main.py``
|
||||||
* ``main.py`` — Chromium subprocess + /dev/input idle watchdog (Linux/Pi)
|
(Chromium subprocess + ``/dev/input`` idle watchdog) and a platform wrapper
|
||||||
* ``run_win.py`` — Chrome/Edge subprocess + Win32 overlay + focus fighting
|
(Chrome subprocess + overlay + focus fighting) — each owning its own process
|
||||||
* ``cef_browser.py`` — embedded CEF child window (Windows, optional)
|
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
|
This module replaces all of them with a single owner:
|
||||||
logic, so "who owns the browser" was ambiguous. That ambiguity caused the
|
|
||||||
documented failure modes: leaked browsers, skipped items, lost foreground and
|
|
||||||
blank screens when a page failed to load.
|
|
||||||
|
|
||||||
This module replaces all three with a single owner:
|
|
||||||
|
|
||||||
:class:`WeblinkSession` — orchestrates one weblink item at a time.
|
:class:`WeblinkSession` — orchestrates one weblink item at a time.
|
||||||
* validates the URL before anything is launched
|
* validates the URL before anything is launched
|
||||||
@@ -224,7 +221,7 @@ class WeblinkAdapter:
|
|||||||
embedded = False
|
embedded = False
|
||||||
|
|
||||||
#: Set by adapters that render in-window but can still *prove* the page
|
#: 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
|
#: 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
|
#: network, DNS failure, 404) would sit on screen for the whole slot
|
||||||
#: instead of being skipped.
|
#: instead of being skipped.
|
||||||
@@ -281,24 +278,17 @@ class WeblinkAdapter:
|
|||||||
|
|
||||||
|
|
||||||
class WebInputSources:
|
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
|
``/dev/input/event*`` (Linux/Raspberry Pi) catches touch, mouse and
|
||||||
sufficient on its own:
|
keyboard activity on every browser engine, but needs read permission
|
||||||
|
(the player's user must be in the ``input`` group).
|
||||||
* ``/dev/input/event*`` (Linux/Pi) — catches touch on every engine, but
|
|
||||||
needs read permission.
|
|
||||||
* ``GetCursorPos`` (Windows) — catches touchscreen *and* mouse pointer
|
|
||||||
movement on the engine that renders inside the Kivy window (CEF), where
|
|
||||||
no child process exists to attribute events to, and where input may be
|
|
||||||
owned by a different process.
|
|
||||||
|
|
||||||
Only *change* counts: holding a finger still or resting the cursor is idle.
|
Only *change* counts: holding a finger still or resting the cursor is idle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._devices = []
|
self._devices = []
|
||||||
self._last_pointer = None
|
|
||||||
|
|
||||||
# ── Device discovery ─────────────────────────────────────────────
|
# ── Device discovery ─────────────────────────────────────────────
|
||||||
def open_devices(self):
|
def open_devices(self):
|
||||||
@@ -341,7 +331,7 @@ class WebInputSources:
|
|||||||
"""Wait up to ``timeout`` for raw input. Returns the readable fds.
|
"""Wait up to ``timeout`` for raw input. Returns the readable fds.
|
||||||
|
|
||||||
With no devices open this must still consume the timeout, otherwise the
|
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 not self._devices:
|
||||||
if timeout > 0:
|
if timeout > 0:
|
||||||
@@ -368,32 +358,6 @@ class WebInputSources:
|
|||||||
self.drop_device(fd)
|
self.drop_device(fd)
|
||||||
return seen
|
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):
|
class InteractionWatcher(threading.Thread):
|
||||||
"""Decides when a web-link item is finished, based on viewer interaction.
|
"""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,
|
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,
|
stop_event=None, min_alive_before_exit_advance=0.0, max_dwell=None,
|
||||||
interaction_postpone=0.0, interaction_debounce=0.5,
|
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,
|
wait_visible=None, visible_timeout=15.0, launched_at=None,
|
||||||
name='weblink-interaction'):
|
name='weblink-interaction'):
|
||||||
super().__init__(daemon=True, name=name)
|
super().__init__(daemon=True, name=name)
|
||||||
@@ -437,11 +401,6 @@ class InteractionWatcher(threading.Thread):
|
|||||||
self._wait_visible = wait_visible
|
self._wait_visible = wait_visible
|
||||||
self._visible_timeout = float(visible_timeout)
|
self._visible_timeout = float(visible_timeout)
|
||||||
self._launched_at = launched_at
|
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._fired = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
@@ -496,16 +455,17 @@ class InteractionWatcher(threading.Thread):
|
|||||||
hard_deadline = started + self._max_dwell if self._max_dwell else None
|
hard_deadline = started + self._max_dwell if self._max_dwell else None
|
||||||
|
|
||||||
self._input.open_devices()
|
self._input.open_devices()
|
||||||
if not self._input.devices and not self._use_pointer:
|
if not self._input.devices:
|
||||||
Logger.warning(
|
Logger.warning(
|
||||||
"SignagePlayer: Web link interaction watcher — no /dev/input "
|
"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:
|
else:
|
||||||
Logger.info(
|
Logger.info(
|
||||||
f"SignagePlayer: Web link interaction watcher on "
|
f"SignagePlayer: Web link interaction watcher on "
|
||||||
f"{len(self._input.devices)} input device(s)"
|
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"
|
+ 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)
|
timeout = self._next_poll_timeout(now, deadline, hard_deadline)
|
||||||
|
|
||||||
activity = self._input.drain(self._input.select(timeout))
|
activity = self._input.drain(self._input.select(timeout))
|
||||||
if self._use_pointer and self._input.pointer_moved():
|
|
||||||
activity = True
|
|
||||||
|
|
||||||
if activity:
|
if activity:
|
||||||
self._register_interaction(time.monotonic())
|
self._register_interaction(time.monotonic())
|
||||||
@@ -680,8 +638,7 @@ class InteractionWatcher(threading.Thread):
|
|||||||
class ChromiumSubprocessAdapter(WeblinkAdapter):
|
class ChromiumSubprocessAdapter(WeblinkAdapter):
|
||||||
"""Default adapter: a separate Chromium/Chrome process in kiosk mode.
|
"""Default adapter: a separate Chromium/Chrome process in kiosk mode.
|
||||||
|
|
||||||
Used on Linux/Raspberry Pi, and as the fallback engine on Windows when the
|
Used on Linux/Raspberry Pi, and by platform subclasses elsewhere.
|
||||||
embedded CEF browser is unavailable.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = 'chromium-subprocess'
|
name = 'chromium-subprocess'
|
||||||
@@ -701,7 +658,7 @@ class ChromiumSubprocessAdapter(WeblinkAdapter):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def find_browser():
|
def find_browser():
|
||||||
for candidate in ('chromium-browser', 'chromium', 'google-chrome',
|
for candidate in ('chromium-browser', 'chromium', 'google-chrome',
|
||||||
'chrome', 'msedge'):
|
'chrome'):
|
||||||
path = shutil.which(candidate)
|
path = shutil.which(candidate)
|
||||||
if path:
|
if path:
|
||||||
return path
|
return path
|
||||||
@@ -715,14 +672,35 @@ class ChromiumSubprocessAdapter(WeblinkAdapter):
|
|||||||
def extra_launch_args(self):
|
def extra_launch_args(self):
|
||||||
"""Extra flags appended to the browser command line.
|
"""Extra flags appended to the browser command line.
|
||||||
|
|
||||||
Subclasses override this instead of duplicating ``launch()``. The
|
Subclasses override this instead of duplicating ``launch()``. A platform
|
||||||
Windows adapter uses it to inject a dedicated ``--user-data-dir``,
|
adapter uses it to inject a dedicated ``--user-data-dir``, which is
|
||||||
which is mandatory there: without it Chrome/Edge hands the URL to an
|
mandatory on Chromium: without it the browser hands the URL to an
|
||||||
already-running instance, the process we launched exits immediately
|
already-running instance, the process we launched exits immediately and
|
||||||
and the weblink never becomes visible.
|
the weblink never becomes visible.
|
||||||
"""
|
"""
|
||||||
return ()
|
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):
|
def launch(self, url, width, height):
|
||||||
browser = self._browser or self.find_browser()
|
browser = self._browser or self.find_browser()
|
||||||
if not browser:
|
if not browser:
|
||||||
@@ -755,7 +733,15 @@ class ChromiumSubprocessAdapter(WeblinkAdapter):
|
|||||||
args += self._extra_flags
|
args += self._extra_flags
|
||||||
args += [str(arg) for arg in self.extra_launch_args()]
|
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
|
return True
|
||||||
|
|
||||||
def is_alive(self):
|
def is_alive(self):
|
||||||
@@ -768,7 +754,7 @@ class ChromiumSubprocessAdapter(WeblinkAdapter):
|
|||||||
confirms the process survived the health grace period. That catches the
|
confirms the process survived the health grace period. That catches the
|
||||||
realistic failure modes (missing binary, instant crash, hand-off to a
|
realistic failure modes (missing binary, instant crash, hand-off to a
|
||||||
leaked instance). Platform layers override this to also check the real
|
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:
|
if self._proc is None:
|
||||||
return False, 'no process'
|
return False, 'no process'
|
||||||
@@ -978,7 +964,7 @@ class WeblinkSession:
|
|||||||
if self._watcher is not None:
|
if self._watcher is not None:
|
||||||
self._watcher.stop()
|
self._watcher.stop()
|
||||||
# An embedded engine normally cannot be verified (CEF), but some can
|
# 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
|
# otherwise a page that never loads is shown as a blank screen for
|
||||||
# the whole duration instead of being skipped.
|
# the whole duration instead of being skipped.
|
||||||
verify = adapter.wait_visible if (
|
verify = adapter.wait_visible if (
|
||||||
|
|||||||
@@ -241,10 +241,17 @@ while true; do
|
|||||||
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
||||||
source "$SCRIPT_DIR/.venv/bin/activate"
|
source "$SCRIPT_DIR/.venv/bin/activate"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start the player
|
# Start the player.
|
||||||
cd "$SCRIPT_DIR/src"
|
#
|
||||||
python3 main.py &
|
# 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=$!
|
PLAYER_PID=$!
|
||||||
|
|
||||||
log_message "Player PID: $PLAYER_PID"
|
log_message "Player PID: $PLAYER_PID"
|
||||||
|
|||||||
+4
-2
@@ -15,15 +15,17 @@ echo "Stopping watchdog..."
|
|||||||
pkill -f "bash.*start.sh"
|
pkill -f "bash.*start.sh"
|
||||||
|
|
||||||
# Kill player
|
# 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..."
|
echo "Stopping player..."
|
||||||
pkill -f "python3 main.py"
|
pkill -f "run_linux.py"
|
||||||
|
|
||||||
# Give processes time to exit gracefully
|
# Give processes time to exit gracefully
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# Force kill if still running
|
# Force kill if still running
|
||||||
pkill -9 -f "bash.*start.sh" 2>/dev/null
|
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
|
# Clean up heartbeat and stop flag files
|
||||||
rm -f "$SCRIPT_DIR/.player_heartbeat"
|
rm -f "$SCRIPT_DIR/.player_heartbeat"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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. `<exe dir>\webview2_runtime\`
|
|
||||||
3. `<exe dir>\` (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)
|
|
||||||
```
|
|
||||||
@@ -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")
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB |
@@ -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'
|
|
||||||
@@ -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/<share_name>/.
|
|
||||||
|
|
||||||
kivy_deps.sdl2/angle/glew and ffpyplayer install their DLLs into
|
|
||||||
<venv>/share/<pkg>/... 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',
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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`** (`<data>/.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` — `<SettingsPopup@Popup>` 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 <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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
-2306
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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%
|
|
||||||
@@ -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())
|
|
||||||
@@ -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)
|
|
||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
|
|
||||||
@@ -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())
|
|
||||||
@@ -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 = '<!doctype html><html><body><h1 id="h">KIWY-NAV-OK</h1></body></html>'
|
|
||||||
|
|
||||||
|
|
||||||
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())
|
|
||||||
@@ -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"""<!doctype html>
|
|
||||||
<html><head><meta charset="utf-8"><title>pc</title></head>
|
|
||||||
<body style="background:#123;color:#fff;font:48px sans-serif">
|
|
||||||
<div id="m">{MARKER}</div>
|
|
||||||
</body></html>"""
|
|
||||||
|
|
||||||
|
|
||||||
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())
|
|
||||||
@@ -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())
|
|
||||||
@@ -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()
|
|
||||||
@@ -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])])
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -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.'
|
|
||||||
@@ -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<CoreWebView2NavigationCompletedEventArgs>``
|
|
||||||
— 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)'
|
|
||||||
@@ -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}')
|
|
||||||
Binary file not shown.
@@ -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'
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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: <project>/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.")
|
|
||||||
Reference in New Issue
Block a user