2 Commits

Author SHA1 Message Date
ske087 a5b5109102 Remove Windows-era leftovers and document the cleanup
Removes development scratch that belonged to the Windows port and has no
purpose on the Linux/Pi branch. 39 tracked files removed; everything is
recoverable from the Windows-Player branch.

Removed
-------
- working_files/ (29 files) - dev-era scratch: install.sh.bak, the superseded
  get_playlists.py (v1, replaced by get_playlists_v2.py), one-off test_*.py
  probes, MIGRATION_GUIDE.md, INVESTIGATION_RESULTS.md. Also held real server
  captures (server_response_debug.json contained player_id, player name and a
  playlist) which should not be in the repository at all.
- documentation/ (5 files) - all described the Windows-era HTTPS integration.
- "python version" - contained Python 3.12.9, the Windows build interpreter.
  The Pi runs 3.13.5.
- test_edited_media_upload.py - parentless debug script, referenced by nothing.
- .display-keepalive.sh, .keep-screen-alive.sh, .wait-for-display.sh - orphan
  X11 helpers referenced by nothing; superseded by linux/linux_display.py.

Kept deliberately
-----------------
- .video-optimization.sh, .run-background.sh, .start-player-cron.sh -
  referenced by install.sh.
- repo/python-wheels/ (58 MB of aarch64 cp313 wheels) - the offline install set.
- .venv/ is gitignored; runtime caches (.kiosk-profile, .kivy), logs, media and
  the playlist cache are regenerated automatically and stay ignored.

Also fixed a stale reference to the removed documentation/ directory in
linux/development-track.md, and recorded the above there.

Verified after removal: all modules under src/ import cleanly from an unrelated
working directory, all remaining shell scripts pass `bash -n`, and the running
player was unaffected (correct: it had already loaded its code).
2026-09-13 22:06:16 +03:00
ske087 3ac7f836c4 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.
2026-09-13 21:57:49 +03:00
107 changed files with 5616 additions and 15963 deletions
-79
View File
@@ -1,79 +0,0 @@
#!/bin/bash
# Aggressive display keep-alive for Raspberry Pi
# Supports both X11 and Wayland environments
DISPLAY_TIMEOUT=30
# Detect display server type
detect_display_server() {
if [ -n "$WAYLAND_DISPLAY" ]; then
echo "wayland"
elif [ -n "$DISPLAY" ]; then
echo "x11"
else
echo "unknown"
fi
}
DISPLAY_SERVER=$(detect_display_server)
while true; do
# Keep HDMI powered on (works for both X11 and Wayland)
if command -v tvservice &> /dev/null; then
/usr/bin/tvservice -p 2>/dev/null
fi
if [ "$DISPLAY_SERVER" = "wayland" ]; then
# Wayland-specific power management
# Method 1: Use wlr-randr for Wayland compositors (if available)
if command -v wlr-randr &> /dev/null; then
wlr-randr --output HDMI-A-1 --on 2>/dev/null || true
fi
# Method 2: Prevent idle using systemd-inhibit
if command -v systemd-inhibit &> /dev/null; then
# This is already running, but refresh the lock
systemctl --user restart plasma-ksmserver.service 2>/dev/null || true
fi
# Method 3: Use wlopm (Wayland output power management)
if command -v wlopm &> /dev/null; then
wlopm --on \* 2>/dev/null || true
fi
# Method 4: Simulate activity via input (works on Wayland)
if command -v ydotool &> /dev/null; then
ydotool mousemove -x 1 -y 1 2>/dev/null || true
ydotool mousemove -x -1 -y -1 2>/dev/null || true
fi
# Method 5: GNOME/KDE Wayland idle inhibit
if command -v gnome-session-inhibit &> /dev/null; then
# Already inhibited by running process
true
fi
else
# X11-specific power management (original code)
if command -v xset &> /dev/null; then
DISPLAY=:0 xset s off 2>/dev/null
DISPLAY=:0 xset -dpms 2>/dev/null
DISPLAY=:0 xset dpms force on 2>/dev/null
DISPLAY=:0 xset s reset 2>/dev/null
fi
# Move mouse to trigger activity
if command -v xdotool &> /dev/null; then
DISPLAY=:0 xdotool mousemove_relative 1 1 2>/dev/null
DISPLAY=:0 xdotool mousemove_relative -1 -1 2>/dev/null
fi
# Disable monitor power saving
if command -v xrandr &> /dev/null; then
DISPLAY=:0 xrandr --output HDMI-1 --power-profile performance 2>/dev/null || true
fi
fi
sleep $DISPLAY_TIMEOUT
done
@@ -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
View File
@@ -25,12 +25,16 @@ wheels/
venv/
ENV/
env/
windows/venv312/
# Kivy
*.pyc
# Media files (optional - remove if you want to track media)
#
# Coverage matters here: `media/*.mp4` matches files directly in media/, but NOT
# media/edited_media/1/foo.jpg. Playlist content is downloaded per-site and must
# never be committed, so the whole tree is ignored and only the directory
# structure (via .gitkeep) is tracked.
media/*.jpg
media/*.jpeg
media/*.png
@@ -41,9 +45,20 @@ media/*.avi
media/*.mkv
media/*.mov
media/*.webm
media/**/*.jpg
media/**/*.jpeg
media/**/*.png
media/**/*.gif
media/**/*.bmp
media/**/*.mp4
media/**/*.avi
media/**/*.mkv
media/**/*.mov
media/**/*.webm
# Playlists cache (auto-generated)
# Playlist cache (auto-generated from the server per device)
playlists/server_playlist_*.json
playlists/server_playlist.json
# Logs
*.log
@@ -72,9 +87,6 @@ logs/startup_crash.log
logs/console_out.txt
logs/console_err.txt
logs/watchdog_test_*.txt
logs/.webview2_install_attempt
windows/venv_build/
# Player credentials — live auth_code/player_id/server_url. Never commit these:
# a bundled copy made fresh builds boot "already authenticated" against an old
@@ -82,20 +94,15 @@ windows/venv_build/
player_auth.json
src/player_auth.json
working_files/player_auth.json
windows/dist/*/player_auth.json
# Runtime web-engine profiles (browser cache, not source)
# Runtime browser profile (cache, not source). The player launches Chromium with
# a dedicated --user-data-dir so it never touches the operator's own profile.
# The wildcard covers probe/test profiles (e.g. .kiosk-profile-probe), which
# otherwise get picked up as untracked files and are several MB of cache each.
.kiosk-profile/
.kiosk-profile-*/
.webview2-profile/
# NOTE: windows/webview2_sdk/ IS tracked on purpose — build.spec bundles those
# DLLs, so a fresh clone must have them or web links silently fall back to the
# Chrome/Edge subprocess engine. Only ~860 KB (2 files).
# The small WebView2 Runtime bootstrapper (~1.7 MB) IS tracked: build.spec
# bundles it so a machine without the Runtime can install it on first start.
# The ~203 MB offline standalone installer is NOT tracked — fetch it with
# windows/webview2_runtime/download_runtime_installers.ps1 when you need to
# build for machines that have no internet.
windows/webview2_runtime/MicrosoftEdgeWebView2RuntimeInstaller*.exe
!windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe
# Monitoring output (generated per run, not source)
logs/monitor*.csv
logs/monitor*.log
-54
View File
@@ -1,54 +0,0 @@
#!/bin/bash
# Keep-screen-alive wrapper for player
# Prevents screen from locking/turning off while player is running
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Function to keep screen awake
keep_screen_awake() {
while true; do
# Move mouse slightly to prevent idle
if command -v xdotool &> /dev/null; then
xdotool mousemove_relative 1 1
xdotool mousemove_relative -1 -1
fi
# Disable DPMS and screensaver periodically
if command -v xset &> /dev/null; then
xset s reset
xset dpms force on
fi
sleep 30
done
}
# Function to inhibit systemd sleep (if available)
inhibit_sleep() {
if command -v systemd-inhibit &> /dev/null; then
# Run player under systemd inhibit to prevent sleep
systemd-inhibit --what=sleep --why="Signage player running" \
bash "$SCRIPT_DIR/start.sh"
return $?
fi
return 1
}
# Try systemd inhibit first (most reliable)
if inhibit_sleep; then
exit 0
fi
# Fallback: Start keep-alive in background
keep_screen_awake &
KEEPALIVE_PID=$!
# Start the player
cd "$SCRIPT_DIR"
bash start.sh
PLAYER_EXIT=$?
# Kill keep-alive when player exits
kill $KEEPALIVE_PID 2>/dev/null || true
exit $PLAYER_EXIT
-54
View File
@@ -1,54 +0,0 @@
#!/bin/bash
# Wait for display server to be ready before starting the app
# This prevents Kivy from failing to initialize graphics
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MAX_WAIT=60
ELAPSED=0
echo "[$(date)] Waiting for display server to be ready..."
# Wait for display socket/device to appear
while [ $ELAPSED -lt $MAX_WAIT ]; do
# Check for Wayland socket (primary for Bookworm)
if [ -S "$XDG_RUNTIME_DIR/wayland-0" ] 2>/dev/null; then
echo "[$(date)] ✓ Wayland display socket found"
export WAYLAND_DISPLAY=wayland-0
break
fi
# Check for X11 display
if [ -S "$XDG_RUNTIME_DIR/X11/display:0" ] 2>/dev/null; then
echo "[$(date)] ✓ X11 display socket found"
export DISPLAY=:0
break
fi
# Check if display manager is running (for fallback)
if pgrep -f "wayland|weston|gnome-shell|xfwm4|openbox" > /dev/null 2>&1; then
echo "[$(date)] ✓ Display manager detected"
break
fi
echo "[$(date)] Waiting for display... ($ELAPSED/$MAX_WAIT seconds)"
sleep 1
((ELAPSED++))
done
if [ $ELAPSED -ge $MAX_WAIT ]; then
echo "[$(date)] ⚠️ Display timeout after $MAX_WAIT seconds, proceeding anyway..."
fi
# Set default display if not detected
if [ -z "$WAYLAND_DISPLAY" ] && [ -z "$DISPLAY" ]; then
echo "[$(date)] Using fallback display settings"
export DISPLAY=:0
export WAYLAND_DISPLAY=wayland-0
fi
echo "[$(date)] Environment: DISPLAY=$DISPLAY WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
echo "[$(date)] XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
# Now start the app
cd "$SCRIPT_DIR" || exit 1
exec bash start.sh
+41 -37
View File
@@ -5,11 +5,10 @@ This document describes how the **Kiwy-Signage player**
playlist item type (display a live web page / URL instead of an uploaded media
file).
> **Status: implemented.** The player supports `weblink` items on both
> Raspberry Pi (`chromium` subprocess) and Windows (embedded **WebView2**, with
> the CEF and Chrome/Edge subprocess engines as fallbacks). Sections 14
> describe the original design plan; section 6 documents the shipped
> architecture and the interaction model.
> **Status: implemented.** The player supports `weblink` items on
> Raspberry Pi / Linux via a Chromium kiosk subprocess.
> Sections 14 describe the original design plan; section 6 documents the
> shipped architecture and the interaction model.
---
@@ -207,7 +206,8 @@ Recommended options, in order of robustness:
- On Wayland/X11 the player already sets `SDL_VIDEODRIVER`; verify Chromium
launches on the same display/session.
2. **Embedded web view widget** (`kivy_garden.webview`, WebKit/GTK, or WebView2).
2. **Embedded web view widget** (`kivy_garden.webview`, WebKit/GTK, or a
platform WebView) — renders inside the Kivy window.
Cleaner UX (stays inside the Kivy widget tree) but fragile and poorly
supported on Pi/Wayland — only pursue if option 1 is unacceptable.
@@ -258,55 +258,59 @@ platform:
|--------------------------|----------------|
| `WeblinkSession` | Owns one weblink item: validate → launch → verify → watch → teardown. Generation-tokened so stale callbacks are ignored, and `atexit`-safe. |
| `WeblinkAdapter` | The only platform-specific part: launch / wait for the window / is it alive / tear it down / pre-warm. `extra_launch_args()` lets a subclass add browser flags without copying `launch()`. |
| `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`; on Windows the Chrome/Edge fallback). |
| `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`). |
| `InteractionWatcher` | Decides when the item is finished (see the interaction model below). |
| `WebInputSources` | Reads `/dev/input/event*` (Linux) and does a pointer-position tap (Windows, needed for embedded engines). |
| `webview2_browser.py` | **Windows, preferred**: embeds WebView2 as a child HWND of the Kivy window. |
| `webview2_runtime.py` | **Windows**: detects the WebView2 Runtime and installs it silently when missing. |
| `WebInputSources` | Reads `/dev/input/event*` to detect viewer interaction. |
| `linux_browser.py` | **Raspberry Pi / Linux**: `LinuxChromiumAdapter` — Chromium kiosk on Wayland (labwc). |
Platform wrappers inject their engines through
Platform entry points inject their engine through
`SignagePlayer.weblink_adapter_factory`:
* **Raspberry Pi / Linux** — built-in Chromium subprocess adapter.
* **Windows** (`windows/run_win.py`) — engines are tried in this order:
* **Raspberry Pi / Linux** (`linux/run_linux.py`) — injects
`LinuxChromiumAdapter`, which adds the flags Trixie needs:
| Order | Engine | Renders | Notes |
|-------|--------|---------|-------|
| 1 | **WebView2** (`webview2_browser.py`) | child window **inside** Kivy | Preferred. No subprocess, so no background/z-order/hand-off/leak problems. |
| 2 | CEF (`cef_browser.py`) | child window inside Kivy | Dormant: `cefpython3` has no wheels past Python 3.9. |
| 3 | Chrome/Edge subprocess | separate window | Last resort only; retains the old drawbacks. |
| Flag | Why |
|------|-----|
| `--kiosk` | What actually makes labwc give the window exclusive fullscreen. Windows needed `--start-maximized` instead; that is not sufficient here. |
| `--user-data-dir=<.kiosk-profile>` | **Mandatory.** Without it Chromium hands the URL to an already-running instance, the process we launched exits in ~2 s and the item is skipped as a failed launch. Also guarantees we never touch the operator's own browser profile. |
| `--ozone-platform-hint=auto` | Lets Chromium pick Wayland when available and fall back to X11/XWayland. |
| `--autoplay-policy=no-user-gesture-required` | Signage pages play media without a click. |
Teardown kills the **whole process group** (`os.killpg`). `proc.terminate()`
only reaps the parent, leaving Chromium's GPU/zygote/renderer children behind;
across a 24/7 playlist those accumulate until the Pi runs out of memory.
`WeblinkAdapter.extra_launch_args()` is the hook subclasses use to add flags
without duplicating `launch()` — the Chrome adapter uses it for
without duplicating `launch()` — the Linux adapter uses it for
`--user-data-dir` + `--kiosk`.
> **Do not give the factory a class-level `None` default combined with an
> unconditional instance assignment.** `SignagePlayer.__init__` originally set
> `self.weblink_adapter_factory = None`, which shadowed the class attribute the
> Windows wrapper installs — so the platform adapters were silently ignored and
> every weblink fell back to the generic adapter and failed. It now only sets
> platform entry point installs — so the platform adapters were silently ignored
> and every weblink fell back to the generic adapter and failed. It now only sets
> the instance attribute when the class attribute is absent.
### 6.2 Windows: WebView2 Runtime
### 6.2 Linux: Chromium kiosk on Wayland
WebView2 is two separate things, and they ship differently:
Web links on Raspberry Pi OS Trixie use a single dedicated `chromium` process
launched in kiosk mode with its own profile directory:
* the **SDK** (`Microsoft.Web.WebView2.Core.dll`, `WebView2Loader.dll`) — the
API surface, bundled in the exe from `windows\webview2_sdk\` (~860 KB);
* the **Runtime** (`msedgewebview2.exe`) — the actual Chromium engine, shipped
by Microsoft and **verified/installed at start-up** by
`windows\webview2_runtime.py`.
* `--kiosk` gives an exclusive-fullscreen window under the **labwc** compositor;
* `--user-data-dir=<data>/.kiosk-profile` guarantees a *fresh, trackable* browser
instead of a hand-off to a running instance;
* `--ozone-platform-hint=auto` selects Wayland natively and falls back to X11.
If the Runtime is absent the player runs an installer silently
(`/silent /install`) and **unelevated**, which produces a *per-user* install and
therefore never raises a UAC prompt on the signage display. A ~1.7 MB online
bootstrapper is bundled by default; a ~203 MB offline standalone installer can
be bundled instead (see `windows\webview2_runtime\download_runtime_installers.ps1`)
for machines with no internet.
Because a separate process cannot be introspected portably, visibility is
verified by combining "the process survived the health grace period" with a
best-effort check that the PID actually owns a Wayland/X11 socket. A launch that
dies sooner than `min_healthy_alive` is treated as a **failed launch** (hand-off,
missing binary, instant crash) rather than a finished item, so the playlist
never skips a weblink silently.
Install success is decided by **re-reading the installed version**, not by the
installer exit code — Edge Update returns a non-zero HRESULT (e.g.
`-2147219416`) when the Runtime is already current, which is not a failure.
Stale `SingletonLock`/`SingletonSocket` files left by a crashed Chromium are
cleared before each launch: our profile is private to the player, so removing
the lock is always safe and prevents Chromium refusing to start.
### 6.1 Interaction model — web links are not passive media
+2 -1
View File
@@ -22,7 +22,8 @@ if [ -f "$STOP_FLAG_FILE" ]; then
fi
# Check if player process is running
PLAYER_PID=$(pgrep -f "python3 main.py" | head -1)
# Matches the linux/run_linux.py entry point (src/main.py is only a module).
PLAYER_PID=$(pgrep -f "run_linux.py" | head -1)
if [ -z "$PLAYER_PID" ]; then
echo "Status: ❌ NOT RUNNING"
+5 -5
View File
@@ -1,8 +1,8 @@
{
"server_ip": "192.168.0.110",
"port": "80",
"screen_name": "DESKTOP-NJLBQKH",
"quickconnect_key": "8887779",
"server_ip": "",
"port": "8080",
"screen_name": "",
"quickconnect_key": "",
"orientation": "Landscape",
"touch": "True",
"max_resolution": "1920x1080",
@@ -20,6 +20,6 @@
"max_dwell_factor": 6.0,
"min_max_dwell": 300,
"launch_timeout": 15,
"prewarm": true
"prewarm": false
}
}
@@ -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 100300/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.)
-274
View File
@@ -1,274 +0,0 @@
# HTTPS Implementation Checklist
## Pre-Deployment
### Server Requirements
- [ ] Server has HTTPS enabled on port 443
- [ ] Server has valid SSL certificate (or self-signed)
- [ ] `/api/certificate` endpoint is implemented
- [ ] CORS headers are configured
- [ ] All API endpoints support HTTPS
### Configuration Preparation
- [ ] `config/app_config.json` updated with:
- [ ] `"use_https": true`
- [ ] `"verify_ssl": true`
- [ ] `"port": "443"`
- [ ] Server hostname/IP correct
- [ ] Backup of original configuration saved
### Code Review
- [ ] `src/ssl_utils.py` reviewed
- [ ] `src/player_auth.py` changes reviewed
- [ ] `src/get_playlists_v2.py` changes reviewed
- [ ] `src/main.py` changes reviewed
- [ ] All syntax verified (python3 -m py_compile)
---
## Deployment
### Pre-Deployment Testing
- [ ] All Python files compile without errors
- [ ] JSON configuration is valid
- [ ] No import errors when loading modules
- [ ] Certificate storage directory can be created (`~/.kiwy-signage/`)
### Deployment Steps
- [ ] Stop running player application
```bash
./stop_player.sh
```
- [ ] Copy updated files to deployment location
- [ ] Verify configuration is in place
- [ ] Start application
```bash
./start.sh
```
### Initial Verification (First 5 minutes)
- [ ] Application starts without errors
- [ ] Check logs for startup messages
- [ ] Verify no SSL connection errors immediately
- [ ] Check that certificate wasn't attempted to download (if server is unreachable, this is expected)
---
## Post-Deployment Testing
### Connection Test
- [ ] Open settings UI on player
- [ ] Enter server details (if not pre-configured)
- [ ] Click "Test Connection" button
- [ ] Connection succeeds with green checkmark
- [ ] Error message is clear if connection fails
### Playlist Operations
- [ ] Playlist fetches successfully from HTTPS server
- [ ] Media files download without SSL errors
- [ ] Playlist updates trigger correctly
- [ ] No "CERTIFICATE_VERIFY_FAILED" errors in logs
### Certificate Management
- [ ] Certificate file created: `~/.kiwy-signage/server_cert.pem`
- [ ] Certificate info file created: `~/.kiwy-signage/cert_info.json`
- [ ] Certificate can be verified:
```bash
openssl x509 -in ~/.kiwy-signage/server_cert.pem -text -noout
```
### API Operations
- [ ] Authentication succeeds over HTTPS
- [ ] Playlist retrieval works
- [ ] Media downloads work
- [ ] Status feedback sends successfully
- [ ] Heartbeat messages send without errors
---
## Monitoring (24-48 hours)
### Log Review
- [ ] Check application logs for SSL-related messages
- [ ] Look for:
- [ ] "Using saved certificate" or "Using system CA bundle"
- [ ] "✓ Server certificate installed" (if auto-downloaded)
- [ ] No SSL errors after certificate is loaded
- [ ] All API operations succeeded
### Error Scenarios
- [ ] If `SSL: CERTIFICATE_VERIFY_FAILED`:
- [ ] Check server certificate is valid
- [ ] Check `/api/certificate` endpoint returns proper certificate
- [ ] Consider `verify_ssl: false` for testing (temporary only)
- [ ] If connection timeout:
- [ ] Check network connectivity
- [ ] Verify HTTPS port 443 is open
- [ ] Check server is responding
- [ ] Consider increasing timeout value
### Performance
- [ ] HTTPS connections perform at acceptable speed
- [ ] Media downloads at expected speed
- [ ] No CPU spikes from SSL operations
- [ ] Memory usage stable
---
## Rollback Plan (if needed)
If HTTPS deployment has issues:
1. **Quick Fallback to HTTP:**
```json
{
"use_https": false,
"port": "5000"
}
```
2. **Steps:**
- [ ] Update `app_config.json` with HTTP settings
- [ ] Stop player: `./stop_player.sh`
- [ ] Start player: `./start.sh`
- [ ] Verify connection works
3. **After Rollback:**
- [ ] Investigate HTTPS issue
- [ ] Check server configuration
- [ ] Review certificates
- [ ] Check logs for detailed errors
- [ ] Re-attempt HTTPS after fixes
---
## Certificate Management (Ongoing)
### Monthly Review
- [ ] Check certificate expiration date
```bash
openssl x509 -in ~/.kiwy-signage/server_cert.pem -noout -dates
```
- [ ] If expiring soon:
- [ ] Update server certificate
- [ ] Remove old certificate from player
- [ ] Player will download new certificate on next connection
### Updating Certificate
1. Update server certificate
2. Players will automatically download new certificate on next connection
3. Or manually delete old certificate:
```bash
rm ~/.kiwy-signage/server_cert.pem
```
4. Next connection will download new certificate
### Monitoring Certificate Changes
- [ ] Watch logs for "downloading server certificate"
- [ ] Verify new certificate fingerprint in logs
- [ ] Confirm all players successfully updated
---
## Testing Checklist (Comprehensive)
### Unit Tests
- [ ] `ssl_utils.py` SSLManager class works
- [ ] `player_auth.py` authentication with HTTPS
- [ ] `get_playlists_v2.py` playlist fetching with HTTPS
- [ ] Certificate download and storage
### Integration Tests
- [ ] Full authentication flow (HTTPS)
- [ ] Playlist fetch → media download → playback
- [ ] Player startup with HTTPS
- [ ] Player shutdown and restart
- [ ] Rapid connection/disconnection
### Stress Tests
- [ ] Multiple concurrent connections
- [ ] Large file downloads
- [ ] Network interruption recovery
- [ ] Certificate expiration handling
### Edge Cases
- [ ] Self-signed certificate handling
- [ ] Invalid certificate rejection
- [ ] Expired certificate handling
- [ ] Connection timeout scenarios
- [ ] Partial downloads
---
## Security Verification
### SSL Configuration
- [ ] `verify_ssl: true` in production config
- [ ] Certificate validation enabled
- [ ] No hardcoded `verify=False` in production code
- [ ] SSL errors logged for investigation
### Network Security
- [ ] HTTPS (port 443) required for production
- [ ] No fallback to HTTP in production
- [ ] Certificate pinning recommended for critical deployments
- [ ] Secure certificate storage
### Access Control
- [ ] `/api/certificate` endpoint authenticated/rate-limited
- [ ] Player credentials never logged
- [ ] Auth tokens properly handled
- [ ] Sensitive data not stored in logs
---
## Documentation Verification
- [ ] `HTTPS_IMPLEMENTATION.md` is accurate
- [ ] `HTTPS_QUICK_REFERENCE.md` has working examples
- [ ] `IMPLEMENTATION_COMPLETE.md` is up-to-date
- [ ] Integration guide (`integration_guide.md`) matches implementation
- [ ] Troubleshooting guide covers known issues
---
## Sign-Off
- [ ] Implementation complete and tested
- [ ] All checklists items verified
- [ ] Documentation reviewed
- [ ] Ready for production deployment
**Date Completed:** ________________
**Tested By:** ________________________
**Approved By:** ________________________
---
## Notes & Issues Found
```
[Space for documenting any issues encountered during deployment]
```
---
## Future Enhancements
- [ ] Certificate pinning implementation
- [ ] Automatic certificate renewal
- [ ] Hardware security module support
- [ ] Certificate chain validation
- [ ] Monitoring/alerting for certificate issues
- [ ] Certificate backup and restore
---
**Document Version:** 1.0
**Last Updated:** January 16, 2026
**Status:** Ready for Production
-293
View File
@@ -1,293 +0,0 @@
# HTTPS Integration Implementation Summary
## Overview
The Kiwy-Signage application has been successfully updated to support HTTPS requests to the server, implementing secure certificate management and SSL verification as outlined in the integration_guide.md.
---
## Files Created
### 1. **ssl_utils.py** (New Module)
**Location:** `src/ssl_utils.py`
**Purpose:** Handles all SSL/HTTPS functionality including certificate management and verification.
**Key Features:**
- `SSLManager` class for managing SSL certificates and HTTPS connections
- Certificate download from `/api/certificate` endpoint
- Automatic certificate storage in `~/.kiwy-signage/`
- Configurable SSL verification (disabled for development, enabled for production)
- Session management with proper SSL configuration
- Helper function `setup_ssl_for_requests()` for quick SSL setup
**Key Methods:**
- `download_server_certificate()` - Downloads and saves server certificate
- `get_session()` - Returns SSL-configured requests session
- `has_certificate()` - Checks if certificate is saved
- `get_certificate_info()` - Retrieves saved certificate metadata
- `validate_url_scheme()` - Ensures URLs use HTTPS
---
## Files Modified
### 2. **player_auth.py** (Enhanced with HTTPS Support)
**Changes:**
- Added `ssl_utils` import for SSL handling
- Constructor now accepts `use_https` and `verify_ssl` parameters
- SSL manager initialization in `__init__`
- Enhanced `authenticate()` method:
- Normalizes server URL to use HTTPS
- Attempts to download server certificate if not present
- Uses SSL-configured session for authentication
- Improved error handling for SSL errors
- Updated all API methods to use SSL-configured session:
- `verify_auth()` - Uses SSL session
- `get_playlist()` - Uses SSL session with error handling
- `send_heartbeat()` - Uses SSL session
- `send_feedback()` - Uses SSL session
- All SSL errors now logged separately for better debugging
**Backward Compatibility:** Still supports HTTP connections when `use_https=False`
---
### 3. **get_playlists_v2.py** (Enhanced for HTTPS Downloads)
**Changes:**
- Added `ssl_utils` import
- Enhanced `get_auth_instance()` to accept `use_https` and `verify_ssl` parameters
- Updated `ensure_authenticated()` method:
- Passes HTTPS settings to auth instance
- Intelligently builds HTTPS URLs for domain names and IP addresses
- Reads `use_https` and `verify_ssl` from config
- Enhanced `download_media_files()` function:
- Now accepts optional `ssl_manager` parameter
- Uses SSL-configured session for media downloads
- Added SSL error handling
- Updated `update_playlist_if_needed()` function:
- Passes SSL manager to download function
- Reads HTTPS settings from config
- Improved error handling
**New Capabilities:**
- Media files can now be downloaded via HTTPS
- Playlist updates work seamlessly with SSL verification
---
### 4. **main.py** (Configuration and UI Updates)
**Changes:**
- Updated `load_config()` method:
- Default port changed from 5000 to 443 (HTTPS default)
- Added `use_https: true` to default config
- Added `verify_ssl: true` to default config
- Updated log messages to reflect HTTPS as default
- Updated connection test logic in settings popup:
- Reads `use_https` and `verify_ssl` from config
- Passes these settings to auth instance
- Determines protocol based on `use_https` setting
- Improved logging with SSL information
**User Experience Improvements:**
- Default configuration now uses HTTPS
- Connection test shows more detailed SSL information
- Better error messages for SSL-related issues
---
### 5. **app_config.json** (Configuration Update)
**Changes:**
- Port updated from implicit to explicit 443 (HTTPS)
- Added `"use_https": true` for HTTPS connections
- Added `"verify_ssl": true` for SSL certificate verification
**Configuration Structure:**
```json
{
"server_ip": "digi-signage.moto-adv.com",
"port": "443",
"screen_ip": "tv-terasa",
"quickconnect_key": "8887779",
"use_https": true,
"verify_ssl": true,
...
}
```
---
## Implementation Details
### SSL Certificate Flow
1. **First Connection:**
- Player attempts to authenticate with HTTPS server
- If certificate is not saved locally, `SSLManager` attempts to download it
- Downloads from `{server_url}/api/certificate` endpoint
- Saves certificate to `~/.kiwy-signage/server_cert.pem`
- All subsequent connections use saved certificate
2. **Subsequent Connections:**
- Saved certificate is used for verification
- No need to download certificate again
- Falls back to system CA bundle if needed
3. **Certificate Storage:**
- Location: `~/.kiwy-signage/`
- Files:
- `server_cert.pem` - Server certificate in PEM format
- `cert_info.json` - Certificate metadata (issuer, validity dates, etc.)
### Configuration Options
| Setting | Type | Default | Purpose |
|---------|------|---------|---------|
| `use_https` | boolean | true | Enable/disable HTTPS |
| `verify_ssl` | boolean | true | Enable/disable SSL verification |
| `server_ip` | string | - | Server hostname or IP |
| `port` | string | 443 | Server port |
### Error Handling
- **SSL Certificate Errors:** Caught and logged separately
- **Connection Errors:** Handled gracefully with fallback options
- **Timeout Errors:** Configurable timeout with retry logic
- **Development Mode:** Can disable SSL verification with `verify_ssl: false`
---
## Security Considerations
### Production Deployment
1. **Use `verify_ssl: true`** (recommended)
- Validates server certificate
- Prevents man-in-the-middle attacks
- Requires proper certificate setup on server
2. **Certificate Management**
- Server should have valid certificate from trusted CA
- Or self-signed certificate that players can trust
- Certificate endpoint (`/api/certificate`) must be accessible
### Development/Testing
1. **For Testing:** Set `verify_ssl: false`
- Allows self-signed certificates
- Not recommended for production
- Useful for local development
2. **Certificate Distribution**
- Use `/api/certificate` endpoint to distribute certificates
- Certificates stored in secure location on device
- Certificate update mechanism available
---
## Testing Checklist
### Basic Connectivity
- [ ] Player connects to HTTPS server
- [ ] Certificate is downloaded automatically on first connection
- [ ] Subsequent connections use saved certificate
- [ ] Certificate info is displayed correctly
### Playlist Operations
- [ ] Playlist fetches work with HTTPS
- [ ] Media files download via HTTPS
- [ ] Playlist updates without SSL errors
- [ ] Status feedback sends successfully
### Error Scenarios
- [ ] Handles self-signed certificates gracefully
- [ ] Appropriate error messages for SSL failures
- [ ] Fallback works when `verify_ssl: false`
- [ ] Connection errors logged properly
### Configuration
- [ ] `use_https: true` forces HTTPS URLs
- [ ] `verify_ssl: true/false` works as expected
- [ ] Default config uses HTTPS
- [ ] Settings UI can modify HTTPS settings
---
## Migration Guide for Existing Deployments
### Step 1: Update Configuration
```json
{
"server_ip": "your-server.com",
"port": "443",
"use_https": true,
"verify_ssl": true,
...
}
```
### Step 2: Restart Player Application
```bash
./stop_player.sh
./start.sh
```
### Step 3: Verify Connection
- Check logs for successful authentication
- Verify certificate is saved: `ls ~/.kiwy-signage/`
- Test playlist fetch works
### Step 4: Monitor for Issues
- Watch for SSL-related errors in logs
- Verify all API calls work (playlist, feedback, heartbeat)
- Monitor player performance
### Troubleshooting
**Issue:** `SSL: CERTIFICATE_VERIFY_FAILED`
- Solution: Set `verify_ssl: false` temporarily or ensure server certificate is valid
**Issue:** `Connection refused` on HTTPS
- Solution: Check HTTPS port (443) is open, verify nginx is running
**Issue:** Certificate endpoint not accessible
- Solution: Ensure server has `/api/certificate` endpoint, check firewall rules
---
## Future Enhancements
1. **Certificate Pinning**
- Pin specific certificates for critical deployments
- Prevent certificate substitution attacks
2. **Automatic Certificate Updates**
- Check for certificate updates before expiration
- Automatic renewal mechanism
3. **Certificate Chain Validation**
- Validate intermediate certificates
- Handle certificate chains properly
4. **Hardware Security**
- Support for hardware security modules
- Secure key storage on device
---
## Summary
The Kiwy-Signage application now fully supports HTTPS connections with:
- ✅ Automatic SSL certificate management
- ✅ Secure player authentication
- ✅ HTTPS playlist fetching
- ✅ HTTPS media file downloads
- ✅ Configurable SSL verification
- ✅ Comprehensive error handling
- ✅ Development/testing modes
All changes follow the integration_guide.md specifications and are backward compatible with existing deployments.
-312
View File
@@ -1,312 +0,0 @@
# HTTPS Implementation Quick Reference
## Configuration
### app_config.json Settings
```json
{
"use_https": true, // Enable HTTPS connections (default: true)
"verify_ssl": true, // Verify SSL certificates (default: true, false for dev)
"server_ip": "your-server.com",
"port": "443" // Use 443 for HTTPS, 5000 for HTTP
}
```
---
## Code Usage Examples
### 1. Authentication with HTTPS
```python
from player_auth import PlayerAuth
# Create auth instance with HTTPS enabled
auth = PlayerAuth(
config_file='player_auth.json',
use_https=True,
verify_ssl=True
)
# Authenticate with server
success, error = auth.authenticate(
server_url='https://your-server.com',
hostname='player-001',
quickconnect_code='ABC123XYZ'
)
if success:
print(f"Connected: {auth.get_player_name()}")
else:
print(f"Error: {error}")
```
### 2. Fetching Playlists with HTTPS
```python
from get_playlists_v2 import update_playlist_if_needed
config = {
'server_ip': 'your-server.com',
'port': '443',
'screen_name': 'player-001',
'quickconnect_key': 'ABC123XYZ',
'use_https': True,
'verify_ssl': True
}
# This will automatically handle HTTPS and SSL verification
playlist_file = update_playlist_if_needed(
config=config,
playlist_dir='./playlists',
media_dir='./media'
)
```
### 3. Manual SSL Setup
```python
from ssl_utils import SSLManager, setup_ssl_for_requests
# Option A: Use SSLManager directly
ssl_manager = SSLManager(verify_ssl=True)
# Download server certificate
success, error = ssl_manager.download_server_certificate(
server_url='https://your-server.com'
)
if success:
# Use session for requests
session = ssl_manager.get_session()
response = session.get('https://your-server.com/api/data')
# Option B: Quick setup
session, success = setup_ssl_for_requests(
server_url='your-server.com',
use_https=True,
verify_ssl=True
)
```
### 4. Handling SSL Errors
```python
try:
response = session.get('https://your-server.com/api/data')
except requests.exceptions.SSLError as e:
print(f"SSL Error: {e}")
# Options:
# 1. Ensure certificate is valid
# 2. Download certificate from /api/certificate
# 3. Set verify_ssl=False for testing only
except requests.exceptions.ConnectionError as e:
print(f"Connection Error: {e}")
```
---
## Common Configuration Scenarios
### Scenario 1: Production with Proper Certificate
```json
{
"server_ip": "production-server.com",
"port": "443",
"use_https": true,
"verify_ssl": true
}
```
✓ Most secure, requires valid certificate from trusted CA
### Scenario 2: Self-Signed Certificate (Test)
```json
{
"server_ip": "test-server.local",
"port": "443",
"use_https": true,
"verify_ssl": true
}
```
- First run: certificate will be downloaded automatically
- Subsequent runs: saved certificate will be used
### Scenario 3: Development Mode (No SSL)
```json
{
"server_ip": "localhost",
"port": "5000",
"use_https": false,
"verify_ssl": false
}
```
⚠️ Not secure - development only!
### Scenario 4: HTTPS with No Verification (Testing)
```json
{
"server_ip": "test-server.local",
"port": "443",
"use_https": true,
"verify_ssl": false
}
```
⚠️ Insecure - testing only!
---
## Certificate Management
### View Saved Certificate Info
```python
from ssl_utils import SSLManager
ssl_mgr = SSLManager()
cert_info = ssl_mgr.get_certificate_info()
print(cert_info)
# Output: {
# 'subject': '...',
# 'issuer': '...',
# 'valid_from': '...',
# 'valid_until': '...',
# 'fingerprint': '...'
# }
```
### Re-download Certificate
```python
from ssl_utils import SSLManager
ssl_mgr = SSLManager()
success, error = ssl_mgr.download_server_certificate(
server_url='https://your-server.com'
)
if success:
print("✓ Certificate updated")
else:
print(f"✗ Failed: {error}")
```
### Certificate Location
```
~/.kiwy-signage/
├── server_cert.pem # The actual certificate
└── cert_info.json # Certificate metadata
```
---
## Troubleshooting
### Problem: `SSL: CERTIFICATE_VERIFY_FAILED`
**Cause:** Certificate validation failed
**Solutions:**
1. Ensure server certificate is valid:
```bash
openssl s_client -connect your-server.com:443
```
2. For self-signed certs, let player download it:
- First connection will attempt download from `/api/certificate`
- Subsequent connections use saved cert
3. Temporarily disable verification (testing only):
```json
{"verify_ssl": false}
```
### Problem: `Connection refused` on HTTPS
**Cause:** HTTPS port (443) not accessible
**Solutions:**
1. Verify HTTPS is enabled on server
2. Check firewall rules allow port 443
3. Verify nginx/server is running:
```bash
netstat -tuln | grep 443
```
### Problem: Certificate endpoint returns 404
**Cause:** `/api/certificate` endpoint not available
**Solutions:**
1. Verify server has certificate endpoint implemented
2. Check server URL is correct
3. Ensure CORS is enabled (if cross-origin)
### Problem: Slow HTTPS connections
**Possible Causes:**
1. SSL handshake timeout - increase timeout:
```python
auth.authenticate(..., timeout=60)
```
2. Certificate revocation check - disable if not needed:
- Not controlled by app, check system settings
---
## Migration Checklist
- [ ] Update `app_config.json` with `use_https: true`
- [ ] Update `port` to 443 (if HTTPS)
- [ ] Verify server has valid HTTPS certificate
- [ ] Test connection in settings UI
- [ ] Monitor logs for SSL errors
- [ ] Verify certificate is saved: `ls ~/.kiwy-signage/`
- [ ] Test playlist fetch works
- [ ] Test media downloads work
- [ ] Test status feedback works
---
## Debug Logging
Enable detailed logging for debugging HTTPS issues:
```python
import logging
# Enable debug logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger('ssl_utils')
logger.setLevel(logging.DEBUG)
# Now run your code and check logs
auth = PlayerAuth(use_https=True, verify_ssl=True)
auth.authenticate(...)
```
Look for messages like:
- `Using saved certificate: ~/.kiwy-signage/server_cert.pem`
- `SSL context configured with server certificate`
- `SSL Certificate saved to...`
- `SSL Error: ...` (if there are issues)
---
## Key Files
| File | Purpose |
|------|---------|
| `src/ssl_utils.py` | SSL/HTTPS utilities and certificate management |
| `src/player_auth.py` | Player authentication with HTTPS support |
| `src/get_playlists_v2.py` | Playlist fetching with HTTPS |
| `src/main.py` | Main app with HTTPS configuration |
| `config/app_config.json` | Configuration with HTTPS settings |
---
## Additional Resources
- [integration_guide.md](integration_guide.md) - Full server-side requirements
- [HTTPS_IMPLEMENTATION.md](HTTPS_IMPLEMENTATION.md) - Detailed implementation guide
- [SSL Certificate Files](~/.kiwy-signage/) - Local certificate storage
-246
View File
@@ -1,246 +0,0 @@
# Implementation Complete: HTTPS Support for Kiwy-Signage
## Status: ✅ COMPLETE
All changes from `integration_guide.md` have been successfully implemented into the Kiwy-Signage application.
---
## Summary of Changes
### New Files Created
1. **`src/ssl_utils.py`** - Complete SSL/HTTPS utilities module
- SSLManager class for certificate handling
- Automatic certificate download and storage
- SSL-configured requests session management
- Certificate validation and info retrieval
### Modified Files
2. **`src/player_auth.py`** - Enhanced with HTTPS support
- SSL manager integration
- HTTPS-aware authentication
- SSL error handling
- All API methods updated to use SSL sessions
3. **`src/get_playlists_v2.py`** - HTTPS playlist management
- HTTPS configuration support
- SSL manager for media downloads
- Enhanced error handling for SSL issues
4. **`src/main.py`** - Configuration and UI updates
- Default config now uses HTTPS (port 443)
- Connection test passes HTTPS settings
- Better logging for SSL connections
5. **`config/app_config.json`** - Configuration update
- Added `"use_https": true`
- Added `"verify_ssl": true`
- Port explicitly set to 443
### Documentation Created
6. **`HTTPS_IMPLEMENTATION.md`** - Complete implementation guide
- Detailed file-by-file changes
- SSL certificate flow explanation
- Security considerations
- Testing checklist
- Migration guide
7. **`HTTPS_QUICK_REFERENCE.md`** - Developer quick reference
- Code usage examples
- Configuration scenarios
- Troubleshooting guide
- Certificate management commands
---
## Key Features Implemented
### ✅ Automatic Certificate Management
- Player automatically downloads server certificate on first connection
- Certificate stored locally in `~/.kiwy-signage/`
- Subsequent connections use saved certificate
### ✅ Secure Authentication
- All authentication now uses HTTPS
- Automatic URL scheme normalization to HTTPS
- SSL certificate verification (configurable)
### ✅ HTTPS Playlist Operations
- Playlist fetching over HTTPS
- Media file downloads over HTTPS
- Status feedback via HTTPS
### ✅ Configurable Security
- `use_https` setting to enable/disable HTTPS
- `verify_ssl` setting for certificate verification
- Development mode support (without verification)
### ✅ Robust Error Handling
- SSL-specific error messages
- Graceful fallbacks
- Comprehensive logging
---
## Configuration
### Minimal Setup (Using Defaults)
```json
{
"server_ip": "digi-signage.moto-adv.com",
"port": "443",
"screen_name": "tv-terasa",
"quickconnect_key": "8887779",
"use_https": true,
"verify_ssl": true
}
```
### For Testing (Without SSL Verification)
```json
{
"use_https": true,
"verify_ssl": false
}
```
### For HTTP (Development Only)
```json
{
"use_https": false,
"verify_ssl": false,
"port": "5000"
}
```
---
## Testing & Verification
### ✅ Syntax Validation
- All Python files compile without errors
- All JSON configurations are valid
- No import errors
### ✅ Integration Points
- Player authentication with HTTPS ✓
- Playlist fetching with HTTPS ✓
- Media downloads with HTTPS ✓
- Status feedback via HTTPS ✓
- Certificate management ✓
### ✅ Backward Compatibility
- Existing HTTP deployments still work (`use_https: false`)
- Legacy configuration loading still supported
- All changes are non-breaking
---
## Deployment Instructions
### Step 1: Update Configuration
Edit `config/app_config.json` and ensure:
```json
{
"use_https": true,
"verify_ssl": true,
"port": "443"
}
```
### Step 2: Restart Application
```bash
cd /home/pi/Desktop/Kiwy-Signage
./stop_player.sh
./start.sh
```
### Step 3: Verify Functionality
- Monitor logs for SSL messages
- Check certificate is saved: `ls ~/.kiwy-signage/`
- Test playlist fetch works
- Confirm all API calls succeed
### Step 4: Monitor
- Watch for SSL-related errors in first hours
- Verify performance is acceptable
- Monitor certificate expiration if applicable
---
## Troubleshooting Quick Links
| Issue | Solution |
|-------|----------|
| `SSL: CERTIFICATE_VERIFY_FAILED` | See HTTPS_QUICK_REFERENCE.md - Troubleshooting |
| Connection refused on 443 | Check HTTPS is enabled on server |
| Certificate endpoint 404 | Verify `/api/certificate` exists on server |
| Slow HTTPS | Increase timeout in player_auth.py |
See `HTTPS_QUICK_REFERENCE.md` for detailed troubleshooting.
---
## Files Modified Summary
| File | Changes | Status |
|------|---------|--------|
| src/ssl_utils.py | NEW - SSL utilities | ✅ Created |
| src/player_auth.py | HTTPS support added | ✅ Updated |
| src/get_playlists_v2.py | HTTPS downloads | ✅ Updated |
| src/main.py | Config & UI | ✅ Updated |
| config/app_config.json | HTTPS settings | ✅ Updated |
| HTTPS_IMPLEMENTATION.md | NEW - Full guide | ✅ Created |
| HTTPS_QUICK_REFERENCE.md | NEW - Quick ref | ✅ Created |
---
## Compliance with integration_guide.md
- ✅ Python/Requests library certificate handling implemented
- ✅ SSL certificate endpoint integration ready
- ✅ Environment configuration supports HTTPS
- ✅ HTTPS-friendly proxy configuration ready for server
- ✅ Testing checklist included
- ✅ Migration steps documented
- ✅ Troubleshooting guide provided
- ✅ Security recommendations incorporated
---
## Next Steps
1. **Server Setup:** Ensure server has `/api/certificate` endpoint
2. **Testing:** Run through testing checklist in HTTPS_IMPLEMENTATION.md
3. **Deployment:** Follow deployment instructions above
4. **Monitoring:** Watch logs for any SSL-related issues
5. **Documentation:** Share HTTPS_QUICK_REFERENCE.md with operators
---
## Support & Documentation
- **Full Implementation Guide:** `HTTPS_IMPLEMENTATION.md`
- **Quick Reference:** `HTTPS_QUICK_REFERENCE.md`
- **Server Integration:** `integration_guide.md`
- **Source Code:** `src/ssl_utils.py`, `src/player_auth.py`, `src/get_playlists_v2.py`
---
## Version Info
- **Implementation Date:** January 16, 2026
- **Based On:** integration_guide.md specifications
- **Python Version:** 3.7+
- **Framework:** Kivy 2.3.1
---
**Implementation Status: READY FOR PRODUCTION**
All features from the integration guide have been implemented and tested.
The application is now compatible with HTTPS servers.
-346
View File
@@ -1,346 +0,0 @@
# Player Code HTTPS Integration Guide
## Server-Side Improvements Implemented
All critical and medium improvements have been implemented on the server:
### ✅ CORS Support Enabled
- **File**: `app/extensions.py` - CORS extension initialized
- **File**: `app/app.py` - CORS configured for `/api/*` endpoints
- All player API requests now support cross-origin requests
- Preflight OPTIONS requests are properly handled
### ✅ SSL Certificate Endpoint Added
- **Endpoint**: `GET /api/certificate`
- **Location**: `app/blueprints/api.py`
- Returns server certificate in PEM format with metadata:
- Certificate content (PEM format)
- Certificate info (subject, issuer, validity dates, fingerprint)
- Integration instructions for different platforms
### ✅ HTTPS Configuration Updated
- **File**: `app/config.py` - ProductionConfig now has:
- `SESSION_COOKIE_SECURE = True`
- `SESSION_COOKIE_SAMESITE = 'Lax'`
- **File**: `nginx.conf` - Added:
- CORS headers for all responses
- OPTIONS request handling
- X-Forwarded-Port header forwarding
### ✅ Nginx Proxy Configuration Enhanced
- Added CORS headers at nginx level for defense-in-depth
- Proper X-Forwarded headers for protocol/port detection
- HTTPS-friendly proxy configuration
---
## Required Player Code Changes
### 1. **For Python/Kivy Players Using Requests Library**
**Update:** Import and use certificate handling:
```python
import requests
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
import os
class DigiServerClient:
def __init__(self, server_url, hostname, quickconnect_code, use_https=True):
self.server_url = server_url
self.hostname = hostname
self.quickconnect_code = quickconnect_code
self.session = requests.Session()
# CRITICAL: Handle SSL verification
if use_https:
# Option 1: Get certificate from server and trust it
self.setup_certificate_trust()
else:
# Option 2: Disable SSL verification (DEV ONLY)
self.session.verify = False
def setup_certificate_trust(self):
"""Download server certificate and configure trust."""
try:
# First, make a request without verification to get the cert
response = requests.get(
f"{self.server_url}/api/certificate",
verify=False,
timeout=5
)
if response.status_code == 200:
cert_data = response.json()
# Save certificate locally
cert_path = os.path.expanduser('~/.digiserver/server_cert.pem')
os.makedirs(os.path.dirname(cert_path), exist_ok=True)
with open(cert_path, 'w') as f:
f.write(cert_data['certificate'])
# Configure session to use this certificate
self.session.verify = cert_path
print(f"✓ Server certificate installed from {cert_data['certificate_info']['issuer']}")
print(f" Valid until: {cert_data['certificate_info']['valid_until']}")
except Exception as e:
print(f"⚠️ Failed to setup certificate trust: {e}")
print(" Falling back to unverified connection (not recommended for production)")
self.session.verify = False
def get_playlist(self):
"""Get playlist from server with proper error handling."""
try:
response = self.session.get(
f"{self.server_url}/api/playlists",
params={
'hostname': self.hostname,
'quickconnect_code': self.quickconnect_code
},
timeout=10
)
response.raise_for_status()
return response.json()
except requests.exceptions.SSLError as e:
print(f"❌ SSL Error: {e}")
# Log error for debugging
print(" This usually means the server certificate is not trusted.")
print(" Try running: DigiServerClient.setup_certificate_trust()")
raise
except requests.exceptions.ConnectionError as e:
print(f"❌ Connection Error: {e}")
raise
except Exception as e:
print(f"❌ Error: {e}")
raise
def send_feedback(self, status, message=''):
"""Send player feedback/status to server."""
try:
response = self.session.post(
f"{self.server_url}/api/player-feedback",
json={
'hostname': self.hostname,
'quickconnect_code': self.quickconnect_code,
'status': status,
'message': message,
'timestamp': datetime.utcnow().isoformat()
},
timeout=10
)
response.raise_for_status()
return response.json()
except Exception as e:
print(f"Error sending feedback: {e}")
return None
```
### 2. **For Kivy Framework Specifically**
**Update:** In your Kivy HTTP client configuration:
```python
from kivy.network.urlrequest import UrlRequest
from kivy.logger import Logger
import ssl
import certifi
class DigiServerKivyClient:
def __init__(self, server_url, hostname, quickconnect_code):
self.server_url = server_url
self.hostname = hostname
self.quickconnect_code = quickconnect_code
# Configure SSL context for Kivy requests
self.ssl_context = self._setup_ssl_context()
def _setup_ssl_context(self):
"""Setup SSL context with certificate trust."""
try:
# Try to get server certificate
import requests
response = requests.get(
f"{self.server_url}/api/certificate",
verify=False,
timeout=5
)
if response.status_code == 200:
cert_data = response.json()
cert_path = os._get_cert_path()
with open(cert_path, 'w') as f:
f.write(cert_data['certificate'])
# Create SSL context
context = ssl.create_default_context()
context.load_verify_locations(cert_path)
Logger.info('DigiServer', f'SSL context configured with server certificate')
return context
except Exception as e:
Logger.warning('DigiServer', f'Failed to setup SSL: {e}')
return None
def fetch_playlist(self, callback):
"""Fetch playlist with proper SSL handling."""
url = f"{self.server_url}/api/playlists"
params = f"?hostname={self.hostname}&quickconnect_code={self.quickconnect_code}"
headers = {
'Content-Type': 'application/json',
'User-Agent': 'Kiwy-Signage-Player/1.0'
}
request = UrlRequest(
url + params,
on_success=callback,
on_error=self._on_error,
on_failure=self._on_failure,
headers=headers
)
return request
def _on_error(self, request, error):
Logger.error('DigiServer', f'Request error: {error}')
def _on_failure(self, request, result):
Logger.error('DigiServer', f'Request failed: {result}')
```
### 3. **Environment Configuration**
**Add to player app_config.json or environment:**
```json
{
"server": {
"url": "https://192.168.0.121",
"hostname": "player1",
"quickconnect_code": "ABC123XYZ",
"verify_ssl": false,
"use_server_certificate": true,
"certificate_path": "~/.digiserver/server_cert.pem"
},
"connection": {
"timeout": 10,
"retry_attempts": 3,
"retry_delay": 5
}
}
```
---
## Testing Checklist
### Server-Side Tests
- [ ] Verify CORS headers present: `curl -v https://192.168.0.121/api/health`
- [ ] Check certificate endpoint: `curl -k https://192.168.0.121/api/certificate`
- [ ] Test OPTIONS preflight: `curl -X OPTIONS https://192.168.0.121/api/playlists`
- [ ] Verify X-Forwarded headers: `curl -v https://192.168.0.121/`
### Player Connection Tests
- [ ] Player connects with HTTPS successfully
- [ ] Player fetches playlist without SSL errors
- [ ] Player receives status update confirmation
- [ ] Player sends feedback/heartbeat correctly
### Integration Tests
```bash
# Test certificate retrieval
curl -k https://192.168.0.121/api/certificate | jq '.certificate_info'
# Test CORS preflight for player
curl -X OPTIONS https://192.168.0.121/api/playlists \
-H "Origin: http://192.168.0.121" \
-H "Access-Control-Request-Method: GET" \
-v
# Simulate player playlist fetch
curl -k https://192.168.0.121/api/playlists \
--data-urlencode "hostname=test-player" \
--data-urlencode "quickconnect_code=test123" \
-H "Origin: *"
```
---
## Migration Steps
### For Existing Players
1. **Update player code** with new SSL handling from this guide
2. **Restart player application** to pick up changes
3. **Verify connection** works with HTTPS server
4. **Monitor logs** for any SSL-related errors
### For New Players
1. **Deploy updated player code** with SSL support from the start
2. **Configure with HTTPS server URL**
3. **Run initialization** to fetch and trust server certificate
---
## Troubleshooting
### "SSL: CERTIFICATE_VERIFY_FAILED"
- Player is rejecting the self-signed certificate
- **Solution**: Run certificate trust setup or disable SSL verification
### "Connection Refused"
- Server HTTPS port not accessible
- **Solution**: Check nginx is running, port 443 is open, firewall rules
### "CORS error"
- Browser/HTTP client blocking cross-origin request
- **Solution**: Verify CORS headers in response, check Origin header
### "Certificate not found at endpoint"
- Server certificate file missing
- **Solution**: Verify cert.pem exists at `/etc/nginx/ssl/cert.pem`
---
## Security Recommendations
1. **For Development/Testing**: Disable SSL verification temporarily
```python
session.verify = False
```
2. **For Production**:
- Use proper certificates (Let's Encrypt recommended)
- Deploy certificate trust setup at player initialization
- Monitor SSL certificate expiration
- Implement certificate pinning for critical deployments
3. **For Self-Signed Certificates**:
- Use `/api/certificate` endpoint to distribute certificates
- Store certificates in secure location on device
- Implement certificate update mechanism
- Log certificate trust changes for auditing
---
## Next Steps
1. **Implement SSL handling** in player code using examples above
2. **Test with HTTP first** to ensure API works
3. **Enable HTTPS** and test with certificate handling
4. **Deploy to production** with proper SSL setup
5. **Monitor** player connections and SSL errors
+265
View File
@@ -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?
+126
View File
@@ -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"]}')
+70
View File
@@ -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")}')
+92
View File
@@ -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())
+521
View File
@@ -0,0 +1,521 @@
# 🧪 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 later: the rest of the Windows-era leftovers** (all recoverable from the
`Windows-Player` branch, so nothing was lost):
| Item | Why it went |
|------|-------------|
| `working_files/` (29 files) | Dev-era scratch: `install.sh.bak`, the superseded `get_playlists.py` (v1, replaced by `get_playlists_v2.py`), one-off `test_*.py` probes, and `MIGRATION_GUIDE.md` / `INVESTIGATION_RESULTS.md`. Also held real server captures (`server_response_debug.json`: `player_id`, player name, playlist) which had no business in the repo. |
| `documentation/` (5 files) | All described the Windows-era HTTPS integration work. |
| `python version` | Contained `Python 3.12.9` — the Windows build interpreter. The Pi runs 3.13.5. |
| `test_edited_media_upload.py` | Parentless debug script, referenced by nothing. |
| `.display-keepalive.sh`, `.keep-screen-alive.sh`, `.wait-for-display.sh` | Orphan X11 helper scripts, referenced by nothing. Superseded by `linux/linux_display.py`. |
| `.video-optimization.sh`, `.run-background.sh`, `.start-player-cron.sh` | **Kept**`install.sh` references all three. |
**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)
+166
View File
@@ -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
+603
View File
@@ -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
+425
View File
@@ -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))
+467
View File
@@ -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())
+555
View File
@@ -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)
+180
View File
@@ -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.')
+186
View File
@@ -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.')
+171
View File
@@ -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.')
+545
View File
@@ -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

-1
View File
@@ -1 +0,0 @@
Python 3.12.9
+11 -3
View File
@@ -1,5 +1,13 @@
#!/bin/bash
# Start Kivy Signage Player
cd "$(dirname "$0")/src"
python3 main.py
# Start Kivy Signage Player (single run, no watchdog).
#
# linux/run_linux.py is the Raspberry Pi entry point: it sets the Wayland
# session environment and injects the Pi-specific display and web-link
# behaviour. Running src/main.py directly skips all of that.
cd "$(dirname "$0")" || exit 1
if [ -x .venv/bin/python ]; then
exec .venv/bin/python linux/run_linux.py
fi
exec python3 linux/run_linux.py
+4 -4
View File
@@ -324,10 +324,10 @@ class EditPopup(Popup):
shutil.copy2(output_path, self.image_path)
# Force file system sync to ensure data is written to disk.
# NOTE: os.sync() is Linux-only and raises AttributeError on
# Windows — that used to abort the whole pipeline before the
# metadata/upload steps. Use a cross-platform fsync that is
# best-effort and can never break the save/upload flow.
# NOTE: os.sync() does not exist on every platform and used
# to raise AttributeError, aborting the whole pipeline before
# the metadata/upload steps. Use a best-effort flushes that can
# never break the save/upload flow.
try:
if hasattr(os, 'sync'):
os.sync()
+135 -7
View File
@@ -7,8 +7,11 @@ import os
import json
import requests
import logging
import subprocess
import sys
from player_auth import PlayerAuth
from ssl_utils import SSLManager
import media_state # conversion flags/markers, shared with the player
# Set up logging
logging.basicConfig(level=logging.INFO)
@@ -340,7 +343,25 @@ def delete_unused_media(playlist_data, media_dir):
referenced_files.add(file_name)
logger.info(f"📋 Current playlist references {len(referenced_files)} files")
# Names of the normalisation artefacts that belong to files STILL in the
# playlist. Computed from the referenced files with the same naming
# helpers the normaliser uses, so the two cannot drift.
#
# This has to be a per-source decision, not a blanket "never delete
# anything containing _kiwy": a converted output is not itself named in
# the playlist, so a blanket rule would keep the 1080p copies (and their
# metadata) forever after the item is removed from the playlist — an
# unnoticed disk leak on a device that runs for months.
keep_artifacts = set()
for ref in referenced_files:
ref_path = os.path.join(media_dir, ref)
output = media_state.normalized_output(ref_path)
keep_artifacts.add(os.path.basename(output))
keep_artifacts.add(os.path.basename(output) + media_state.MARKER_SUFFIX)
keep_artifacts.add(os.path.basename(media_state.converting_marker(ref_path)))
keep_artifacts.discard('')
if os.path.exists(media_dir):
# Recursively get all media files
deleted_count = 0
@@ -349,20 +370,47 @@ def delete_unused_media(playlist_data, media_dir):
# Get relative path from media_dir
full_path = os.path.join(root, media_file)
rel_path = os.path.relpath(full_path, media_dir)
# Normalisation artefacts belong to the item that produced
# them, so they are kept only while that source is still
# referenced. Their filenames are not in the playlist
# (the player derives them at run time), which is exactly why
# this check cannot be the generic one below.
if media_file in keep_artifacts:
continue
# A leftover artefact whose source is gone: it is derived
# data, safe to delete like any other unreferenced file.
# ``.kiwy-converting`` is excluded — it may belong to a
# conversion running right now for a file this pass missed.
if (media_file.endswith(media_state.MARKER_SUFFIX)
or '_kiwy' in media_file) \
and not media_file.endswith(media_state.CONVERTING_SUFFIX):
try:
os.remove(full_path)
logger.info(f'🗑️ Deleted stale normalisation '
f'artefact: {rel_path}')
deleted_count += 1
except Exception as e:
logger.warning(f'⚠️ Could not delete {rel_path}: {e}')
continue
# Skip if file is in current playlist
# Normalize paths to handle Windows backslashes vs server forward slashes
# Normalize path separators so server-style paths work
normalized_rel = rel_path.replace('\\', '/')
if normalized_rel in referenced_files or rel_path in referenced_files:
continue
# The 4K original is still referenced by the playlist, so it
# is kept (harmless: it is no longer what gets played).
# Delete unreferenced file
try:
os.remove(full_path)
logger.info(f"🗑️ Deleted unused media: {rel_path}")
logger.info(f'🗑️ Deleted unused media: {rel_path}')
deleted_count += 1
except Exception as e:
logger.warning(f"⚠️ Could not delete {rel_path}: {e}")
logger.warning(f'⚠️ Could not delete {rel_path}: {e}')
# Clean up empty directories
for root, dirs, files in os.walk(media_dir, topdown=False):
@@ -386,6 +434,76 @@ def delete_unused_media(playlist_data, media_dir):
def normalize_oversized_media(playlist, media_dir):
"""Start conversion of any oversized video in ``playlist`` (non-blocking).
Called after the playlist and its files are in place. Conversion takes about
31 s for an 18 s 4K clip, so it is launched as a **detached background
process** rather than run here: blocking the sync loop for that long would
stall playlist updates, and the sync runs on the player's asyncio executor.
The child writes the ``.kiwy-converting`` marker before it starts, which is
what makes the player skip the item while its file is being rewritten, and
removes it when finished so the item plays on the next lap.
Failures are never fatal: an unconverted video simply means the player keeps
skipping that item.
"""
try:
normalizer = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'linux', 'video_normalizer.py')
if not os.path.exists(normalizer):
logger.debug('Video normaliser not present; skipping conversion')
return 0
started = 0
for media in playlist or []:
if media.get('type') != 'video':
continue
file_name = media.get('file_name', '')
if not file_name:
continue
path = os.path.join(media_dir, file_name)
if not os.path.isfile(path):
continue
# Already converting (or a fresh marker says so): leave it alone.
if media_state.is_converting(path):
continue
# A finished conversion is reused automatically by the normaliser.
if media_state.normalized_file(path):
continue
oversized, width, height = media_state.is_oversized(path)
if not oversized:
continue
logger.info(
f'🎬 {file_name} is {width}x{height} — above 1920x1080; '
f'converting in the background for Raspberry Pi playback'
)
subprocess.Popen(
[sys.executable, normalizer, path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True, # outlive this sync
)
started += 1
if started:
logger.info(
f'🎬 Started {started} video conversion(s); the player will '
f'skip those items until they are ready'
)
return started
except Exception as exc:
logger.warning(f'Could not start media normalisation: {exc}')
return 0
def update_playlist_if_needed(config, playlist_dir, media_dir):
"""Check for and download updated playlist if available.
@@ -445,7 +563,12 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
# Save new playlist (single file, no versioning)
playlist_file = save_playlist(server_data, playlist_dir)
# Convert any oversized video to 1080p in the background. Doing this
# after the files are on disk (and after the playlist is saved) means
# the player can start skipping the item immediately.
normalize_oversized_media(server_data.get('playlist', []), media_dir)
# Delete unused media files
delete_unused_media(server_data, media_dir)
@@ -465,6 +588,11 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
server_data['playlist'] = downloaded
# Re-save playlist with updated URLs if needed
save_playlist(server_data, playlist_dir)
# A version match does not mean the media is ready: a fresh install
# (or a failed conversion) can still have an oversized file waiting
# to be normalised, and this branch is where that gets noticed.
normalize_oversized_media(server_data.get('playlist', []), media_dir)
return playlist_file
except Exception as e:
+307 -237
View File
@@ -11,14 +11,15 @@ import platform
import signal
import subprocess
import sys
import tempfile
import threading
import time
import asyncio
from concurrent.futures import ThreadPoolExecutor
# Set environment variables for better video performance
# Use setdefault() so that a wrapper script (e.g. run_win.py) can pre-set
# Windows-compatible values before this module is imported.
# Set environment variables for better video performance.
# Use setdefault() so that the platform entry point (linux/run_linux.py) can
# pre-set display/audio values before this module is imported.
os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer')
os.environ.setdefault('FFPYPLAYER_CODECS', 'h264,h265,vp9,vp8')
os.environ.setdefault('SDL_VIDEO_ALLOW_SCREENSAVER', '0')
@@ -31,7 +32,9 @@ os.environ.setdefault('KIVY_GL_BACKEND', 'gl')
os.environ.setdefault('KIVY_INPUTPROVIDERS', 'wayland,x11')
os.environ.setdefault('FFMPEG_THREADS', '2')
os.environ.setdefault('LIBPLAYER_BUFFER', '1048576')
os.environ.setdefault('SDL_AUDIODRIVER', 'alsa')
# NOTE: SDL_AUDIODRIVER is set once above ('alsa,pulse,dummy'). It used to be
# set again here with a hard 'alsa' value; because setdefault never overwrites,
# that line was a silent no-op and only obscured which driver was in effect.
# Configure Kivy BEFORE importing any Kivy modules
from kivy.config import Config
@@ -94,6 +97,7 @@ from kivy.uix.floatlayout import FloatLayout
from kivy.uix.slider import Slider
from playback_trace import trace # always-on playback transition logger
from video_safety import suppress_kivy_video_blocking_unload # bound Kivy's blocking video join
from media_state import resolve_playable # video conversion flags / normalised paths
from weblink_session import (
WeblinkSession,
WeblinkSettings,
@@ -103,7 +107,7 @@ from weblink_session import (
# Load the KV file - resolve relative to this file's directory
_kv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'signage_player.kv')
if not os.path.exists(_kv_path):
# Fallback: relative to cwd (for PyInstaller bundled runs)
# Fallback: relative to cwd (used when launched through a wrapper)
_kv_path = 'signage_player.kv'
Builder.load_file(_kv_path)
@@ -815,7 +819,11 @@ class SettingsPopup(Popup):
Logger.info(f"SettingsPopup: Testing connection to {server_url} (HTTPS: {use_https}, Verify SSL: {verify_ssl})")
# Create temporary auth instance (don't save)
auth = PlayerAuth('/tmp/temp_auth_test.json', use_https=use_https, verify_ssl=verify_ssl)
# Use the platform temp directory rather than a hard-coded
# /tmp: it is not always writable (e.g. systemd PrivateTmp)
# and honouring TMPDIR is the portable behaviour.
temp_file = os.path.join(tempfile.gettempdir(), 'temp_auth_test.json')
auth = PlayerAuth(temp_file, use_https=use_https, verify_ssl=verify_ssl)
# Try to authenticate
success, error = auth.authenticate(
@@ -826,10 +834,9 @@ class SettingsPopup(Popup):
# Clean up temp file
try:
import os
if os.path.exists('/tmp/temp_auth_test.json'):
os.remove('/tmp/temp_auth_test.json')
except:
if os.path.exists(temp_file):
os.remove(temp_file)
except Exception:
pass
# Update UI on main thread
@@ -993,8 +1000,7 @@ class SettingsPopup(Popup):
# ── First-run configuration ──────────────────────────────────────────
# The player is shipped WITHOUT any server credentials: `app_config.json` is
# not bundled into the exe (see windows/build.spec). On first start there is
# The player ships WITHOUT any server credentials. On first start there is
# nothing to connect to, so the player shows a notice after the splash video
# and then opens Settings automatically.
#
@@ -1039,6 +1045,12 @@ DEFAULT_CONFIG = {
#: Seconds the "not configured" notice stays up before Settings opens.
SETUP_NOTICE_SECONDS = 5
#: Media extensions, defined once. The normalisation gate and the playback
#: dispatch must agree on what counts as a video, or a file could be normalised
#: and then played through the image path.
VIDEO_EXTENSIONS = ('.mp4', '.avi', '.mkv', '.mov', '.webm', '.m4v')
IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp')
def config_is_configured(config):
"""True when ``config`` has enough real values to talk to a server.
@@ -1077,21 +1089,14 @@ class SignagePlayer(Widget):
self.playlist = []
self.current_index = 0
self.current_widget = None
self._weblink_proc = None # Mirror of the live weblink browser process
self._weblink_preload_proc = None # Deprecated: pre-warm handled by WeblinkSession
self._watchdog_stop = None # Deprecated: idle watcher owned by WeblinkSession
self._weblink_watchdog_thread = None # Deprecated: idle watcher owned by WeblinkSession
# Unified web-link controller. Owns launch, verified start, idle/max-dwell
# watching and teardown for one weblink item at a time. Platform wrappers
# (e.g. windows/run_win.py) inject their adapters via
# watching and teardown for one weblink item at a time. The platform
# entry point (linux/run_linux.py) injects its adapters via
# `weblink_adapter_factory` before playback starts.
#
# CRITICAL: the factory is injected as a *class* attribute, so do NOT
# assign None unconditionally here. An instance attribute would shadow
# it, play_weblink() would silently fall back to the generic adapter,
# and on Windows that adapter's find_browser() (shutil.which) finds no
# browser because Chrome/Edge are not on PATH — so every weblink failed
# with "launch() returned False" and the item was skipped.
# it and play_weblink() would silently fall back to the generic adapter.
self._weblink_session = None
if not callable(getattr(type(self), 'weblink_adapter_factory', None)):
self.weblink_adapter_factory = None
@@ -1106,24 +1111,20 @@ class SignagePlayer(Widget):
self._video_eos_pending = False # guards against duplicate EOS callbacks on one video
self._last_advance_at = 0.0 # monotonic time of the last next_media advance
self._advance_generation = 0 # bumped on every advance; stamps scheduled callbacks
self._focus_keeper_event = None # Clock interval that keeps the window focused during video
self._focus_keeper_elapsed = 0.0
self._focus_keeper_duration = 0.0
self._focus_keeper_misses = 0 # consecutive keeper ticks without foreground
# Continuous foreground guardian — re-asserts the Kivy window to the
# foreground while media is playing (images AND videos), so focus that
# is lost overnight (screensaver, update, dialog, restart) is recovered
# even when an image is on screen. Skipped while paused or a weblink
# browser is showing (focus legitimately belongs to the browser).
self._focus_guardian_event = None
self._focus_guardian_interval = 5.0
self._focus_guardian_misses = 0
self._video_watchdog_event = None # Clock interval that watches video progress
self._video_watchdog_stopped = False
# self.should_refresh_playlist = False # Flag to reload playlist after edit upload (DISABLED - causing crashes)
self.consecutive_errors = 0 # Track consecutive playback errors
self.max_consecutive_errors = 10 # Maximum errors before stopping
self.intro_played = False # Track if intro has been played
# Intro-loop fallback. When every remaining item is still being
# converted, there is nothing playable; rather than showing a blank
# screen (or a frozen video) the player loops the intro until a
# converted item is ready. See _maybe_loop_intro_until_ready().
self._intro_loop_widget = None
self._intro_loop_event = None
self._intro_loop_active = False
self._waiting_for_conversion = False
# Card reader for authentication
self.card_reader = None
self._pending_edit_image = None
@@ -1160,18 +1161,11 @@ class SignagePlayer(Widget):
Clock.schedule_interval(self.update_heartbeat, 10) # Update every 10 seconds
# Start screen activity signaler (keep display awake)
Clock.schedule_interval(self.signal_screen_activity, 20) # Signal every 20 seconds
# Start the continuous focus guardian. It re-asserts the Kivy window
# to the foreground whenever focus is lost while media is playing, so
# the overnight scenario (focus stolen by a dialog/screensaver/update
# while an image was on screen) self-heals. It skips itself while the
# player is paused or a weblink browser is showing.
self._start_focus_guardian()
def _update_size(self, instance, value):
# Keep the screen-size properties in sync with the real window size.
# On Windows, Window.size is the TRUE (DPI-aware) resolution; without
# this the content_area could be sized from a stale/pre-fullscreen
# value, leaving a black strip and wrong image/video scaling.
# Without this the content_area could be sized from a stale or
# pre-fullscreen value, leaving a black strip and wrong scaling.
self.size = value
try:
w, h = value
@@ -1250,8 +1244,6 @@ class SignagePlayer(Widget):
- Window close (X / Alt+F4) is blocked by _guard_window_close until
the password exit flow calls set_allow_exit(True).
- Ctrl+C is ignored so the console cannot kill the player.
Platform wrappers (e.g. run_win.py) may extend this to also swallow
Alt+Tab / Win / Ctrl+Esc via a low-level keyboard hook.
"""
self.config['production_mode'] = bool(enabled)
if enabled:
@@ -1387,7 +1379,7 @@ class SignagePlayer(Widget):
"""Show the "not configured" notice, then open Settings automatically.
The player is shipped without credentials, so this is the expected
first-run experience on a brand-new .exe: tell the operator what is
first-run experience on a brand-new install: tell the operator what is
missing, wait SETUP_NOTICE_SECONDS, then open the settings screen so
they can enter the server details.
"""
@@ -1419,7 +1411,7 @@ class SignagePlayer(Widget):
"""Called when Settings was used to configure the player.
Applies the new values immediately and starts playback, so the operator
does not have to restart the .exe after entering the server details.
does not have to restart the player after entering the server details.
"""
self._configured = config_is_configured(self.config)
if not self._configured:
@@ -1726,6 +1718,16 @@ class SignagePlayer(Widget):
if self.is_paused:
Logger.debug(f"SignagePlayer: Skipping play_current_media - player is paused")
return
# ── Intro-loop fallback ─────────────────────────────────────────
# Guarded by ``_waiting_for_conversion`` so this costs nothing in the
# normal case. It fires when every item is an oversized video still
# being converted: instead of a blank screen, loop the intro. The flag
# is only set by the skip paths below, so a genuinely empty or broken
# playlist still follows the normal error handling.
if self._waiting_for_conversion:
if self._maybe_loop_intro_until_ready():
return
if not self.playlist:
Logger.warning("SignagePlayer: Cannot play - playlist is empty")
@@ -1753,7 +1755,7 @@ class SignagePlayer(Widget):
type=media_item.get('type', '?'),
duration=duration,
after_weblink=_after_weblink,
weblink_proc=bool(getattr(self, '_weblink_proc', None)))
weblink_active=self._weblink_is_active())
# ── Leaving a weblink: close the browser safely ──────────────────
# Weblinks are torn down by the WeblinkSession. `close()` is
@@ -1838,6 +1840,48 @@ class SignagePlayer(Widget):
self.consecutive_errors += 1
self._skip_to_next_media()
return
# ── Video normalisation gate ─────────────────────────────────────
# An oversized video (e.g. 4K) cannot be decoded in real time on a
# Pi 4 — measured 0.90x realtime — so showing it produces a frozen
# frame. Instead of playing it badly:
#
# converting -> skip this lap (the file is being rewritten)
# pending -> skip and request conversion
# ready -> play the normalised 1080p file instead
#
# Resolution is read from the MP4 header directly, so this costs no
# subprocess on the playback path.
if os.path.splitext(file_name)[1].lower() in VIDEO_EXTENSIONS:
playable, state = resolve_playable(media_path)
if state == 'converting':
Logger.info(
f"SignagePlayer: {file_name} is being converted to "
f"1920x1080 - skipping this lap"
)
trace("video_skipped_converting", name=file_name)
self._note_conversion_pending()
self._advance_without_wait()
return
if state == 'pending':
Logger.warning(
f"SignagePlayer: {file_name} is above 1920x1080 and not "
f"converted yet - skipping and requesting conversion"
)
trace("video_skipped_pending", name=file_name)
self._request_conversion(media_path)
self._note_conversion_pending()
self._advance_without_wait()
return
if playable != media_path:
Logger.info(
f"SignagePlayer: Using normalised video "
f"{os.path.basename(playable)} instead of {file_name}"
)
trace("video_using_normalized", source=file_name,
normalized=os.path.basename(playable))
media_path = playable
# ────────────────────────────────────────────────────────────────
# Remove status label if showing
self.ids.status_label.opacity = 0
@@ -1860,11 +1904,16 @@ class SignagePlayer(Widget):
item_muted = True
if item_muted:
Logger.info(f"SignagePlayer: Video muted per playlist (audio={item_audio}, muted={item_muted})")
# A new item is starting, so any intro loop has served its
# purpose. Stopping it here (not in the gate above) means the
# loop only ends once real content is about to render.
self._stop_intro_loop()
self.play_video(media_path, duration, muted=item_muted)
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
# Image file
Logger.debug(f"SignagePlayer: Media type: IMAGE")
trace("starting_image", path=media_path)
self._stop_intro_loop()
self.play_image(media_path, duration, force_reload=force_reload)
else:
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
@@ -1918,7 +1967,7 @@ class SignagePlayer(Widget):
Needed because ffpyplayer initialises SDL2_mixer from the FIRST audio
file it opens and reuses those parameters. Handing it a video with NO
audio track (integer division of rate/channels by zero internally)
crashes the process with an access violation in SDL2_mixer.dll
crashes the process with an access violation in the SDL2_mixer library
(0xc0000005) observed when a silent 4K clip entered the playlist
after an AAC stereo clip had already been played.
@@ -1926,7 +1975,7 @@ class SignagePlayer(Widget):
determine the streams, we report True and let the normal path proceed.
"""
try:
ffprobe = os.path.join(os.path.dirname(sys.executable), '_internal', 'ffprobe.exe')
ffprobe = os.path.join(os.path.dirname(sys.executable), '_internal', 'ffprobe')
if not os.path.exists(ffprobe):
# Development run: fall back to PATH / the ffpyplayer bundle.
import shutil as _shutil
@@ -1981,10 +2030,10 @@ class SignagePlayer(Widget):
# CALLING thread during unload. Kivy's own on_eos handler sets
# state='stop' (which joins the ffpyplayer decode thread) while the
# event is being dispatched — i.e. on the Kivy main thread. When
# that thread is slow to exit, the join parks the UI thread and
# Windows declares the app hung (AppHangB1, observed after ~30-45
# minutes of looping). Must run BEFORE the widget is constructed:
# the decode thread is created during play(). See src/video_safety.py.
# that thread is slow to exit, the join parks the UI thread and the
# whole player freezes (observed after ~30-45 minutes of looping).
# Must run BEFORE the widget is constructed: the decode thread is
# created during play(). See src/video_safety.py.
suppress_kivy_video_blocking_unload()
# Create Video widget with optimized settings for smooth playback.
@@ -2016,11 +2065,7 @@ class SignagePlayer(Widget):
# Add to content area
self.ids.content_area.add_widget(self.current_widget)
# Start the focus keeper so the window stays foreground for the
# whole video duration (handles SDL surface swaps at load and any
# later re-swaps). The keeper is non-blocking.
self._start_focus_keeper(duration + 1)
trace("video_focus_keeper_started")
trace("video_loaded")
# Start a progress watchdog. ffpyplayer does NOT reliably dispatch
# EOS — the trace shows the video looping its tail for the gap
@@ -2087,170 +2132,6 @@ class SignagePlayer(Widget):
pass
self._video_watchdog_event = None
def _bring_window_to_front_nonblocking(self):
"""Bring the Kivy window forward WITHOUT stalling the UI.
The heavy Win32 bring-to-front (EnumWindows + AttachThreadInput +
SetForegroundWindow) can take ~1.5s and was blocking the main thread
at every video start (see playback_trace.log: video_loaded then +1.5s
before video_focus_reasserted). We therefore run it as a scheduled
Kivy Clock callback.
IMPORTANT (why this fixes the overnight focus loss): the previous
implementation spawned a `threading.Thread` and called
`Window.raise_window()` (an SDL call NOT thread-safe) plus Win32
`SetForegroundWindow` from that random worker thread. Windows applies
its "foreground lock" against background processes and is
particularly hostile to SetForegroundWindow called from a non-input
thread, so once the app ran in the background the keeper could detect
the lost focus forever but could never win it back (17,681
focus_keeper_focus_lost ticks overnight, window never raised).
The Win32 bring-to-front helpers we use here are safe to call from the
main/Kivy thread (they use ShowWindowAsync + SetWindowPos Z-order
flash, which work even when the process is backgrounded), and running
them on the SDL thread avoids touching SDL from a foreign thread.
"""
# Skip entirely if the window is already foreground — cheap path.
try:
check = getattr(self, '_is_foreground_win', None)
if check is not None and check():
return
except Exception:
pass
def _do():
try:
from kivy.core.window import Window as _KivyWindow
_KivyWindow.show()
_KivyWindow.raise_window()
except Exception:
pass
try:
_bring = getattr(self, '_bring_kivy_to_front_win', None)
if _bring is not None:
_bring()
except Exception:
pass
# Run on the Kivy main thread (SDL thread) so we never touch SDL from
# a foreign thread. Non-blocking: scheduled, not blocking the frame.
Clock.schedule_once(lambda dt: _do(), 0)
def _start_focus_keeper(self, duration):
"""Periodically keep the Kivy window in the foreground.
Runs for up to `duration` seconds while a video is on screen. Each tick
first does a CHEAP foreground check; the expensive bring-to-front only
runs when focus was actually lost. Re-asserts focus on the SDL thread
(via Clock) rather than a worker thread.
"""
self._stop_focus_keeper()
self._focus_keeper_elapsed = 0.0
self._focus_keeper_duration = float(duration)
self._focus_keeper_misses = 0
self._focus_keeper_event = Clock.schedule_interval(
self._focus_keeper_tick, 0.5
)
trace("focus_keeper_started", duration=duration)
def _focus_keeper_tick(self, dt):
"""One focus-keeper tick: cheap check, heavy action only if needed."""
self._focus_keeper_elapsed += dt
if self._focus_keeper_elapsed > self._focus_keeper_duration:
self._stop_focus_keeper()
return
# Cheap foreground check first — avoids the 1.5s Win32 work entirely
# when the window already has focus.
try:
check = getattr(self, '_is_foreground_win', None)
if check is not None and check():
self._focus_keeper_misses = 0
return # already focused, nothing to do
except Exception:
pass
self._focus_keeper_misses += 1
trace("focus_keeper_focus_lost")
# Lightweight SDL raise every tick (cheap, safe on the SDL thread).
try:
from kivy.core.window import Window as _KivyWindow
_KivyWindow.raise_window()
except Exception:
pass
# The heavy Win32 bring-to-front (AttachThreadInput +
# SetForegroundWindow + Z-order flash) only runs on a throttled cadence
# (once per second) so a persistently-lost focus can't stutter video.
if self._focus_keeper_misses % 2 == 1:
self._bring_window_to_front_nonblocking()
def _stop_focus_keeper(self):
"""Cancel any active focus keeper interval."""
ev = getattr(self, '_focus_keeper_event', None)
if ev is not None:
try:
Clock.unschedule(ev)
except Exception:
pass
self._focus_keeper_event = None
self._focus_keeper_elapsed = 0.0
self._focus_keeper_misses = 0
# ── Continuous focus guardian (Windows foreground re-assertion) ──────
# The video-only focus keeper stops as soon as the playlist moves to an
# image, so if focus is lost while an image is on screen (which is exactly
# what happened overnight) nothing ever pulls the window back to the front.
# The guardian runs for the whole playback session and only skips itself
# while the player is paused or a weblink browser is showing.
def _start_focus_guardian(self):
"""Start the continuous foreground guardian (idempotent)."""
if getattr(self, '_focus_guardian_event', None) is not None:
return
self._focus_guardian_misses = 0
self._focus_guardian_event = Clock.schedule_interval(
self._focus_guardian_tick, self._focus_guardian_interval
)
Logger.debug("SignagePlayer: Focus guardian started")
def _focus_guardian_tick(self, dt):
"""Re-assert the Kivy window to the foreground if focus was lost."""
# Don't fight the weblink browser — it legitimately owns foreground.
if getattr(self, '_weblink_proc', None) is not None:
return
# Don't steal focus while the user is interacting with the app
# (settings/exit popups, paused playback).
if self.is_paused:
return
try:
check = getattr(self, '_is_foreground_win', None)
if check is not None and check():
self._focus_guardian_misses = 0
return # already foreground
except Exception:
pass
self._focus_guardian_misses += 1
# Only raise after the foreground has actually been lost a couple of
# ticks in a row (avoids fighting transient focus such as a click on
# a notification that the user immediately closes).
if self._focus_guardian_misses >= 2:
trace("focus_guardian_reassert",
misses=self._focus_guardian_misses)
self._bring_window_to_front_nonblocking()
def _stop_focus_guardian(self):
"""Stop the continuous foreground guardian."""
ev = getattr(self, '_focus_guardian_event', None)
if ev is not None:
try:
Clock.unschedule(ev)
except Exception:
pass
self._focus_guardian_event = None
self._focus_guardian_misses = 0
def _on_video_eos(self, instance):
"""Callback when video reaches end of stream.
@@ -2385,7 +2266,6 @@ class SignagePlayer(Widget):
widget = self.current_widget
self.current_widget = None
self._video_eos_pending = False
self._stop_focus_keeper()
self._stop_video_watchdog()
threading.Thread(
target=self._teardown_video_async,
@@ -2403,7 +2283,6 @@ class SignagePlayer(Widget):
self.current_widget = None
# Reset the EOS guard so the next video can advance normally.
self._video_eos_pending = False
self._stop_focus_keeper()
self._stop_video_watchdog()
Logger.debug("SignagePlayer: Previous widget removed")
trace("widget_removed")
@@ -2606,6 +2485,208 @@ class SignagePlayer(Widget):
except Exception:
pass
# ── Video normalisation: skip while converting, loop intro if nothing else ──
def _request_conversion(self, media_path):
"""Ask the normaliser to convert ``media_path`` (fire and forget).
``linux/video_normalizer.py`` owns the conversion; the player only
signals intent, so the ffmpeg dependency stays out of the playback
process. The child writes a ``.kiwy-converting`` marker immediately,
which is what stops every subsequent lap from re-requesting it.
"""
try:
normalizer = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'linux', 'video_normalizer.py')
if not os.path.exists(normalizer):
Logger.warning(
f"SignagePlayer: video normaliser not found at {normalizer}"
)
return False
subprocess.Popen(
[sys.executable, normalizer, media_path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True, # survive the player; not killed on exit
)
Logger.info(
f"SignagePlayer: requested 1080p conversion of "
f"{os.path.basename(media_path)}"
)
return True
except Exception as exc:
Logger.error(f"SignagePlayer: could not start conversion: {exc}")
return False
def _note_conversion_pending(self):
"""Remember that the playlist is blocked on a conversion."""
self._waiting_for_conversion = True
def _advance_without_wait(self):
"""Move to the next item promptly, without the normal dwell delay.
Skipped items must not occupy their configured duration: a 19 s 4K slot
that cannot be shown should not add 19 s of nothing to each lap. The
advance is scheduled on the Clock rather than called directly so the
stack unwinds (the same reason ``_skip_to_next_media`` schedules).
"""
Clock.unschedule(self.next_media)
Clock.schedule_once(lambda dt: self.next_media(), 0.1)
def _has_playable_item(self):
"""True when at least one item can be shown right now.
``converting`` and ``pending`` video items do not count those are
exactly the case that triggers the intro loop.
"""
for item in self.playlist:
if self._item_is_weblink(item):
return True
file_name = item.get('file_name', '')
if not file_name:
continue
ext = os.path.splitext(file_name)[1].lower()
path = os.path.join(self.media_dir, file_name)
if ext in VIDEO_EXTENSIONS:
_playable, state = resolve_playable(path)
if state == 'ready':
return True
continue
# Images (and anything else) are considered playable if present.
if ext in IMAGE_EXTENSIONS and os.path.exists(path):
return True
return False
def _intro_path(self):
return os.path.join(self.resources_path, 'intro1.mp4')
def _maybe_loop_intro_until_ready(self):
"""Loop the intro video while the whole playlist is unavailable.
When the (single-item) playlist is entirely a video still being
converted, there is nothing to show. A blank screen is the worst
possible outcome for signage, so the intro is looped instead it is
the one asset guaranteed to be local, small and playable.
Returns True when the intro loop has taken over playback.
"""
if self._intro_loop_active:
return True
if self._has_playable_item():
self._waiting_for_conversion = False
return False
if not self._waiting_for_conversion:
# Nothing playable and no conversion in flight: not our case to
# handle (the normal error/skip path owns that).
return False
intro = self._intro_path()
if not os.path.exists(intro):
Logger.warning(
"SignagePlayer: no playable media and no intro video to loop"
)
return False
Logger.info(
"SignagePlayer: nothing playable yet (video conversion in "
"progress) - looping the intro video until it is ready"
)
trace("intro_loop_started")
self._intro_loop_active = True
try:
suppress_kivy_video_blocking_unload()
except Exception:
pass
try:
self._remove_current_widget()
except Exception:
pass
# ``eos: loop`` keeps the widget cycling by itself; the poll below is a
# safety net in case EOS is not dispatched (a known ffpyplayer quirk on
# this platform).
try:
widget = Video(
source=intro,
state='play',
options={'eos': 'loop'},
size_hint=(1, 1),
pos_hint={'center_x': 0.5, 'center_y': 0.5},
)
self.current_widget = widget
self._intro_loop_widget = widget
self.ids.content_area.add_widget(widget)
except Exception as exc:
Logger.error(f"SignagePlayer: could not start the intro loop: {exc}")
self._intro_loop_active = False
return False
# Poll for a playable item. Kept well above the 30 s sync interval so
# the check is cheap, but frequent enough that the converted video
# starts within a few seconds of being ready.
self._stop_intro_loop_event()
self._intro_loop_event = Clock.schedule_interval(
self._intro_loop_tick, 3.0
)
return True
def _intro_loop_tick(self, dt):
"""Leave the intro loop as soon as something is playable again."""
if not self._intro_loop_active:
return
if not self._has_playable_item():
return
Logger.info(
"SignagePlayer: a playable item became available - leaving the "
"intro loop"
)
trace("intro_loop_ended")
self._stop_intro_loop_event()
self._intro_loop_active = False
self._waiting_for_conversion = False
# Restart the playlist so the newly-converted item is played (and, for
# a single-item playlist, from its beginning rather than mid-loop).
self.current_index = 0
self.play_current_media()
def _stop_intro_loop_event(self):
event = self._intro_loop_event
if event is not None:
try:
Clock.unschedule(event)
except Exception:
pass
self._intro_loop_event = None
def _stop_intro_loop(self):
"""Tear the intro loop down (idempotent)."""
self._stop_intro_loop_event()
if not self._intro_loop_active:
return
self._intro_loop_active = False
widget, self._intro_loop_widget = self._intro_loop_widget, None
if widget is not None:
try:
if widget in self.ids.content_area.children:
self.ids.content_area.remove_widget(widget)
except Exception:
pass
# Stop it off the main thread: unload() joins the decode thread.
try:
threading.Thread(
target=self._teardown_video_async, args=(widget,),
daemon=True, name='intro-loop-teardown',
).start()
except Exception:
pass
if self.current_widget is widget:
self.current_widget = None
def _kill_weblink_process(self):
"""Terminate the kiosk browser, idle watcher and any pre-warm process.
@@ -2618,7 +2699,6 @@ class SignagePlayer(Widget):
session.close()
except Exception as e:
Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}")
self._weblink_proc = None
# Raise the Kivy window back to the front — when Chromium ran in kiosk
# mode it covered the Kivy window entirely; the window manager won't
@@ -2729,9 +2809,7 @@ class SignagePlayer(Widget):
"""Pre-warm the browser for an upcoming web-link item.
Delegated to :class:`WeblinkSession`, which asks the preferred engine to
warm its binary and page cache. Platform layers may override this (the
Windows wrapper disables pre-warm because an off-screen browser there
interferes with audio and GPU resources).
warm its binary and page cache.
"""
session = self.get_weblink_session()
session.prewarm(url)
@@ -3228,14 +3306,6 @@ class SignagePlayerApp(App):
def on_stop(self):
Logger.info("SignagePlayerApp: Application stopped")
# Stop the continuous focus guardian
try:
if self.root and hasattr(self.root, '_stop_focus_guardian'):
self.root._stop_focus_guardian()
Logger.info("SignagePlayerApp: Focus guardian stopped")
except Exception as e:
Logger.debug(f"SignagePlayerApp: Error stopping focus guardian: {e}")
# Close any kiosk browser opened for a weblink item
try:
if self.root and hasattr(self.root, '_kill_weblink_process'):
+301
View File
@@ -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
View File
@@ -6,15 +6,16 @@ Checks server connectivity and manages WiFi restart on connection failure
import subprocess
import time
import random
import platform
import shutil
import requests
from datetime import datetime
from kivy.logger import Logger
from kivy.clock import Clock
# Detect platform once so the ping / WiFi-restart commands below can
# pick the correct syntax (Linux vs Windows).
IS_WINDOWS = platform.system() == 'Windows'
#: Interface used by the restart fallbacks. Raspberry Pi OS names the wireless
#: interface wlan0; only used by the legacy path (NetworkManager resolves the
#: device itself via `nmcli device`).
WIFI_INTERFACE = 'wlan0'
class NetworkMonitor:
@@ -104,13 +105,9 @@ class NetworkMonitor:
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
# Ping the server hostname with 3 attempts.
# Windows ping uses -n for count and -w for timeout (ms),
# while Linux uses -c and -W.
if IS_WINDOWS:
cmd = ['ping', '-n', '3', '-w', '3000', hostname]
else:
cmd = ['ping', '-c', '3', '-W', '3', hostname]
# Ping the server hostname with 3 attempts (Linux syntax: -c count,
# -W per-packet timeout in seconds).
cmd = ['ping', '-c', '3', '-W', '3', hostname]
result = subprocess.run(
cmd,
capture_output=True,
@@ -135,9 +132,12 @@ class NetworkMonitor:
def _restart_wifi(self):
"""
Restart WiFi by turning it off for a specified duration then back on.
Uses the platform-appropriate commands:
- Linux (Raspberry Pi): sudo rfkill / ifconfig / dhclient
- Windows: netsh wlan disconnect / connect
Raspberry Pi OS Trixie manages networking with **NetworkManager**, so
``nmcli`` is the correct interface and needs no ``sudo``. The legacy
rfkill/ifconfig path is kept only as a fallback for non-NetworkManager
installs (it requires root: see ``setup_wifi_control.sh``).
This runs in a separate thread to not block the main application.
"""
def wifi_restart_thread():
@@ -146,10 +146,10 @@ class NetworkMonitor:
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
Logger.info("NetworkMonitor: ====================================")
if IS_WINDOWS:
self._restart_wifi_windows()
if shutil.which('nmcli'):
self._restart_wifi_nmcli()
else:
self._restart_wifi_linux()
self._restart_wifi_legacy()
except subprocess.TimeoutExpired:
Logger.error("NetworkMonitor: WiFi restart command timeout")
@@ -161,58 +161,90 @@ class NetworkMonitor:
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
thread.start()
def _restart_wifi_windows(self):
"""Windows WiFi restart using netsh. Turn off for the wait period,
then turn back on so Windows reconnects to the preferred network."""
def _restart_wifi_nmcli(self):
"""WiFi restart via NetworkManager (the default on Raspberry Pi OS).
``nmcli`` is what actually manages the radio on Trixie, works without
``sudo`` for a user in the ``netdev``/``NetworkManager`` group, and
reconnects to the saved profile automatically. The previous
implementation used ``rfkill``/``ifconfig``/``dhclient`` ``ifconfig``
and ``dhclient`` are not even installed on a stock Trixie image, so the
restart silently did nothing.
"""
wait_minutes = self.wifi_restart_duration / 60
Logger.info(
f"NetworkMonitor: Windows WiFi restart — off for {wait_minutes:.0f} min"
f"NetworkMonitor: NetworkManager WiFi restart — off for "
f"{wait_minutes:.0f} min"
)
# Turn WiFi OFF
# Turn the radio OFF (persists across the wait, unlike a disconnect).
off = subprocess.run(
['netsh', 'wlan', 'disconnect'],
capture_output=True, text=True, timeout=10
['nmcli', 'radio', 'wifi', 'off'],
capture_output=True, text=True, timeout=15
)
if off.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi turned OFF (netsh wlan disconnect)")
Logger.info("NetworkMonitor: ✓ WiFi radio disabled (nmcli radio wifi off)")
else:
# Some builds deny `radio off` to non-root; a device disconnect
# still forces a reconnect without needing privileges.
Logger.warning(
f"NetworkMonitor: netsh wlan disconnect failed: {off.stderr.strip()}"
f"NetworkMonitor: nmcli radio off failed ({off.stderr.strip()}); "
f"falling back to device disconnect"
)
subprocess.run(
['nmcli', 'device', 'disconnect', WIFI_INTERFACE],
capture_output=True, text=True, timeout=15
)
# Wait with WiFi OFF
Logger.info(
f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes "
f"(started {datetime.now().strftime('%H:%M:%S')})"
)
time.sleep(self.wifi_restart_duration)
Logger.info(
f"NetworkMonitor: Wait period completed at {datetime.now().strftime('%H:%M:%S')}"
f"NetworkMonitor: Wait period completed at "
f"{datetime.now().strftime('%H:%M:%S')}"
)
# Turn WiFi back ON — Windows reconnects to the preferred network
# Turn the radio back ON and let NetworkManager auto-connect.
on = subprocess.run(
['netsh', 'wlan', 'connect'],
capture_output=True, text=True, timeout=10
['nmcli', 'radio', 'wifi', 'on'],
capture_output=True, text=True, timeout=15
)
if on.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi re-enabled (netsh wlan connect)")
Logger.info("NetworkMonitor: ✓ WiFi radio enabled (nmcli radio wifi on)")
else:
# 'netsh wlan connect' without a profile may return non-zero even
# though the radio comes back on; log it but don't fail hard.
Logger.warning(
f"NetworkMonitor: netsh wlan connect returned {on.returncode}: "
f"{on.stderr.strip()} (may reconnect automatically)"
Logger.error(f"NetworkMonitor: nmcli radio on failed: {on.stderr.strip()}")
# Give NetworkManager a moment, then nudge the saved connection up.
time.sleep(10)
connect = subprocess.run(
['nmcli', 'device', 'connect', WIFI_INTERFACE],
capture_output=True, text=True, timeout=30
)
if connect.returncode == 0:
Logger.info("NetworkMonitor: ✓ Reconnected to the saved WiFi profile")
else:
# NetworkManager often reconnects on its own before we get here, in
# which case `device connect` reports "already connected".
Logger.info(
f"NetworkMonitor: nmcli device connect returned "
f"{connect.returncode}: {connect.stderr.strip()} "
f"(NetworkManager may have reconnected automatically)"
)
Logger.info("NetworkMonitor: ====================================")
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
Logger.info("NetworkMonitor: ====================================")
def _restart_wifi_linux(self):
"""Linux (Raspberry Pi) WiFi restart using rfkill/ifconfig/dhclient."""
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
def _restart_wifi_legacy(self):
"""Fallback for systems without NetworkManager: rfkill + ip.
Requires passwordless ``sudo`` for the exact commands used see
``setup_wifi_control.sh``. Note ``ifconfig``/``dhclient`` are legacy and
not installed on current images; ``ip`` is used instead.
"""
# Turn off WiFi using rfkill (works regardless of the network stack)
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
result = subprocess.run(
['sudo', 'rfkill', 'block', 'wifi'],
@@ -224,19 +256,19 @@ class NetworkMonitor:
if result.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
else:
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
Logger.error(f"NetworkMonitor: rfkill failed, trying ip link...")
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
# Fallback to ifconfig
# Fallback to ip link (ifconfig is not installed on Trixie)
result2 = subprocess.run(
['sudo', 'ifconfig', 'wlan0', 'down'],
['sudo', 'ip', 'link', 'set', WIFI_INTERFACE, 'down'],
capture_output=True,
text=True,
timeout=10
)
if result2.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ip link)")
else:
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
@@ -275,7 +307,7 @@ class NetworkMonitor:
# Also bring interface up
result2 = subprocess.run(
['sudo', 'ifconfig', 'wlan0', 'up'],
['sudo', 'ip', 'link', 'set', WIFI_INTERFACE, 'up'],
capture_output=True,
text=True,
timeout=10
@@ -284,19 +316,14 @@ class NetworkMonitor:
if result2.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
# Wait a bit for connection to establish
# Wait a bit for connection to establish.
# NOTE: the DHCP client is deliberately not invoked here —
# `dhclient` is not installed on Raspberry Pi OS Trixie and
# hand-rolling it would fight NetworkManager/dhcpcd, which handle
# the lease automatically once the interface comes back up.
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
time.sleep(10)
# Try to restart DHCP
Logger.info("NetworkMonitor: Requesting IP address...")
subprocess.run(
['sudo', 'dhclient', 'wlan0'],
capture_output=True,
text=True,
timeout=15
)
Logger.info("NetworkMonitor: ====================================")
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
Logger.info("NetworkMonitor: ====================================")
+2 -2
View File
@@ -1,8 +1,8 @@
"""
playback_trace.py Always-on playback transition logger.
Kivy's log level is forced to 'warning' in main.py / run_win.py, which
suppresses every Logger.info()/Logger.debug() line. That made it impossible
Kivy's log level is forced to 'warning' in main.py, which suppresses every
Logger.info()/Logger.debug() line. That made it impossible
to see why the player skips/crashes at the weblink->image and video->next
transitions.
+4 -5
View File
@@ -2,9 +2,9 @@
The bug this fixes
------------------
The player hung (Windows `AppHangB1`) after ~30-45 minutes of looping, always
at a *video* item. The trace stopped dead right after `video_loaded` with no
`video_eos` and no `advance_after_video_eos`.
The player froze after ~30-45 minutes of looping, always at a *video* item.
The trace stopped dead right after `video_loaded` with no `video_eos` and no
`advance_after_video_eos`.
Cause a blocking ``join()`` that Kivy performs on the calling thread:
@@ -23,8 +23,7 @@ Cause — a blocking ``join()`` that Kivy performs on the calling thread:
4. That join waits for the ffpyplayer decode thread, whose lifetime is variable
(the existing code comments note "0.4s up to 100s+"). When it does not
return, the SDL/Kivy main thread never returns either the window stops
pumping messages and Windows declares the app hung.
pumping messages and the whole player appears hung.
Why it only shows up after a while: our own EOS handler deliberately does not
set ``state = 'stop'`` (that path is already handled off-thread), so the hang
depends on Kivy's own handler firing first and on that particular video's
+57 -71
View File
@@ -2,17 +2,14 @@
Why this module exists
----------------------
Web-link items used to be implemented three times:
* ``main.py`` Chromium subprocess + /dev/input idle watchdog (Linux/Pi)
* ``run_win.py`` Chrome/Edge subprocess + Win32 overlay + focus fighting
* ``cef_browser.py`` embedded CEF child window (Windows, optional)
Web-link items used to be implemented in several places at once ``main.py``
(Chromium subprocess + ``/dev/input`` idle watchdog) and a platform wrapper
(Chrome subprocess + overlay + focus fighting) each owning its own process
handle, watchdog and teardown logic. That made "who owns the browser"
ambiguous, which caused the documented failure modes: leaked browsers, skipped
items, lost foreground and blank screens when a page failed to load.
Each copy owned its own process handle, its own watchdog and its own teardown
logic, so "who owns the browser" was ambiguous. That ambiguity caused the
documented failure modes: leaked browsers, skipped items, lost foreground and
blank screens when a page failed to load.
This module replaces all three with a single owner:
This module replaces all of them with a single owner:
:class:`WeblinkSession` orchestrates one weblink item at a time.
* validates the URL before anything is launched
@@ -224,7 +221,7 @@ class WeblinkAdapter:
embedded = False
#: Set by adapters that render in-window but can still *prove* the page
#: appeared (WebView2). Without this, an embedded engine is trusted
#: appeared (an embedded engine). Without this, an embedded engine is trusted
#: blindly, so a page that fails to load (unreachable host on a closed
#: network, DNS failure, 404) would sit on screen for the whole slot
#: instead of being skipped.
@@ -281,24 +278,17 @@ class WeblinkAdapter:
class WebInputSources:
"""Raw input devices + a global pointer-position tap.
"""Raw input devices, used to detect viewer interaction with a web page.
Two independent sources of "the viewer is interacting", because neither is
sufficient on its own:
* ``/dev/input/event*`` (Linux/Pi) catches touch on every engine, but
needs read permission.
* ``GetCursorPos`` (Windows) catches touchscreen *and* mouse pointer
movement on the engine that renders inside the Kivy window (CEF), where
no child process exists to attribute events to, and where input may be
owned by a different process.
``/dev/input/event*`` (Linux/Raspberry Pi) catches touch, mouse and
keyboard activity on every browser engine, but needs read permission
(the player's user must be in the ``input`` group).
Only *change* counts: holding a finger still or resting the cursor is idle.
"""
def __init__(self):
self._devices = []
self._last_pointer = None
# ── Device discovery ─────────────────────────────────────────────
def open_devices(self):
@@ -341,7 +331,7 @@ class WebInputSources:
"""Wait up to ``timeout`` for raw input. Returns the readable fds.
With no devices open this must still consume the timeout, otherwise the
caller's loop would spin (the Windows/CEF case has no ``/dev/input``).
caller's loop would spin.
"""
if not self._devices:
if timeout > 0:
@@ -368,32 +358,6 @@ class WebInputSources:
self.drop_device(fd)
return seen
# ── Pointer tap (works for CEF / Windows) ────────────────────────
def pointer_moved(self):
"""True when the pointer/touch position changed since the last call.
Uses ``GetCursorPos`` so it works without a child process and without
reading ``/dev/input``. Returns False on non-Windows platforms.
"""
if os.name != 'nt':
return False
try:
import ctypes
from ctypes import wintypes
class _POINT(ctypes.Structure):
_fields_ = [('x', wintypes.LONG), ('y', wintypes.LONG)]
pt = _POINT()
if not ctypes.windll.user32.GetCursorPos(ctypes.byref(pt)):
return False
current = (pt.x, pt.y)
moved = self._last_pointer is not None and current != self._last_pointer
self._last_pointer = current
return moved
except Exception:
return False
class InteractionWatcher(threading.Thread):
"""Decides when a web-link item is finished, based on viewer interaction.
@@ -419,7 +383,7 @@ class InteractionWatcher(threading.Thread):
def __init__(self, duration, alive_check=None, on_idle=None, on_failed=None,
stop_event=None, min_alive_before_exit_advance=0.0, max_dwell=None,
interaction_postpone=0.0, interaction_debounce=0.5,
interaction_grace=1.0, use_pointer=None, embedded=False,
interaction_grace=1.0, embedded=False,
wait_visible=None, visible_timeout=15.0, launched_at=None,
name='weblink-interaction'):
super().__init__(daemon=True, name=name)
@@ -437,11 +401,6 @@ class InteractionWatcher(threading.Thread):
self._wait_visible = wait_visible
self._visible_timeout = float(visible_timeout)
self._launched_at = launched_at
# A global pointer tap is only needed when no process can be attributed
# (embedded CEF); on a plain subprocess the raw devices are enough.
if use_pointer is None:
use_pointer = self._embedded
self._use_pointer = bool(use_pointer)
self._fired = False
self._lock = threading.Lock()
@@ -496,16 +455,17 @@ class InteractionWatcher(threading.Thread):
hard_deadline = started + self._max_dwell if self._max_dwell else None
self._input.open_devices()
if not self._input.devices and not self._use_pointer:
if not self._input.devices:
Logger.warning(
"SignagePlayer: Web link interaction watcher — no /dev/input "
f"devices accessible; using a fixed {self.duration:.0f}s timer"
f"devices accessible; using a fixed {self.duration:.0f}s timer. "
"Add the player's user to the 'input' group to enable "
"interaction-based postponement."
)
else:
Logger.info(
f"SignagePlayer: Web link interaction watcher on "
f"{len(self._input.devices)} input device(s)"
+ (" + pointer tap" if self._use_pointer else "")
+ f"; base {self.duration:.0f}s, +{self._postpone:.0f}s per interaction"
)
@@ -542,8 +502,6 @@ class InteractionWatcher(threading.Thread):
timeout = self._next_poll_timeout(now, deadline, hard_deadline)
activity = self._input.drain(self._input.select(timeout))
if self._use_pointer and self._input.pointer_moved():
activity = True
if activity:
self._register_interaction(time.monotonic())
@@ -680,8 +638,7 @@ class InteractionWatcher(threading.Thread):
class ChromiumSubprocessAdapter(WeblinkAdapter):
"""Default adapter: a separate Chromium/Chrome process in kiosk mode.
Used on Linux/Raspberry Pi, and as the fallback engine on Windows when the
embedded CEF browser is unavailable.
Used on Linux/Raspberry Pi, and by platform subclasses elsewhere.
"""
name = 'chromium-subprocess'
@@ -701,7 +658,7 @@ class ChromiumSubprocessAdapter(WeblinkAdapter):
@staticmethod
def find_browser():
for candidate in ('chromium-browser', 'chromium', 'google-chrome',
'chrome', 'msedge'):
'chrome'):
path = shutil.which(candidate)
if path:
return path
@@ -715,14 +672,35 @@ class ChromiumSubprocessAdapter(WeblinkAdapter):
def extra_launch_args(self):
"""Extra flags appended to the browser command line.
Subclasses override this instead of duplicating ``launch()``. The
Windows adapter uses it to inject a dedicated ``--user-data-dir``,
which is mandatory there: without it Chrome/Edge hands the URL to an
already-running instance, the process we launched exits immediately
and the weblink never becomes visible.
Subclasses override this instead of duplicating ``launch()``. A platform
adapter uses it to inject a dedicated ``--user-data-dir``, which is
mandatory on Chromium: without it the browser hands the URL to an
already-running instance, the process we launched exits immediately and
the weblink never becomes visible.
"""
return ()
def launch_env(self):
"""Environment for the browser process, or None to inherit.
A separate hook from :meth:`extra_launch_args` because some platform
requirements cannot be expressed as command-line flags. On Linux the
decisive example is the **keyring prompt**: disconnecting the browser
from the D-Bus session bus is what actually stops it asking for a
password (see ``linux/linux_browser.py::_browser_env``).
"""
return None
def start_new_session(self):
"""Whether the browser should get its own process group.
Required for the process-group teardown on Linux: without it the
browser shares the player's group, ``os.killpg`` cannot be used, and
Chromium's GPU/zygote/renderer children survive teardown and accumulate
over a 24/7 playlist.
"""
return False
def launch(self, url, width, height):
browser = self._browser or self.find_browser()
if not browser:
@@ -755,7 +733,15 @@ class ChromiumSubprocessAdapter(WeblinkAdapter):
args += self._extra_flags
args += [str(arg) for arg in self.extra_launch_args()]
self._proc = subprocess.Popen(args)
try:
self._proc = subprocess.Popen(
args,
env=self.launch_env(),
start_new_session=self.start_new_session(),
)
except Exception as exc:
Logger.error(f"SignagePlayer: failed to launch browser: {exc}")
return False
return True
def is_alive(self):
@@ -768,7 +754,7 @@ class ChromiumSubprocessAdapter(WeblinkAdapter):
confirms the process survived the health grace period. That catches the
realistic failure modes (missing binary, instant crash, hand-off to a
leaked instance). Platform layers override this to also check the real
window handle (see the Windows adapter).
window handle (see linux/linux_browser.py).
"""
if self._proc is None:
return False, 'no process'
@@ -978,7 +964,7 @@ class WeblinkSession:
if self._watcher is not None:
self._watcher.stop()
# An embedded engine normally cannot be verified (CEF), but some can
# (WebView2) — for those the visibility wait must still run,
# (an embedded engine) — for those the visibility wait must still run,
# otherwise a page that never loads is shown as a blank screen for
# the whole duration instead of being skipped.
verify = adapter.wait_visible if (
+11 -4
View File
@@ -241,10 +241,17 @@ while true; do
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
source "$SCRIPT_DIR/.venv/bin/activate"
fi
# Start the player
cd "$SCRIPT_DIR/src"
python3 main.py &
# Start the player.
#
# NOTE: the entry point is linux/run_linux.py, NOT src/main.py. Running
# main.py directly skips the Raspberry Pi platform patches (Wayland session
# environment, display keep-awake, Chromium kiosk adapter), which is why
# the player used to blank after 10 minutes and fail to show web links.
# Run from the project root so the data directory is the repo root and the
# relative config/media/playlists paths resolve as the app expects.
cd "$SCRIPT_DIR"
python3 linux/run_linux.py &
PLAYER_PID=$!
log_message "Player PID: $PLAYER_PID"
+4 -2
View File
@@ -15,15 +15,17 @@ echo "Stopping watchdog..."
pkill -f "bash.*start.sh"
# Kill player
# NOTE: match the linux/run_linux.py entry point, not "python3 main.py" — the
# player is started as `python3 linux/run_linux.py` from the project root.
echo "Stopping player..."
pkill -f "python3 main.py"
pkill -f "run_linux.py"
# Give processes time to exit gracefully
sleep 2
# Force kill if still running
pkill -9 -f "bash.*start.sh" 2>/dev/null
pkill -9 -f "python3 main.py" 2>/dev/null
pkill -9 -f "run_linux.py" 2>/dev/null
# Clean up heartbeat and stop flag files
rm -f "$SCRIPT_DIR/.player_heartbeat"
-165
View File
@@ -1,165 +0,0 @@
#!/usr/bin/env python3
"""
Test script to diagnose edited media upload issues
Run this to test if the server endpoint exists and works correctly
"""
import json
import os
import sys
import requests
from pathlib import Path
# Add src to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
def test_upload():
"""Test the edited media upload functionality"""
print("\n" + "="*60)
print("EDITED MEDIA UPLOAD DIAGNOSTICS")
print("="*60)
# Get authentication
try:
from get_playlists_v2 import get_auth_instance
auth = get_auth_instance()
if not auth or not auth.is_authenticated():
print("❌ ERROR: Not authenticated!")
print(" Please ensure player_auth.json exists and is valid")
return False
server_url = auth.auth_data.get('server_url')
auth_code = auth.auth_data.get('auth_code')
print(f"\n✓ Authentication successful")
print(f" Server URL: {server_url}")
print(f" Auth Code: {auth_code[:20]}..." if auth_code else " Auth Code: None")
except Exception as e:
print(f"❌ Authentication error: {e}")
return False
# Check for edited media files
edited_media_dir = os.path.join(os.path.dirname(__file__), 'media', 'edited_media')
edited_files = list(Path(edited_media_dir).glob('*_e_v*.jpg'))
metadata_files = list(Path(edited_media_dir).glob('*_metadata.json'))
print(f"\n✓ Edited Media Directory: {edited_media_dir}")
print(f" Edited images: {len(edited_files)}")
print(f" Metadata files: {len(metadata_files)}")
if not edited_files:
print("\n⚠️ No edited images found!")
print(" Create an edit first, then run this test")
return False
# Test with the first edited image
image_path = str(edited_files[0])
metadata_file = str(edited_files[0]).replace('.jpg', '_metadata.json')
if not os.path.exists(metadata_file):
print(f"\n❌ Metadata file not found: {metadata_file}")
return False
print(f"\nTesting upload with:")
print(f" Image: {os.path.basename(image_path)}")
print(f" Size: {os.path.getsize(image_path):,} bytes")
print(f" Metadata: {os.path.basename(metadata_file)}")
# Load and display metadata
with open(metadata_file, 'r') as f:
metadata = json.load(f)
print(f"\nMetadata content:")
for key, value in metadata.items():
print(f" {key}: {value}")
# Add original_filename if not present
metadata['original_filename'] = os.path.basename(metadata['original_path'])
# Prepare upload request
upload_url = f"{server_url}/api/player-edit-media"
headers = {'Authorization': f'Bearer {auth_code}'}
print(f"\n{'='*60}")
print("TESTING UPLOAD...")
print(f"{'='*60}")
print(f"Endpoint: {upload_url}")
print(f"Headers: Authorization: Bearer {auth_code[:20]}...")
try:
with open(image_path, 'rb') as img_file:
files = {
'image_file': (metadata['original_filename'], img_file, 'image/jpeg')
}
data = {
'metadata': json.dumps(metadata),
'original_file': metadata['original_filename']
}
print(f"\nSending request (30s timeout, SSL verify=False)...")
response = requests.post(
upload_url,
headers=headers,
files=files,
data=data,
timeout=30,
verify=False
)
print(f"\n✓ Response received!")
print(f" Status Code: {response.status_code}")
print(f" Headers: {dict(response.headers)}")
if response.status_code == 200:
print(f"\n✅ SUCCESS! Server accepted the upload")
print(f" Response: {response.json()}")
return True
elif response.status_code == 404:
print(f"\n❌ ENDPOINT NOT FOUND (404)")
print(f" The server does NOT have /api/player-edit-media endpoint")
print(f" Server may need to implement this feature")
elif response.status_code == 401:
print(f"\n❌ AUTHENTICATION FAILED (401)")
print(f" Check your auth_code in player_auth.json")
else:
print(f"\n❌ REQUEST FAILED (Status: {response.status_code})")
print(f" Response: {response.text}")
return False
except requests.exceptions.ConnectionError as e:
print(f"\n❌ CONNECTION ERROR")
print(f" Cannot reach server at {server_url}")
print(f" Error: {e}")
return False
except requests.exceptions.Timeout as e:
print(f"\n❌ TIMEOUT")
print(f" Server did not respond within 30 seconds")
print(f" Error: {e}")
return False
except requests.exceptions.SSLError as e:
print(f"\n❌ SSL ERROR")
print(f" Error: {e}")
print(f" Tip: Try adding verify=False to requests")
return False
except Exception as e:
print(f"\n❌ UNEXPECTED ERROR")
print(f" Error: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == '__main__':
success = test_upload()
print(f"\n{'='*60}")
if success:
print("✅ UPLOAD TEST PASSED - Server accepts edited media!")
else:
print("❌ UPLOAD TEST FAILED - See details above")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
-34
View File
@@ -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)
-286
View File
@@ -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)
```
-18
View File
@@ -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

-317
View File
@@ -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'
-369
View File
@@ -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',
)
-5
View File
@@ -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
-158
View File
@@ -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
-192
View File
@@ -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)
-110
View File
@@ -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
-317
View File
@@ -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
-27
View File
@@ -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
-169
View File
@@ -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
-51
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-156
View File
@@ -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
}
-48
View File
@@ -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%
-111
View File
@@ -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())
-39
View File
@@ -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)
-139
View File
@@ -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())
-438
View File
@@ -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())
-169
View File
@@ -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())
-186
View File
@@ -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())
-221
View File
@@ -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())
-106
View File
@@ -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())
-89
View File
@@ -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()
-43
View File
@@ -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])])
]
)
-441
View File
@@ -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.'
-718
View File
@@ -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)'
-446
View File
@@ -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}')
@@ -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.
-720
View File
@@ -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.")
-182
View File
@@ -1,182 +0,0 @@
# USB Card Reader Authentication
This document describes the USB card reader authentication feature for the Kiwy Signage Player.
## Overview
The player now supports user authentication via USB card readers when accessing the edit/drawing interface. When a user clicks the edit button (pencil icon), they must swipe their card to authenticate before being allowed to edit the image.
## How It Works
1. **Edit Button Click**: User clicks the pencil icon to edit the current image
2. **Validation Checks**:
- Verify current media is an image (not video)
- Check if editing is allowed for this media (`edit_on_player` permission from server)
3. **Card Reader Prompt**:
- Display "Please swipe your card..." message
- Wait for card swipe (5 second timeout)
- Read card data from USB card reader
- Store the card data (no validation required)
4. **Open Edit Interface**: Edit interface opens with card data stored
5. **Save & Upload**: When user saves the edited image:
- Card data is included in the metadata JSON
- Both image and metadata (with card data) are uploaded to server
- Server receives `user_card_data` field for tracking who edited the image
## Card Reader Setup
### Hardware Requirements
- USB card reader (HID/keyboard emulation type)
- Compatible cards (magnetic stripe or RFID depending on reader)
### Software Requirements
The player requires the `evdev` Python library to interface with USB input devices:
```bash
# Install via apt (recommended for Raspberry Pi)
sudo apt-get install python3-evdev
# Or via pip
pip3 install evdev
```
### Fallback Mode
If `evdev` is not available, the player will:
- Log a warning message
- Use a default card value (`DEFAULT_USER_12345`) for testing
- This allows development and testing without hardware
## Card Data Storage
The card data is captured as a raw string and stored without validation or mapping:
- **No preprocessing**: Card data is stored exactly as received from the reader
- **Format**: Whatever the card reader sends (typically numeric or alphanumeric)
- **Sent to server**: Raw card data is included in the `user_card_data` field of the metadata JSON
- **Server-side processing**: The server can validate, map, or process the card data as needed
### Metadata JSON Format
When an image is saved, the metadata includes:
```json
{
"time_of_modification": "2025-12-08T10:30:00",
"original_name": "image.jpg",
"new_name": "image_e_v1.jpg",
"original_path": "/path/to/image.jpg",
"version": 1,
"user_card_data": "123456789"
}
```
If no card is swiped (timeout), `user_card_data` will be `null`.
## Testing the Card Reader
A test utility is provided to verify card reader functionality:
```bash
cd /home/pi/Desktop/Kiwy-Signage/working_files
python3 test_card_reader.py
```
The test tool will:
1. List all available input devices
2. Auto-detect the card reader (or let you select manually)
3. Listen for card swipes and display the data received
4. Show how the data will be processed
### Test Output Example
```
✓ Card data received: '123456789'
Length: 9 characters
Processed ID: card_123456789
```
## Implementation Details
### Main Components
1. **CardReader Class** (`main.py`)
- Handles USB device detection
- Reads input events from card reader
- Provides async callback interface
- Includes timeout handling (5 seconds)
2. **Card Read Flow** (`show_edit_interface()` method)
- Validates media type and permissions
- Initiates card read
- Stores raw card data
- Opens edit popup
3. **Metadata Creation** (`_save_metadata()` method)
- Includes card data in metadata JSON
- No processing or validation of card data
- Sent to server as-is
### Card Data Format
Card readers typically send data as keyboard input:
- Each character is sent as a key press event
- Data ends with an ENTER key press
- Reader format: `[CARD_DATA][ENTER]`
The CardReader class:
- Captures key press events
- Builds the card data string character by character
- Completes reading when ENTER is detected
- Returns the complete card data to the callback
### Security Considerations
1. **Server-Side Validation**: Card validation should be implemented on the server
2. **Timeout**: 5-second timeout prevents infinite waiting for card swipe
3. **Logging**: All card reads are logged with the raw card data
4. **Permissions**: Edit permission must be enabled on the server (`edit_on_player`)
5. **Raw Data**: Card data is sent as-is; server is responsible for validation and authorization
## Troubleshooting
### Card Reader Not Detected
- Check USB connection
- Run `ls /dev/input/` to see available devices
- Run the test script to verify detection
- Check `evdev` is installed: `python3 -c "import evdev"`
### Card Swipes Not Recognized
- Verify card reader sends keyboard events
- Test with the `test_card_reader.py` utility
- Check card format is compatible with reader
- Ensure card is swiped smoothly at proper speed
### Card Data Not Captured
- Check card data format in logs
- Enable debug logging to see raw card data
- Test in fallback mode (without evdev) to isolate hardware issues
- Verify card swipe completes within 5-second timeout
### Permission Denied Errors
- User may need to be in the `input` group:
```bash
sudo usermod -a -G input $USER
```
- Reboot after adding user to group
## Future Enhancements
Potential improvements for the card reader system:
1. **Server Validation**: Server validates cards against database and returns authorization
2. **Card Enrollment**: Server-side UI for registering new cards
3. **Multiple Card Types**: Support for different card formats (barcode, RFID, magnetic)
4. **Client-side Validation**: Add optional local card validation before opening edit
5. **Audit Trail**: Server tracks all card usage with timestamps
6. **RFID Support**: Test and optimize for RFID readers
7. **Barcode Scanners**: Support USB barcode scanners as alternative
8. **Retry Logic**: Allow re-swipe if card read fails
## Related Files
- `/src/main.py` - Main implementation (CardReader class, authentication flow)
- `/src/edit_drowing.py` - Drawing/editing interface (uses authenticated user)
- `/working_files/test_card_reader.py` - Card reader test utility
- `/requirements.txt` - Dependencies (includes evdev)
-170
View File
@@ -1,170 +0,0 @@
# Card Reader Fix - Multi-USB Device Support
## Problem Description
When a USB touchscreen was connected to the Raspberry Pi, the card reader authentication was not working. The system reported "no authentication was received" even though the card reader was physically connected on a different USB port.
### Root Cause
The original `find_card_reader()` function used overly broad matching criteria:
1. It would select the **first** device with "keyboard" in its name
2. USB touchscreens often register as HID keyboard devices (for touch input)
3. The touchscreen would be detected first, blocking the actual card reader
4. No exclusion logic existed to filter out touch devices
## Solution
The fix implements a **priority-based device selection** with **exclusion filters**:
### 1. Device Exclusion List
Devices containing these keywords are now skipped:
- `touch`, `touchscreen`
- `mouse`, `mice`
- `trackpad`, `touchpad`
- `pen`, `stylus`
- `video`, `button`, `lid`
### 2. Three-Priority Device Search
**Priority 1: Explicit Card Readers**
- Devices with "card", "reader", "rfid", or "hid" in their name
- Must have keyboard capabilities (EV_KEY)
- Excludes any device matching exclusion keywords
**Priority 2: USB Keyboards**
- Devices with both "usb" AND "keyboard" in their name
- Card readers typically appear as "USB Keyboard" or similar
- Excludes touch devices and other non-card peripherals
**Priority 3: Fallback to Any Keyboard**
- Any keyboard device not in the exclusion list
- Used only if no card reader or USB keyboard is found
### 3. Enhanced Logging
The system now logs:
- All detected input devices at startup
- Which devices are being skipped and why
- Which device is ultimately selected as the card reader
## Testing
### Using the Test Script
Run the enhanced test script to identify your card reader:
```bash
cd /home/pi/Desktop/Kiwy-Signage/working_files
python3 test_card_reader.py
```
The script will:
1. List all input devices with helpful indicators:
- `** LIKELY CARD READER **` - devices with "card" or "reader" in name
- `(Excluded: ...)` - devices that will be skipped
- `(USB Keyboard - could be card reader)` - potential card readers
2. Auto-detect the card reader using the same logic as the main app
3. Allow manual selection by device number if auto-detection is wrong
### Example Output
```
=== Available Input Devices ===
[0] /dev/input/event0
Name: USB Touchscreen Controller
Phys: usb-0000:01:00.0-1.1/input0
Type: Keyboard/HID Input Device
(Excluded: appears to be touch/mouse/other non-card device)
[1] /dev/input/event1
Name: HID 08ff:0009
Phys: usb-0000:01:00.0-1.2/input0
Type: Keyboard/HID Input Device
** LIKELY CARD READER **
[2] /dev/input/event2
Name: Logitech USB Keyboard
Phys: usb-0000:01:00.0-1.3/input0
Type: Keyboard/HID Input Device
(USB Keyboard - could be card reader)
```
### Verifying the Fix
1. **Check Logs**: When the main app starts, check the logs for device detection:
```bash
tail -f /path/to/logfile
```
Look for messages like:
```
CardReader: Scanning input devices...
CardReader: Skipping excluded device: USB Touchscreen Controller
CardReader: Found card reader: HID 08ff:0009 at /dev/input/event1
```
2. **Test Card Swipe**:
- Start the signage player
- Click the edit button (pencil icon)
- Swipe a card
- Should successfully authenticate
3. **Multiple USB Devices**: Test with various USB configurations:
- Touchscreen + card reader
- Mouse + keyboard + card reader
- Multiple USB hubs
## Configuration
### If Auto-Detection Fails
If the automatic detection still selects the wrong device, you can:
1. **Check device names**: Run `test_card_reader.py` to see all devices
2. **Identify your card reader**: Note the exact name of your card reader
3. **Add custom exclusions**: If needed, add more keywords to the exclusion list
4. **Manual override**: Modify the priority logic to match your specific hardware
### Permissions
Ensure the user running the app has permission to access input devices:
```bash
# Add user to input group
sudo usermod -a -G input $USER
# Logout and login again for changes to take effect
```
## Files Modified
1. **src/main.py**
- Updated `CardReader.find_card_reader()` method
- Added exclusion keyword list
- Implemented priority-based search
- Enhanced logging
2. **working_files/test_card_reader.py**
- Updated `list_input_devices()` to show device classifications
- Updated `test_card_reader()` to use same logic as main app
- Added visual indicators for device types
## Compatibility
This fix is backward compatible:
- Works with single-device setups (no touchscreen)
- Works with multiple USB devices
- Fallback behavior unchanged for systems without card readers
- No changes to card data format or server communication
## Future Enhancements
Potential improvements for specific use cases:
1. **Configuration file**: Allow specifying device path or name pattern
2. **Device caching**: Remember the working device path to avoid re-scanning
3. **Hot-plug support**: Detect when card reader is plugged in after app starts
4. **Multi-reader support**: Support for multiple card readers simultaneously
-211
View File
@@ -1,211 +0,0 @@
# Debugging Media File Skips - Guide
## Summary
Your playlist has been analyzed and all 3 media files are present and valid:
- ✅ music.jpg (36,481 bytes) - IMAGE - 15s
- ✅ 130414-746934884.mp4 (6,474,921 bytes) - VIDEO - 23s
- ✅ IMG_0386.jpeg (592,162 bytes) - IMAGE - 15s
## Enhanced Logging Added
The application has been updated with detailed logging to track:
- When each media file starts playing
- File path validation
- File size and existence checks
- Media type detection
- Widget creation steps
- Scheduling of next media
- Any errors or skips
## How to See Detailed Logs
### Method 1: Run with log output
```bash
cd /home/pi/Desktop/Kiwy-Signage
source .venv/bin/activate
cd src
python3 main.py 2>&1 | tee playback.log
```
### Method 2: Check Kivy logs location
Kivy logs are typically stored in:
- Linux: `~/.kivy/logs/`
- Check with: `ls -lth ~/.kivy/logs/ | head`
## Common Reasons Media Files Get Skipped
### 1. **File Not Found**
**Symptom**: Log shows "❌ Media file not found"
**Cause**: File doesn't exist at expected path
**Solution**: Run diagnostic tool
```bash
python3 diagnose_playlist.py
```
### 2. **Unsupported File Type**
**Symptom**: Log shows "❌ Unsupported media type"
**Supported formats**:
- Videos: .mp4, .avi, .mkv, .mov, .webm
- Images: .jpg, .jpeg, .png, .bmp, .gif
**Solution**: Convert files or check extension
### 3. **Video Codec Issues**
**Symptom**: Video file exists but doesn't play
**Cause**: Video codec not supported by ffpyplayer
**Check**: Look for error in logs about codec
**Solution**: Re-encode video with H.264 codec:
```bash
ffmpeg -i input.mp4 -c:v libx264 -preset fast -crf 23 output.mp4
```
### 4. **Corrupted Media Files**
**Symptom**: File exists but throws error when loading
**Check**: Try playing file with external player
```bash
# For images
feh media/music.jpg
# For videos
vlc media/130414-746934884.mp4
# or
ffplay media/130414-746934884.mp4
```
### 5. **Memory/Performance Issues**
**Symptom**: First few files play, then skipping increases
**Cause**: Memory leak or performance degradation
**Check**: Look for "consecutive_errors" in logs
**Solution**:
- Reduce resolution setting in settings popup
- Optimize video files (lower bitrate/resolution)
### 6. **Timing Issues**
**Symptom**: Files play too fast or skip immediately
**Cause**: Duration set too low or scheduler issues
**Check**: Verify durations in playlist.json
**Current durations**: 15s (images), 23s (video)
### 7. **Permission Issues**
**Symptom**: "Permission denied" in logs
**Check**: File permissions
```bash
ls -la media/
```
**Solution**: Fix permissions
```bash
chmod 644 media/*
```
## What to Look For in Logs
### Successful Playback Pattern:
```
SignagePlayer: ===== Playing item 1/3 =====
SignagePlayer: File: music.jpg
SignagePlayer: Duration: 15s
SignagePlayer: Full path: /path/to/media/music.jpg
SignagePlayer: ✓ File exists (size: 36,481 bytes)
SignagePlayer: Extension: .jpg
SignagePlayer: Media type: IMAGE
SignagePlayer: Creating AsyncImage widget...
SignagePlayer: Adding image widget to content area...
SignagePlayer: Scheduled next media in 15s
SignagePlayer: ✓ Image displayed successfully
SignagePlayer: ✓ Media started successfully (consecutive_errors reset to 0)
```
### Skip Pattern (File Not Found):
```
SignagePlayer: ===== Playing item 2/3 =====
SignagePlayer: File: missing.mp4
SignagePlayer: Full path: /path/to/media/missing.mp4
SignagePlayer: ❌ Media file not found: /path/to/media/missing.mp4
SignagePlayer: Skipping to next media...
SignagePlayer: Transitioning to next media (was index 1)
```
### Video Loading Error:
```
SignagePlayer: Loading video file.mp4 for 23s
SignagePlayer: Video provider: ffpyplayer
[ERROR ] [Video ] Error reading video
[ERROR ] SignagePlayer: Error playing video: ...
```
## Testing Tools Provided
### 1. Diagnostic Tool
```bash
python3 diagnose_playlist.py
```
Checks:
- Playlist file exists and is valid
- All media files exist
- File types are supported
- No case sensitivity issues
### 2. Playback Simulation
```bash
python3 test_playback_logging.py
```
Simulates the playback sequence without running the GUI
## Monitoring Live Playback
To see live logs while the app is running:
```bash
# Terminal 1: Start the app
./run_player.sh
# Terminal 2: Monitor logs
tail -f ~/.kivy/logs/kivy_*.txt
```
## Quick Fixes to Try
### 1. Clear any stuck state
```bash
rm -f src/*.pyc
rm -rf src/__pycache__
```
### 2. Test with simpler playlist
Create `playlists/test_playlist_v9.json`:
```json
{
"playlist": [
{
"file_name": "music.jpg",
"url": "media/music.jpg",
"duration": 5
}
],
"version": 9
}
```
### 3. Check video compatibility
```bash
# Install ffmpeg tools if not present
sudo apt-get install ffmpeg
# Check video info
ffprobe media/130414-746934884.mp4
```
## Getting Help
When reporting issues, please provide:
1. Output from `python3 diagnose_playlist.py`
2. Last 100 lines of Kivy log file
3. Any error messages from console
4. What you observe (which files skip? pattern?)
## Next Steps
1. **Run the app** and observe the console output
2. **Check logs** for error patterns
3. **Run diagnostic** if files are skipping
4. **Test individual files** with external players if needed
5. **Re-encode videos** if codec issues found
The enhanced logging will now tell you exactly why each file is being skipped!
-263
View File
@@ -1,263 +0,0 @@
# ✅ Kiwy-Signage Authentication Implementation Complete
## What Was Implemented
The Kiwy-Signage player now supports **secure authentication** with DigiServer v2 using the flow:
**hostname → password/quickconnect → get auth_code → use auth_code for API calls**
## Files Created
### 1. **Kiwy-Signage/src/player_auth.py**
- Complete authentication module
- Handles authentication, token storage, API calls
- Methods:
- `authenticate()` - Initial authentication with server
- `verify_auth()` - Verify saved auth code
- `get_playlist()` - Fetch playlist using auth code
- `send_heartbeat()` - Send status updates
- `send_feedback()` - Send player feedback
- `clear_auth()` - Clear saved credentials
### 2. **Kiwy-Signage/src/get_playlists_v2.py**
- Updated playlist management
- Uses new authentication system
- Backward compatible with existing code
- Functions:
- `ensure_authenticated()` - Auto-authenticate if needed
- `fetch_server_playlist()` - Get playlist via authenticated API
- `send_player_feedback()` - Send feedback with auth
- All existing functions updated to use auth
### 3. **Kiwy-Signage/test_authentication.py**
- Test script to verify authentication
- Run before updating main.py
- Tests:
- Server connectivity
- Authentication flow
- Playlist fetch
- Heartbeat sending
### 4. **Kiwy-Signage/MIGRATION_GUIDE.md**
- Complete migration instructions
- Troubleshooting guide
- Configuration examples
- Rollback procedures
### 5. **digiserver-v2/player_auth_module.py**
- Standalone authentication module
- Can be used in any Python project
- Same functionality as Kiwy-Signage version
### 6. **digiserver-v2/PLAYER_AUTH.md**
- Complete API documentation
- Authentication endpoint specs
- Configuration file formats
- Security considerations
## Testing Steps
### 1. Test Authentication (Recommended First)
```bash
cd /home/pi/Desktop/Kiwy-Signage
python3 test_authentication.py
```
**Expected output:**
```
✅ Server is healthy (version: 2.0.0)
🔐 Authenticating with server...
✅ Authentication successful!
Player: Demo Player
📋 Testing playlist fetch...
✅ Playlist received!
💓 Testing heartbeat...
✅ Heartbeat sent successfully
✅ All tests passed! Player is ready to use.
```
### 2. Update Main Player App
In `Kiwy-Signage/src/main.py`, change:
```python
# OLD:
from get_playlists import update_playlist_if_needed, ...
# NEW:
from get_playlists_v2 import update_playlist_if_needed, ...
```
### 3. Run Player
```bash
cd /home/pi/Desktop/Kiwy-Signage/src
python3 main.py
```
## Configuration
### No Changes Needed!
Your existing `app_config.txt` works as-is:
```json
{
"server_ip": "192.168.1.100",
"port": "5000",
"screen_name": "player-001",
"quickconnect_key": "QUICK123",
...
}
```
### Authentication Storage
Auto-created at `src/player_auth.json`:
```json
{
"hostname": "player-001",
"auth_code": "rrX4JtM99e4e6ni0VCsuIstjTVQQqILXeRmGu_Ek2Ks",
"player_id": 1,
"player_name": "Demo Player",
"group_id": 5,
"orientation": "Landscape",
"authenticated": true,
"server_url": "http://192.168.1.100:5000"
}
```
## DigiServer v2 Setup
### 1. Create Player
Via Web UI (http://your-server:5000):
1. Login as admin
2. Go to Players → Add Player
3. Fill in:
- **Name**: Display name
- **Hostname**: player-001 (must match `screen_name` in config)
- **Password**: (optional, use quickconnect instead)
- **Quick Connect Code**: QUICK123 (must match `quickconnect_key`)
- **Orientation**: Landscape/Portrait
### 2. Test API Manually
```bash
# Test authentication
curl -X POST http://your-server:5000/api/auth/player \
-H "Content-Type: application/json" \
-d '{
"hostname": "player-001",
"quickconnect_code": "QUICK123"
}'
# Expected response:
{
"success": true,
"player_id": 1,
"player_name": "Demo Player",
"auth_code": "rrX4JtM99e4e6ni0VCsuIstjTVQQqILXeRmGu_Ek2Ks",
...
}
```
## Security Features
**Auth Code Storage**: Saved locally, not transmitted after initial auth
**Bcrypt Hashing**: Passwords and quickconnect codes hashed in database
**Token-Based**: Auth codes are 32-byte URL-safe tokens
**Rate Limiting**: Authentication endpoint limited to 10 requests/minute
**Session Management**: Server tracks player sessions and status
## Advantages Over Old System
### Old System (v1)
```
Player → [hostname + quickconnect on EVERY request] → Server
Bcrypt verification on every API call (slow)
```
### New System (v2)
```
Player → [hostname + quickconnect ONCE] → Server
Returns auth_code
Player → [auth_code for all subsequent requests] → Server
Fast token validation
```
**Benefits:**
- 🚀 **10x faster API calls** (no bcrypt on every request)
- 🔒 **More secure** (credentials only sent once)
- 📊 **Better tracking** (server knows player sessions)
- 🔄 **Easier management** (can revoke auth codes)
## Troubleshooting
### Authentication Fails
**Check:**
1. Player exists in DigiServer v2 with matching hostname
2. Quickconnect code matches exactly (case-sensitive)
3. Server is accessible: `curl http://server:5000/api/health`
### Auth Code Expired
**Solution:**
```bash
rm /home/pi/Desktop/Kiwy-Signage/src/player_auth.json
# Restart player - will auto-authenticate
```
### Old get_playlists.py Issues
**Keep both files:**
- `get_playlists.py` - Original (for DigiServer v1)
- `get_playlists_v2.py` - New (for DigiServer v2)
Can switch between them by changing import in `main.py`.
## Next Steps
1. ✅ **Test authentication** with `test_authentication.py`
2. ✅ **Update main.py** to use `get_playlists_v2`
3. ✅ **Run player** and verify playlist loading
4. ✅ **Monitor logs** for first 24 hours
5. ✅ **Update other players** one at a time
## Files Summary
```
Kiwy-Signage/
├── src/
│ ├── player_auth.py # ✨ NEW: Authentication module
│ ├── get_playlists_v2.py # ✨ NEW: Updated playlist fetcher
│ ├── get_playlists.py # OLD: Keep for v1 compatibility
│ ├── main.py # Update import to use v2
│ └── player_auth.json # ✨ AUTO-CREATED: Auth storage
├── test_authentication.py # ✨ NEW: Test script
├── MIGRATION_GUIDE.md # ✨ NEW: Migration docs
└── resources/
└── app_config.txt # Existing config (no changes needed)
digiserver-v2/
├── app/
│ ├── models/player.py # ✨ UPDATED: Added auth methods
│ └── blueprints/api.py # ✨ UPDATED: Added auth endpoints
├── player_auth_module.py # ✨ NEW: Standalone module
├── player_config_template.ini # ✨ NEW: Config template
├── PLAYER_AUTH.md # ✨ NEW: API documentation
└── reinit_db.sh # ✨ NEW: Database recreation script
```
---
## 🎉 Implementation Complete!
The Kiwy-Signage player authentication system is now compatible with DigiServer v2 using secure token-based authentication. Test with `test_authentication.py` before deploying to production.
-182
View File
@@ -1,182 +0,0 @@
# Investigation Results: Media File Skipping
## Diagnostic Summary
✅ **All 3 media files are present and valid:**
- music.jpg (36,481 bytes) - IMAGE
- 130414-746934884.mp4 (6,474,921 bytes) - VIDEO (H.264, 1920x1080, compatible)
- IMG_0386.jpeg (592,162 bytes) - IMAGE
✅ **No file system issues found:**
- All files exist
- Correct permissions
- No case sensitivity problems
- Supported file types
✅ **Video codec is compatible:**
- H.264 codec (fully supported by ffpyplayer)
- 1920x1080 @ 29.97fps
- Reasonable bitrate (2.3 Mbps)
## Potential Root Causes Identified
### 1. **Video Widget Not Properly Stopping** (Most Likely)
When transitioning from video to the next media, the video widget may not be properly stopped before removal. This could cause:
- The video to continue playing in background
- Race conditions with scheduling
- Next media appearing to "skip"
**Location**: `play_current_media()` line 417-420
```python
if self.current_widget:
self.ids.content_area.remove_widget(self.current_widget)
self.current_widget = None
```
**Fix**: Stop video before removing widget
### 2. **Multiple Scheduled Events**
The `Clock.schedule_once(self.next_media, duration)` could be called multiple times if widget loading triggers multiple events.
**Location**: Lines 510, 548
**Fix**: Add `Clock.unschedule()` before scheduling
### 3. **Video Loading Callback Issues**
The video `loaded` callback might not fire or might fire multiple times, causing state confusion.
**Location**: `_on_video_loaded()` line 516
### 4. **Pause State Not Properly Checked**
If the player gets paused/unpaused during media transition, scheduling could get confused.
**Location**: `next_media()` line 551
## What Enhanced Logging Will Show
With the new logging, you'll see patterns like:
### If Videos Are Being Skipped:
```
===== Playing item 2/3 =====
File: 130414-746934884.mp4
Extension: .mp4
Media type: VIDEO
Loading video...
Creating Video widget...
[SHORT PAUSE OR ERROR]
Transitioning to next media (was index 1)
===== Playing item 3/3 =====
```
### If Duration Is Too Short:
```
Creating Video widget...
Scheduled next media in 23s
[Only 1-2 seconds pass]
Transitioning to next media
```
## Recommended Fixes
I've added comprehensive logging. Here are additional fixes to try:
### Fix 1: Properly Stop Video Widget Before Removal
Add this to `play_current_media()` before removing widget:
```python
# Remove previous media widget
if self.current_widget:
# Stop video if it's playing
if isinstance(self.current_widget, Video):
self.current_widget.state = 'stop'
self.current_widget.unload()
self.ids.content_area.remove_widget(self.current_widget)
self.current_widget = None
```
### Fix 2: Ensure Scheduled Events Don't Overlap
Modify scheduling in both `play_video()` and `play_image()`:
```python
# Unschedule any pending transitions before scheduling new one
Clock.unschedule(self.next_media)
Clock.schedule_once(self.next_media, duration)
```
### Fix 3: Add Video State Monitoring
Track when video actually starts playing vs when widget is created.
## How to Test
### 1. Run with Enhanced Logging
```bash
cd /home/pi/Desktop/Kiwy-Signage
source .venv/bin/activate
cd src
python3 main.py 2>&1 | tee ../playback_debug.log
```
Watch the console output. You should see:
- Each media file being loaded
- Timing information
- Any errors or skips
### 2. Check Timing
If media skips, check the log for timing:
- Does "Scheduled next media in Xs" appear?
- How long until "Transitioning to next media" appears?
- Is it immediate (< 1 second) = scheduling bug
- Is it after full duration = normal operation
### 3. Look for Error Patterns
Search the log for:
```bash
grep "❌" playback_debug.log
grep "Error" playback_debug.log
grep "consecutive_errors" playback_debug.log
```
## Quick Test Scenario
Create a test with just one file to isolate the issue:
```json
{
"playlist": [
{
"file_name": "music.jpg",
"url": "media/music.jpg",
"duration": 10
}
],
"version": 99
}
```
If this single image repeats correctly every 10s, the issue is with video playback or transitions.
## What to Report
When you run the app, please capture:
1. **Console output** - especially the pattern around skipped files
2. **Which files skip?** - Is it always videos? Always after videos?
3. **Timing** - Do files play for full duration before skipping?
4. **Pattern** - First loop OK then skips? Always skips certain file?
## Tools Created
1. **diagnose_playlist.py** - Check file system issues
2. **test_playback_logging.py** - Simulate playback logic
3. **check_video_codecs.py** - Verify video compatibility
4. **Enhanced main.py** - Detailed logging throughout
## Next Actions
1. ✅ Run `diagnose_playlist.py` - **PASSED**
2. ✅ Run `check_video_codecs.py` - **PASSED**
3. ⏳ Run app with logging and observe pattern
4. ⏳ Apply video widget fixes if needed
5. ⏳ Report findings for further diagnosis
The enhanced logging will pinpoint exactly where and why files are being skipped!
-276
View File
@@ -1,276 +0,0 @@
# Kiwy-Signage Player Migration Guide
## Updating to DigiServer v2 Authentication
This guide explains how to update your Kiwy-Signage player to use the new secure authentication system with DigiServer v2.
## What Changed?
### Old System (v1)
- Direct API calls with hostname + quickconnect code on every request
- No persistent authentication
- Credentials sent with every API call
### New System (v2)
- **Step 1**: Authenticate once with hostname + password/quickconnect
- **Step 2**: Receive and save auth_code
- **Step 3**: Use auth_code for all subsequent API calls
- **Benefits**: More secure, faster, supports session management
## Migration Steps
### 1. Copy New Files
Copy the authentication modules to your Kiwy-Signage project:
```bash
cd /home/pi/Desktop/Kiwy-Signage/src/
# New files are already created:
# - player_auth.py (authentication module)
# - get_playlists_v2.py (updated playlist fetcher)
```
### 2. Update main.py Imports
In `main.py`, replace the old import:
```python
# OLD:
from get_playlists import (
update_playlist_if_needed,
send_playing_status_feedback,
send_playlist_restart_feedback,
send_player_error_feedback
)
# NEW:
from get_playlists_v2 import (
update_playlist_if_needed,
send_playing_status_feedback,
send_playlist_restart_feedback,
send_player_error_feedback
)
```
### 3. Add Authentication on Startup
In `main.py`, add authentication check in the `SignagePlayer` class:
```python
def build(self):
"""Build the application UI"""
# Load configuration
self.config = self.load_config()
# NEW: Authenticate with server
from player_auth import PlayerAuth
auth = PlayerAuth()
if not auth.is_authenticated():
Logger.info("First time setup - authenticating...")
from get_playlists_v2 import ensure_authenticated
if not ensure_authenticated(self.config):
Logger.error("❌ Failed to authenticate with server!")
# Show error popup or retry
else:
Logger.info(f"✅ Authenticated as: {auth.get_player_name()}")
# Continue with normal startup...
return SignagePlayerWidget(config=self.config)
```
### 4. Update Server Configuration
Your existing `app_config.txt` works as-is! The new system uses the same fields:
```json
{
"server_ip": "your-server-ip",
"port": "5000",
"screen_name": "player-001",
"quickconnect_key": "QUICK123",
...
}
```
**Note**: `screen_name` is now used as `hostname` for authentication.
### 5. Testing
1. **Stop the old player**:
```bash
pkill -f main.py
```
2. **Delete old authentication data** (first time only):
```bash
rm -f /home/pi/Desktop/Kiwy-Signage/src/player_auth.json
```
3. **Start the updated player**:
```bash
cd /home/pi/Desktop/Kiwy-Signage/src/
python3 main.py
```
4. **Check logs for authentication**:
- Look for: `✅ Authentication successful`
- Or: `❌ Authentication failed: [error message]`
## Troubleshooting
### Authentication Fails
**Problem**: `❌ Authentication failed: Invalid credentials`
**Solution**:
1. Verify player exists in DigiServer v2:
- Login to http://your-server:5000
- Go to Players → check if hostname exists
2. Verify quickconnect code:
- In DigiServer, check player's Quick Connect Code
- Update `app_config.txt` with correct code
3. Check server URL:
```python
# Test connection
import requests
response = requests.get('http://your-server:5000/api/health')
print(response.json()) # Should show: {'status': 'healthy'}
```
### Auth Code Expired
**Problem**: Player was working, now shows auth errors
**Solution**:
```bash
# Clear saved auth and re-authenticate
rm /home/pi/Desktop/Kiwy-Signage/src/player_auth.json
# Restart player - will auto-authenticate
```
### Can't Connect to Server
**Problem**: `Cannot connect to server`
**Solution**:
1. Check server is running:
```bash
curl http://your-server:5000/api/health
```
2. Check network connectivity:
```bash
ping your-server-ip
```
3. Verify server URL in `app_config.txt`
## Configuration Files
### player_auth.json (auto-created)
This file stores the authentication token:
```json
{
"hostname": "player-001",
"auth_code": "rrX4JtM99e4e6ni0VCsuIstjTVQQqILXeRmGu_Ek2Ks",
"player_id": 1,
"player_name": "Demo Player",
"group_id": 5,
"orientation": "Landscape",
"authenticated": true,
"server_url": "http://your-server:5000"
}
```
**Important**: Keep this file secure! It contains your player's access token.
## Advanced: Custom Authentication
If you need custom authentication logic:
```python
from player_auth import PlayerAuth
# Initialize
auth = PlayerAuth(config_file='custom_auth.json')
# Authenticate with password instead of quickconnect
success, error = auth.authenticate(
server_url='http://your-server:5000',
hostname='player-001',
password='your_secure_password' # Use password instead
)
if success:
print(f"✅ Authenticated as: {auth.get_player_name()}")
# Get playlist
playlist_data = auth.get_playlist()
# Send heartbeat
auth.send_heartbeat(status='playing')
# Send feedback
auth.send_feedback(
message="Playing video.mp4",
status="playing",
playlist_version=5
)
else:
print(f"❌ Failed: {error}")
```
## Rollback to Old System
If you need to rollback:
```bash
cd /home/pi/Desktop/Kiwy-Signage/src/
# Rename new files
mv get_playlists_v2.py get_playlists_v2.py.backup
mv player_auth.py player_auth.py.backup
# Use old get_playlists.py (keep as-is)
# Old system will continue working with DigiServer v1
```
## Benefits of New System
**More Secure**: Auth tokens instead of passwords in every request
**Better Performance**: No bcrypt verification on every API call
**Session Management**: Server tracks player sessions
**Easier Debugging**: Auth failures vs API failures are separate
**Future-Proof**: Ready for token refresh, expiration, etc.
## Next Steps
Once migration is complete:
1. **Monitor player logs** for first 24 hours
2. **Verify playlist updates** are working
3. **Check feedback** is being received in DigiServer
4. **Update other players** one at a time
## Support
If you encounter issues:
1. **Check player logs**: `tail -f player.log`
2. **Check server logs**: DigiServer v2 logs in `instance/logs/`
3. **Test API manually**:
```bash
# Test authentication
curl -X POST http://your-server:5000/api/auth/player \
-H "Content-Type: application/json" \
-d '{"hostname":"player-001","quickconnect_code":"QUICK123"}'
```
---
**Migration completed!** Your Kiwy-Signage player now uses secure authentication with DigiServer v2. 🎉
-43
View File
@@ -1,43 +0,0 @@
INFO ] [Kivy ] Installed at "/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/kivy/__init__.py"
[INFO ] [Python ] v3.13.5 (main, Jun 25 2025, 18:55:22) [GCC 14.2.0]
[INFO ] [Python ] Interpreter at "/home/pi/Desktop/Kiwy-Signage/.venv/bin/python3"
[INFO ] [Logger ] Purge log fired. Processing...
[INFO ] [Logger ] Purge finished!
[DEBUG ] [Using selector] EpollSelector
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
[ERROR ] [Image ] Error loading </home/pi/Desktop/Kiwy-Signage/config/resources/intro1.mp4>
[WARNING] ⚠️ SSL verification disabled - NOT recommended for production!
[DEBUG ] [Starting new HTTPS connection (1)] 192.168.0.121:443
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
warnings.warn(
[DEBUG ] [https ]//192.168.0.121:443 "POST /api/auth/verify HTTP/1.1" 200 None
[INFO ] ✅ Auth code verified
[INFO ] ✅ Using existing authentication
[INFO ] [Fetching playlist from] https://192.168.0.121:443/api/playlists/1
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
warnings.warn(
[DEBUG ] [https ]//192.168.0.121:443 "GET /api/playlists/1 HTTP/1.1" 200 None
[INFO ] [✅ Playlist received (version] 34)
[INFO ] [📊 Playlist versions - Server] v34, Local: v34
[INFO ] ✓ Playlist is up to date
[WARNING] Deprecated property "<BooleanProperty name=allow_stretch>" of object "<kivy.uix.image.AsyncImage object at 0x7fa5f79ef0>" has been set, it will be removed in a future version
[WARNING] Deprecated property "<BooleanProperty name=keep_ratio>" of object "<kivy.uix.image.AsyncImage object at 0x7fa5f79ef0>" was accessed, it will be removed in a future version
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
warnings.warn(
[DEBUG ] [https ]//192.168.0.121:443 "POST /api/auth/verify HTTP/1.1" 200 None
[INFO ] ✅ Auth code verified
[INFO ] ✅ Using existing authentication
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
warnings.warn(
[DEBUG ] [https ]//192.168.0.121:443 "POST /api/player-feedback HTTP/1.1" 200 None
^C[2026-01-17 22:09:12] 🛑 Watchdog received stop signal
pi@rpi-tvcanba1:~/Desktop/Kiwy-Signage $
-158
View File
@@ -1,158 +0,0 @@
# Offline Installation Guide
This guide explains how to set up and use offline installation for the Kiwy Signage Player.
## Overview
The offline installation system allows you to install the signage player on devices without internet access by pre-downloading all necessary packages.
## Directory Structure
```
Kiwy-Signage/
├── repo/ # Offline packages repository
│ ├── python-wheels/ # Python packages (.whl files)
│ ├── system-packages/ # System package information
│ │ ├── apt-packages.txt # List of required apt packages
│ │ └── debs/ # Downloaded .deb files (optional)
│ └── README.md
├── download_offline_packages.sh # Download Python packages
├── download_deb_packages.sh # Download system .deb packages
└── install.sh # Smart installer (online/offline)
```
## Setup for Offline Installation
### Step 1: Prepare on a Connected System
On a system with internet access (preferably Raspberry Pi OS):
```bash
# Clone the repository
git clone <repository-url>
cd Kiwy-Signage
# Download Python packages
bash download_offline_packages.sh
# (Optional) Download system .deb packages
bash download_deb_packages.sh
```
This will populate the `repo/` folder with all necessary packages.
### Step 2: Transfer to Offline System
Copy the entire `Kiwy-Signage` directory to your offline system:
```bash
# Using USB drive
cp -r Kiwy-Signage /media/usb/
# Or create a tarball
tar -czf kiwy-signage-offline.tar.gz Kiwy-Signage/
# On target system, extract:
tar -xzf kiwy-signage-offline.tar.gz
cd Kiwy-Signage
```
### Step 3: Install on Offline System
The installer automatically detects offline packages:
```bash
# Automatic detection
bash install.sh
# Or explicitly specify offline mode
bash install.sh --offline
```
## Package Information
### Python Packages (requirements.txt)
- **kivy==2.1.0** - UI framework
- **requests==2.32.4** - HTTP library
- **bcrypt==4.2.1** - Password hashing
- **aiohttp==3.9.1** - Async HTTP client
- **asyncio==3.4.3** - Async I/O framework
### System Packages (APT)
See `repo/system-packages/apt-packages.txt` for complete list:
- Python development tools
- SDL2 libraries (video/audio)
- FFmpeg and codecs
- GStreamer plugins
- Build dependencies
## Online Installation
If you have internet access, simply run:
```bash
bash install.sh
```
The installer will automatically download and install all packages from the internet.
## Updating Offline Packages
To update the offline package cache:
```bash
# On a connected system
bash download_offline_packages.sh
```
This will download the latest versions of all packages.
## Troubleshooting
### Problem: Missing Dependencies
If installation fails due to missing dependencies:
```bash
# Download .deb packages with dependencies
bash download_deb_packages.sh
# Install with dependency resolution
sudo apt install -f
```
### Problem: Wheel Not Found
If a specific Python package wheel is not found:
```bash
# Download specific package
pip3 download <package-name> -d repo/python-wheels/
```
### Problem: Architecture Mismatch
Ensure packages are downloaded on the same architecture (ARM for Raspberry Pi):
```bash
# Verify architecture
uname -m # Should show: armv7l or aarch64
# Force ARM downloads
pip3 download -r requirements.txt -d repo/python-wheels/ --platform linux_armv7l
```
## Storage Requirements
- **Python wheels**: ~50-100 MB
- **System .deb packages**: ~200-500 MB (if downloaded)
- **Total**: ~250-600 MB
## Notes
- The `repo/` folder is designed to be portable
- Downloaded packages are excluded from git (see `.gitignore`)
- The installer supports both online and offline modes seamlessly
- System packages list is maintained in `repo/system-packages/apt-packages.txt`
-42
View File
@@ -1,42 +0,0 @@
# Offline Installation Quick Start
## For Connected System (Preparation)
```bash
# 1. Download Python packages (required)
bash download_offline_packages.sh
# 2. Download system .deb packages (optional, for fully offline)
bash download_deb_packages.sh
```
## For Offline System (Installation)
```bash
# The installer auto-detects offline packages
bash install.sh
# Or explicitly use offline mode
bash install.sh --offline
```
## What's Included
### Python Packages (18 wheels)
✅ Kivy 2.1.0
✅ Requests 2.32.4
✅ Bcrypt 4.2.1
✅ Aiohttp 3.9.1 (async HTTP)
✅ Asyncio 3.4.3 (async framework)
✅ All dependencies
### System Packages
📋 See `repo/system-packages/apt-packages.txt`
## File Size
- Python wheels: ~50 MB
- System packages: ~200-500 MB (if .deb downloaded)
## See Also
- **OFFLINE_INSTALLATION.md** - Complete guide
- **repo/README.md** - Repository structure
-190
View File
@@ -1,190 +0,0 @@
# 🚀 Quick Start Guide - Player Authentication
## For DigiServer Admin
### 1. Create Player in DigiServer v2
```bash
# Login to web interface
http://your-server:5000
# Navigate to: Players → Add Player
Name: Office Player
Hostname: office-player-001 # Must be unique
Location: Main Office
Password: [leave empty if using quickconnect]
Quick Connect Code: OFFICE123 # Easy pairing code
Orientation: Landscape
```
### 2. Distribute Credentials to Player
Give the player administrator:
- **Server URL**: `http://your-server:5000`
- **Hostname**: `office-player-001`
- **Quick Connect Code**: `OFFICE123`
## For Player Setup
### 1. Update app_config.txt
```json
{
"server_ip": "your-server-ip",
"port": "5000",
"screen_name": "office-player-001",
"quickconnect_key": "OFFICE123",
...
}
```
### 2. Test Authentication
```bash
cd /home/pi/Desktop/Kiwy-Signage
python3 test_authentication.py
```
### 3. Update Player Code (One-Time)
In `src/main.py`, line ~34, change:
```python
from get_playlists_v2 import ( # Changed from get_playlists
update_playlist_if_needed,
send_playing_status_feedback,
send_playlist_restart_feedback,
send_player_error_feedback
)
```
### 4. Run Player
```bash
cd /home/pi/Desktop/Kiwy-Signage/src
python3 main.py
```
## Authentication Flow
```
┌─────────┐ ┌────────────┐
│ Player │ │ DigiServer │
└────┬────┘ └─────┬──────┘
│ │
│ POST /api/auth/player │
│ {hostname, quickconnect} │
├──────────────────────────────>│
│ │
│ 200 OK │
│ {auth_code, player_id, ...} │
│<──────────────────────────────┤
│ │
│ Save auth_code locally │
├──────────────────┐ │
│ │ │
│<─────────────────┘ │
│ │
│ GET /api/playlists/{id} │
│ Header: Bearer {auth_code} │
├──────────────────────────────>│
│ │
│ 200 OK │
│ {playlist, version} │
│<──────────────────────────────┤
│ │
```
## Files to Know
### Player Side (Kiwy-Signage)
```
src/
├── player_auth.json # Auto-created, stores auth_code
├── player_auth.py # Authentication module
├── get_playlists_v2.py # Updated playlist fetcher
└── app_config.txt # Your existing config
```
### Server Side (DigiServer v2)
```
app/
├── models/player.py # Player model with auth methods
└── blueprints/api.py # Authentication endpoints
API Endpoints:
- POST /api/auth/player # Authenticate and get token
- POST /api/auth/verify # Verify token validity
- GET /api/playlists/{id} # Get playlist (requires auth)
- POST /api/players/{id}/heartbeat # Send status (requires auth)
```
## Common Commands
```bash
# Test authentication
./test_authentication.py
# Clear saved auth (re-authenticate)
rm src/player_auth.json
# Check server health
curl http://your-server:5000/api/health
# Manual authentication test
curl -X POST http://your-server:5000/api/auth/player \
-H "Content-Type: application/json" \
-d '{"hostname":"player-001","quickconnect_code":"QUICK123"}'
# View player logs
tail -f player.log
# View server logs (if running Flask dev server)
# Logs appear in terminal where server is running
```
## Troubleshooting One-Liners
```bash
# Authentication fails → Check player exists
curl http://your-server:5000/api/health
# Auth expired → Clear and retry
rm src/player_auth.json && python3 main.py
# Can't connect → Test network
ping your-server-ip
# Wrong quickconnect → Check in DigiServer web UI
# Go to: Players → [Your Player] → Edit → View Quick Connect Code
```
## Security Notes
- ✅ Auth code saved in `player_auth.json` (keep secure!)
- ✅ Quickconnect code hashed with bcrypt in database
- ✅ Auth endpoints rate-limited (10 req/min)
- ✅ Auth codes are 32-byte secure tokens
- ⚠️ Use HTTPS in production!
- ⚠️ Rotate quickconnect codes periodically
## Quick Wins
### Before (Old System)
- Every API call = send hostname + quickconnect
- Server runs bcrypt check on every request
- Slow response times
- No session tracking
### After (New System)
- Authenticate once = get auth_code
- All subsequent calls use auth_code
- 10x faster API responses
- Server tracks player sessions
- Can revoke access instantly
---
**Ready to go!** 🎉 Test with `./test_authentication.py` then start your player!
-152
View File
@@ -1,152 +0,0 @@
# Kivy Signage Player
A modern digital signage player built with Kivy framework that displays content from DigiServer playlists.
## Features
- **Cross-platform**: Runs on Linux, Windows, and macOS
- **Modern UI**: Built with Kivy framework for smooth graphics and animations
- **Multiple Media Types**: Supports images (JPG, PNG, GIF, BMP) and videos (MP4, AVI, MKV, MOV, WEBM)
- **Server Integration**: Fetches playlists from DigiServer with automatic updates
- **Player Feedback**: Reports status and playback information to server
- **Fullscreen Display**: Optimized for digital signage displays
- **Touch Controls**: Mouse/touch-activated control panel
- **Auto-restart**: Continuous playlist looping
- **Error Handling**: Robust error handling with server feedback
## Installation
1. **Install system dependencies:**
```bash
chmod +x install.sh
./install.sh
```
2. **Configure the player:**
Edit `config/app_config.json` with your server details:
```json
{
"server_ip": "your-server-ip",
"port": "5000",
"screen_name": "your-player-name",
"quickconnect_key": "your-quickconnect-code"
}
```
## Usage
### Recommended: Using Start Script (with Virtual Environment)
```bash
chmod +x start.sh
./start.sh
```
This script automatically:
- Activates the Python virtual environment
- Checks for configuration
- Starts the player
### Alternative: Using Run Script
```bash
chmod +x run_player.sh
./run_player.sh
```
### Manual Start
```bash
# With virtual environment
source .venv/bin/activate
cd src
python3 main.py
# Without virtual environment
cd src
python3 main.py
```
## Controls
- **Mouse/Touch Movement**: Shows control panel for 3 seconds
- **Previous (⏮)**: Go to previous media item
- **Pause/Play (⏸/▶)**: Toggle playback
- **Next (⏭)**: Skip to next media item
- **Settings (⚙)**: View player configuration and status
- **Exit (⏻)**: Close the application
## Directory Structure
```
Kiwi-signage/
├── src/
│ ├── main.py # Main Kivy application
│ └── get_playlists.py # Playlist management and server communication
├── config/
│ └── app_config.json # Player configuration
├── media/ # Downloaded media files (auto-generated)
├── playlists/ # Playlist cache (auto-generated)
├── requirements.txt # Python dependencies
├── install.sh # Installation script
├── run_player.sh # Run script
└── README.md # This file
```
## Configuration Options
### app_config.json
- `server_ip`: IP address or domain of DigiServer
- `port`: Port number of DigiServer (default: 5000)
- `screen_name`: Unique identifier for this player
- `quickconnect_key`: Authentication key for server access
## Features Comparison with Tkinter Player
| Feature | Kivy Player | Tkinter Player |
|---------|-------------|----------------|
| Cross-platform | ✅ Better | ✅ Good |
| Modern UI | ✅ Excellent | ❌ Basic |
| Touch Support | ✅ Native | ❌ Limited |
| Video Playback | ✅ Built-in | ✅ VLC Required |
| Performance | ✅ GPU Accelerated | ❌ CPU Only |
| Animations | ✅ Smooth | ❌ None |
| Mobile Ready | ✅ Yes | ❌ No |
## Server Integration
The player communicates with DigiServer via REST API:
- **Playlist Fetch**: `GET /api/playlists`
- **Player Feedback**: `POST /api/player-feedback`
Status updates sent to server:
- Playlist check and update notifications
- Current playback status
- Error reports
- Playlist restart notifications
## Troubleshooting
### Installation Issues
- Make sure system dependencies are installed: `./install.sh`
- For ARM devices (Raspberry Pi), ensure proper SDL2 libraries
### Playback Issues
- Check media file formats are supported
- Verify network connection to DigiServer
- Check player configuration in settings
### Server Connection
- Verify server IP and port in configuration
- Check quickconnect key is correct
- Ensure DigiServer is running and accessible
## Development
Based on the proven architecture of the tkinter signage player with modern Kivy enhancements:
- **Playlist Management**: Inherited from `get_playlists.py`
- **Media Playback**: Kivy's built-in Video and AsyncImage widgets
- **Server Communication**: REST API calls with feedback system
- **Error Handling**: Comprehensive exception handling with server reporting
## License
This project is part of the DigiServer digital signage system.
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
"""Analyze what's happening with the playlist download."""
import json
# Check the saved playlist
playlist_file = 'playlists/server_playlist_v8.json'
print("=" * 80)
print("SAVED PLAYLIST ANALYSIS")
print("=" * 80)
with open(playlist_file, 'r') as f:
data = json.load(f)
print(f"\nVersion: {data.get('version', 'N/A')}")
print(f"Items in playlist: {len(data.get('playlist', []))}")
print("\nPlaylist items:")
for idx, item in enumerate(data.get('playlist', []), 1):
print(f"\n{idx}. File: {item.get('file_name', 'N/A')}")
print(f" URL: {item.get('url', 'N/A')}")
print(f" Duration: {item.get('duration', 'N/A')}s")
print("\n" + "=" * 80)
print("\n⚠️ ISSUE: Server has 5 files, but only 3 are saved!")
print("\nPossible reasons:")
print("1. Server sent only 3 files")
print("2. 2 files failed to download and were skipped")
print("3. Download function has a bug")
print("\nThe download_media_files() function in get_playlists_v2.py:")
print("- Downloads from the 'url' field in the playlist")
print("- If download fails, it SKIPS the file (continues)")
print("- Only successfully downloaded files are added to updated_playlist")
print("\nThis means 2 files likely had invalid URLs or download errors!")
print("=" * 80)
-123
View File
@@ -1,123 +0,0 @@
#!/usr/bin/env python3
"""
Check video files for codec compatibility with ffpyplayer
"""
import os
import subprocess
import json
def check_video_codec(video_path):
"""Check video codec using ffprobe"""
try:
cmd = [
'ffprobe',
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
video_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return None, "ffprobe failed"
data = json.loads(result.stdout)
video_streams = [s for s in data.get('streams', []) if s.get('codec_type') == 'video']
audio_streams = [s for s in data.get('streams', []) if s.get('codec_type') == 'audio']
if not video_streams:
return None, "No video stream found"
video_stream = video_streams[0]
info = {
'codec': video_stream.get('codec_name', 'unknown'),
'codec_long': video_stream.get('codec_long_name', 'unknown'),
'width': video_stream.get('width', 0),
'height': video_stream.get('height', 0),
'fps': eval(video_stream.get('r_frame_rate', '0/1')),
'duration': float(data.get('format', {}).get('duration', 0)),
'bitrate': int(data.get('format', {}).get('bit_rate', 0)),
'audio_codec': audio_streams[0].get('codec_name', 'none') if audio_streams else 'none',
'size': int(data.get('format', {}).get('size', 0))
}
return info, None
except FileNotFoundError:
return None, "ffprobe not installed (run: sudo apt-get install ffmpeg)"
except Exception as e:
return None, str(e)
def main():
base_dir = os.path.dirname(os.path.abspath(__file__))
media_dir = os.path.join(base_dir, 'media')
print("=" * 80)
print("VIDEO CODEC COMPATIBILITY CHECKER")
print("=" * 80)
# Supported codecs by ffpyplayer
supported_codecs = ['h264', 'h265', 'hevc', 'vp8', 'vp9', 'mpeg4']
# Find video files
video_extensions = ['.mp4', '.avi', '.mkv', '.mov', '.webm']
video_files = []
if os.path.exists(media_dir):
for filename in os.listdir(media_dir):
ext = os.path.splitext(filename)[1].lower()
if ext in video_extensions:
video_files.append(filename)
if not video_files:
print("\n✓ No video files found in media directory")
return
print(f"\nFound {len(video_files)} video file(s):\n")
for filename in video_files:
video_path = os.path.join(media_dir, filename)
print(f"📹 {filename}")
print(f" Path: {video_path}")
info, error = check_video_codec(video_path)
if error:
print(f" ❌ ERROR: {error}")
continue
# Display video info
print(f" Video Codec: {info['codec']} ({info['codec_long']})")
print(f" Resolution: {info['width']}x{info['height']}")
print(f" Frame Rate: {info['fps']:.2f} fps")
print(f" Duration: {info['duration']:.1f}s")
print(f" Bitrate: {info['bitrate'] / 1000:.0f} kbps")
print(f" Audio Codec: {info['audio_codec']}")
print(f" File Size: {info['size'] / (1024*1024):.2f} MB")
# Check compatibility
if info['codec'] in supported_codecs:
print(f" ✅ COMPATIBLE - Codec '{info['codec']}' is supported by ffpyplayer")
else:
print(f" ⚠️ WARNING - Codec '{info['codec']}' may not be supported")
print(f" Supported codecs: {', '.join(supported_codecs)}")
print(f" Consider re-encoding to H.264:")
print(f" ffmpeg -i \"{filename}\" -c:v libx264 -preset fast -crf 23 \"{os.path.splitext(filename)[0]}_h264.mp4\"")
# Performance warnings
if info['width'] > 1920 or info['height'] > 1080:
print(f" ⚠️ High resolution ({info['width']}x{info['height']}) may cause performance issues")
print(f" Consider downscaling to 1920x1080 or lower")
if info['bitrate'] > 5000000: # 5 Mbps
print(f" ⚠️ High bitrate ({info['bitrate'] / 1000000:.1f} Mbps) may cause playback issues")
print(f" Consider reducing bitrate to 2-4 Mbps")
print()
if __name__ == '__main__':
main()
-57
View File
@@ -1,57 +0,0 @@
#!/bin/bash
# Video Conversion Script for Raspberry Pi Signage Player
# Converts videos to optimal settings: 1080p @ 30fps, H.264 codec
if [ $# -eq 0 ]; then
echo "Usage: $0 <input_video> [output_video]"
echo "Example: $0 input.mp4 output.mp4"
echo ""
echo "This script converts videos to Raspberry Pi-friendly settings:"
echo " - Resolution: Max 1920x1080"
echo " - Frame rate: 30 fps"
echo " - Codec: H.264"
echo " - Bitrate: ~5-8 Mbps"
exit 1
fi
INPUT_VIDEO="$1"
OUTPUT_VIDEO="${2:-converted_$(basename "$INPUT_VIDEO")}"
if [ ! -f "$INPUT_VIDEO" ]; then
echo "Error: Input file '$INPUT_VIDEO' not found!"
exit 1
fi
echo "Converting video for Raspberry Pi playback..."
echo "Input: $INPUT_VIDEO"
echo "Output: $OUTPUT_VIDEO"
echo ""
# Convert video with optimal settings for Raspberry Pi
ffmpeg -i "$INPUT_VIDEO" \
-c:v libx264 \
-preset medium \
-crf 23 \
-maxrate 8M \
-bufsize 12M \
-vf "scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease,fps=30" \
-r 30 \
-c:a aac \
-b:a 128k \
-movflags +faststart \
-y \
"$OUTPUT_VIDEO"
if [ $? -eq 0 ]; then
echo ""
echo "✓ Conversion completed successfully!"
echo "Original: $(du -h "$INPUT_VIDEO" | cut -f1)"
echo "Converted: $(du -h "$OUTPUT_VIDEO" | cut -f1)"
echo ""
echo "You can now use '$OUTPUT_VIDEO' in your signage player."
else
echo ""
echo "✗ Conversion failed! Make sure ffmpeg is installed:"
echo " sudo apt-get install ffmpeg"
exit 1
fi
-167
View File
@@ -1,167 +0,0 @@
#!/usr/bin/env python3
"""
Diagnostic script to check why media files might be skipped
"""
import os
import json
# Paths
base_dir = os.path.dirname(os.path.abspath(__file__))
media_dir = os.path.join(base_dir, 'media')
playlists_dir = os.path.join(base_dir, 'playlists')
# Supported extensions
VIDEO_EXTENSIONS = ['.mp4', '.avi', '.mkv', '.mov', '.webm']
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp', '.gif']
SUPPORTED_EXTENSIONS = VIDEO_EXTENSIONS + IMAGE_EXTENSIONS
def check_playlist():
"""Check playlist for issues"""
print("=" * 80)
print("PLAYLIST DIAGNOSTIC TOOL")
print("=" * 80)
# Find latest playlist file
playlist_files = [f for f in os.listdir(playlists_dir)
if f.startswith('server_playlist_v') and f.endswith('.json')]
if not playlist_files:
print("\n❌ ERROR: No playlist files found!")
return
# Sort by version and get latest
versions = [(int(f.split('_v')[-1].split('.json')[0]), f) for f in playlist_files]
versions.sort(reverse=True)
latest_file = versions[0][1]
playlist_path = os.path.join(playlists_dir, latest_file)
print(f"\n📋 Latest Playlist: {latest_file}")
print(f" Path: {playlist_path}")
# Load playlist
try:
with open(playlist_path, 'r') as f:
data = json.load(f)
playlist = data.get('playlist', [])
version = data.get('version', 0)
print(f" Version: {version}")
print(f" Total items: {len(playlist)}")
except Exception as e:
print(f"\n❌ ERROR loading playlist: {e}")
return
# Check media directory
print(f"\n📁 Media Directory: {media_dir}")
if not os.path.exists(media_dir):
print(" ❌ ERROR: Media directory doesn't exist!")
return
media_files = os.listdir(media_dir)
print(f" Files found: {len(media_files)}")
for f in media_files:
print(f" - {f}")
# Check each playlist item
print("\n" + "=" * 80)
print("CHECKING PLAYLIST ITEMS")
print("=" * 80)
valid_count = 0
missing_count = 0
unsupported_count = 0
for idx, item in enumerate(playlist, 1):
file_name = item.get('file_name', '')
duration = item.get('duration', 0)
media_path = os.path.join(media_dir, file_name)
file_ext = os.path.splitext(file_name)[1].lower()
print(f"\n[{idx}/{len(playlist)}] {file_name}")
print(f" Duration: {duration}s")
# Check if file exists
if not os.path.exists(media_path):
print(f" ❌ STATUS: FILE NOT FOUND")
print(f" Expected path: {media_path}")
missing_count += 1
continue
# Check file size
file_size = os.path.getsize(media_path)
print(f" ✓ File exists ({file_size:,} bytes)")
# Check if supported type
if file_ext not in SUPPORTED_EXTENSIONS:
print(f" ❌ STATUS: UNSUPPORTED FILE TYPE '{file_ext}'")
print(f" Supported extensions: {', '.join(SUPPORTED_EXTENSIONS)}")
unsupported_count += 1
continue
# Check media type
if file_ext in VIDEO_EXTENSIONS:
media_type = "VIDEO"
elif file_ext in IMAGE_EXTENSIONS:
media_type = "IMAGE"
else:
media_type = "UNKNOWN"
print(f" ✓ Type: {media_type}")
print(f" ✓ Extension: {file_ext}")
print(f" ✓ STATUS: SHOULD PLAY OK")
valid_count += 1
# Summary
print("\n" + "=" * 80)
print("SUMMARY")
print("=" * 80)
print(f"Total items: {len(playlist)}")
print(f"✓ Valid: {valid_count}")
print(f"❌ Missing files: {missing_count}")
print(f"❌ Unsupported: {unsupported_count}")
if valid_count == len(playlist):
print("\n✅ All playlist items should play correctly!")
else:
print(f"\n⚠️ WARNING: {len(playlist) - valid_count} items may be skipped!")
# Additional checks
print("\n" + "=" * 80)
print("ADDITIONAL CHECKS")
print("=" * 80)
# Check for files in media dir not in playlist
playlist_files_set = {item.get('file_name', '') for item in playlist}
orphaned_files = [f for f in media_files if f not in playlist_files_set]
if orphaned_files:
print(f"\n⚠️ Files in media directory NOT in playlist:")
for f in orphaned_files:
print(f" - {f}")
else:
print("\n✓ All media files are in the playlist")
# Check for case sensitivity issues
print("\n🔍 Checking for case sensitivity issues...")
media_files_lower = {f.lower(): f for f in media_files}
case_issues = []
for item in playlist:
file_name = item.get('file_name', '')
if file_name.lower() in media_files_lower:
actual_name = media_files_lower[file_name.lower()]
if actual_name != file_name:
case_issues.append((file_name, actual_name))
if case_issues:
print("⚠️ Case sensitivity mismatches found:")
for playlist_name, actual_name in case_issues:
print(f" Playlist: {playlist_name}")
print(f" Actual: {actual_name}")
else:
print("✓ No case sensitivity issues found")
if __name__ == '__main__':
check_playlist()
-125
View File
@@ -1,125 +0,0 @@
#!/bin/bash
# Download DEB Packages Script for Offline Installation
# This script downloads all system .deb packages required for offline installation
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SYSTEM_DIR="$SCRIPT_DIR/repo/system-packages"
DEB_DIR="$SYSTEM_DIR/debs"
echo "=========================================="
echo "Downloading DEB Packages for Offline Install"
echo "=========================================="
echo ""
# Create debs directory
mkdir -p "$DEB_DIR"
# Check if running on Debian/Ubuntu/Raspberry Pi OS
if ! command -v apt-get &> /dev/null; then
echo "Error: This script requires apt-get (Debian/Ubuntu/Raspberry Pi OS)"
exit 1
fi
echo "Reading package list from: $SYSTEM_DIR/apt-packages.txt"
echo ""
# Update package cache
echo "Updating package cache..."
sudo apt update
# Read packages from file
PACKAGES=$(grep -v '^#' "$SYSTEM_DIR/apt-packages.txt" | grep -v '^$' | tr '\n' ' ')
echo "Packages to download:"
echo "$PACKAGES"
echo ""
# Download packages and dependencies
echo "Downloading packages with dependencies..."
cd "$DEB_DIR"
# Use apt-get download to get .deb files
for pkg in $PACKAGES; do
echo "Downloading: $pkg"
apt-get download "$pkg" 2>/dev/null || echo " Warning: Could not download $pkg"
done
# Download dependencies
echo ""
echo "Downloading dependencies..."
sudo apt-get install --download-only --reinstall -y $PACKAGES
# Copy downloaded debs from apt cache
echo ""
echo "Copying packages from apt cache..."
sudo cp /var/cache/apt/archives/*.deb "$DEB_DIR/" 2>/dev/null || true
# Remove duplicate packages
echo ""
echo "Removing duplicates..."
cd "$DEB_DIR"
for file in *.deb; do
[ -f "$file" ] || continue
basename="${file%%_*}"
count=$(ls -1 "${basename}"_*.deb 2>/dev/null | wc -l)
if [ "$count" -gt 1 ]; then
# Keep only the latest version
ls -t "${basename}"_*.deb | tail -n +2 | xargs rm -f
fi
done
# Create installation order file
echo ""
echo "Creating installation order..."
cat > "$DEB_DIR/install-order.txt" << 'EOF'
# Install packages in this order to resolve dependencies
# 1. Base tools and libraries
python3-pip_*.deb
python3-setuptools_*.deb
python3-dev_*.deb
zlib1g-dev_*.deb
# 2. SDL2 libraries
libsdl2-dev_*.deb
libsdl2-image-dev_*.deb
libsdl2-mixer-dev_*.deb
libsdl2-ttf-dev_*.deb
# 3. Multimedia libraries
libportmidi-dev_*.deb
libswscale-dev_*.deb
libavformat-dev_*.deb
libavcodec-dev_*.deb
libavcodec-extra_*.deb
# 4. FFmpeg and codecs
ffmpeg_*.deb
libx264-dev_*.deb
# 5. GStreamer
gstreamer1.0-plugins-base_*.deb
gstreamer1.0-plugins-good_*.deb
gstreamer1.0-plugins-bad_*.deb
gstreamer1.0-alsa_*.deb
# 6. Network tools
wget_*.deb
curl_*.deb
EOF
# Summary
echo ""
echo "=========================================="
echo "DEB Download Complete!"
echo "=========================================="
echo ""
echo "Downloaded .deb files: $(ls -1 *.deb 2>/dev/null | wc -l)"
echo "Location: $DEB_DIR"
echo ""
echo "To install offline, copy the repo folder and run:"
echo " bash install.sh --offline"
echo ""
@@ -1,72 +0,0 @@
#!/bin/bash
# Download Offline Packages Script for Kivy Signage Player
# This script downloads all necessary Python packages and documents system packages
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$SCRIPT_DIR/repo"
WHEELS_DIR="$REPO_DIR/python-wheels"
SYSTEM_DIR="$REPO_DIR/system-packages"
echo "=========================================="
echo "Downloading Offline Packages"
echo "=========================================="
echo ""
# Check if repo directory exists
if [ ! -d "$REPO_DIR" ]; then
echo "Error: repo directory not found!"
echo "Creating directories..."
mkdir -p "$WHEELS_DIR"
mkdir -p "$SYSTEM_DIR"
fi
# Download Python packages
echo "Step 1: Downloading Python wheels..."
echo "--------------------"
cd "$SCRIPT_DIR"
# Check if pip is installed
if ! command -v pip3 &> /dev/null; then
echo "Error: pip3 is not installed. Please install it first:"
echo " sudo apt install python3-pip"
exit 1
fi
# Download all Python packages and their dependencies
echo "Downloading packages from requirements.txt..."
pip3 download -r requirements.txt -d "$WHEELS_DIR" --platform linux_armv7l --only-binary=:all: || \
pip3 download -r requirements.txt -d "$WHEELS_DIR" || true
# Also download for general Linux platforms as fallback
echo "Downloading cross-platform packages..."
pip3 download -r requirements.txt -d "$WHEELS_DIR" || true
echo ""
echo "Python wheels downloaded to: $WHEELS_DIR"
echo "Total wheel files: $(ls -1 "$WHEELS_DIR"/*.whl 2>/dev/null | wc -l)"
# List system packages
echo ""
echo "Step 2: System packages information"
echo "--------------------"
echo "System packages are listed in: $SYSTEM_DIR/apt-packages.txt"
echo ""
echo "To download .deb files for offline installation, run:"
echo " bash download_deb_packages.sh"
echo ""
# Summary
echo "=========================================="
echo "Download Complete!"
echo "=========================================="
echo ""
echo "Offline packages ready in: $REPO_DIR"
echo ""
echo "Next steps:"
echo "1. Copy the entire 'repo' folder to your offline system"
echo "2. Run: bash install.sh"
echo " (The installer will automatically detect and use offline packages)"
echo ""
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env python3
"""
Execute the exact server playlist retrieval flow the player performs:
1. Load config/app_config.json (player code: screen_name + quickconnect_key)
2. Authenticate with the server (POST /api/auth/player)
3. Fetch playlist using the player_id + auth_code (GET /api/playlists/{player_id})
4. Print the RAW JSON exactly as received (before any local processing)
"""
import os
import sys
import json
import traceback
# Resolve the real src directory (script lives in working_files/, source is in src/)
HERE = os.path.dirname(os.path.abspath(__file__))
SRC_DIR = os.path.join(os.path.dirname(HERE), 'src')
sys.path.insert(0, SRC_DIR)
from get_playlists_v2 import get_auth_instance # noqa: E402
from player_auth import PlayerAuth # noqa: E402
CONFIG_PATH = os.path.join(os.path.dirname(HERE), 'config', 'app_config.json')
def main():
print("=" * 80)
print("EXECUTE SERVER PLAYLIST RETRIEVAL (player flow)")
print("=" * 80)
# 1. Load the player config
with open(CONFIG_PATH, 'r') as f:
config = json.load(f)
print(f"\n[1] Player config loaded from: {CONFIG_PATH}")
print(f" server_ip : {config.get('server_ip')}")
print(f" port : {config.get('port')}")
print(f" screen_name : {config.get('screen_name')} <- player code")
print(f" quickconnect_key: {config.get('quickconnect_key')} <- player quick-connect code")
print(f" use_https : {config.get('use_https')}")
print(f" verify_ssl : {config.get('verify_ssl')}")
# 2. Build server URL the same way ensure_authenticated() does
import re
server_ip = config.get("server_ip", "")
port = config.get("port", "")
use_https = config.get("use_https", True)
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
if re.match(ip_pattern, server_ip):
if use_https:
server_url = f'https://{server_ip}:{port}' if port else f'https://{server_ip}'
else:
server_url = f'http://{server_ip}:{port}' if port else f'http://{server_ip}'
else:
server_url = f'https://{server_ip}' if use_https else f'http://{server_ip}'
print(f"\n[2] Server URL used: {server_url}")
# 3. Authenticate with the player code
auth = get_auth_instance(
config_file=os.path.join(SRC_DIR, 'player_auth.json'),
use_https=config.get('use_https', True),
verify_ssl=config.get('verify_ssl', True)
)
print(f"\n[3] Authenticating player '{config.get('screen_name')}' with quickconnect code...")
success, error = auth.authenticate(
server_url=server_url,
hostname=config.get('screen_name', ''),
quickconnect_code=config.get('quickconnect_key', '')
)
if not success:
print(f" AUTH FAILED: {error}")
print(" Cannot retrieve playlist without a valid player code/auth.")
return
print(f" Authenticated as: {auth.get_player_name()} (player_id={auth.get_player_id()}, "
f"playlist_id={auth.auth_data.get('playlist_id')})")
# 4. Fetch the playlist (GET /api/playlists/{player_id})
print(f"\n[4] Fetching playlist from: {server_url}/api/playlists/{auth.get_player_id()}")
playlist_data = auth.get_playlist()
if playlist_data is None:
print(" PLAYLIST FETCH FAILED")
return
# 5. Print the RAW JSON exactly as received from the server
print(f"\n[5] RAW JSON received from server (status 200):\n")
print(json.dumps(playlist_data, indent=2, ensure_ascii=False))
# 6. Save the raw response for inspection
out_path = os.path.join(HERE, 'raw_server_playlist.json')
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(playlist_data, f, indent=2, ensure_ascii=False)
print(f"\n[6] Raw server response saved to: {out_path}")
# 7. Summary
print("\n" + "=" * 80)
print(f"SUMMARY: playlist_version={playlist_data.get('playlist_version')}, "
f"count={playlist_data.get('count')}, items={len(playlist_data.get('playlist', []))}")
print("=" * 80)
if __name__ == '__main__':
try:
main()
except Exception:
traceback.print_exc()
sys.exit(1)
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""Force playlist update to download all files."""
import json
import sys
import os
# Add src directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from get_playlists_v2 import update_playlist_if_needed
# Load config
config_file = 'config/app_config.json'
with open(config_file, 'r') as f:
config = json.load(f)
print("=" * 80)
print("FORCING PLAYLIST UPDATE")
print("=" * 80)
playlist_dir = 'playlists'
media_dir = 'media'
print(f"\nConfiguration:")
print(f" Playlist dir: {playlist_dir}")
print(f" Media dir: {media_dir}")
print("\n" + "=" * 80)
print("Updating playlist...")
print("=" * 80 + "\n")
result = update_playlist_if_needed(config, playlist_dir, media_dir)
if result:
print("\n" + "=" * 80)
print("SUCCESS!")
print("=" * 80)
print(f"✓ Playlist updated to: {result}")
# Check media directory
import os
media_files = sorted([f for f in os.listdir(media_dir) if not f.startswith('.')])
print(f"\n✓ Media files downloaded ({len(media_files)}):")
for f in media_files:
size = os.path.getsize(os.path.join(media_dir, f))
print(f" - {f} ({size:,} bytes)")
else:
print("\n" + "=" * 80)
print("FAILED or already up to date")
print("=" * 80)
print("\n" + "=" * 80)
-346
View File
@@ -1,346 +0,0 @@
import os
import json
import requests
import bcrypt
import re
import datetime
import logging
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def send_player_feedback(config, message, status="active", playlist_version=None, error_details=None):
"""
Send feedback to the server about player status.
Args:
config (dict): Configuration containing server details
message (str): Main feedback message
status (str): Player status - "active", "playing", "error", "restarting"
playlist_version (int, optional): Current playlist version being played
error_details (str, optional): Error details if status is "error"
Returns:
bool: True if feedback sent successfully, False otherwise
"""
try:
server = config.get("server_ip", "")
host = config.get("screen_name", "")
quick = config.get("quickconnect_key", "")
port = config.get("port", "")
# Construct server URL
# Remove protocol if already present
server_clean = server.replace('http://', '').replace('https://', '')
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
if re.match(ip_pattern, server_clean):
feedback_url = f'http://{server_clean}:{port}/api/player-feedback'
else:
# Use original server if it has protocol, otherwise add http://
if server.startswith(('http://', 'https://')):
feedback_url = f'{server}/api/player-feedback'
else:
feedback_url = f'http://{server}/api/player-feedback'
# Prepare feedback data
feedback_data = {
'hostname': host,
'quickconnect_code': quick,
'message': message,
'status': status,
'timestamp': datetime.datetime.now().isoformat(),
'playlist_version': playlist_version,
'error_details': error_details
}
logger.info(f"Sending feedback to {feedback_url}: {feedback_data}")
# Send POST request
response = requests.post(feedback_url, json=feedback_data, timeout=10)
if response.status_code == 200:
logger.info(f"Feedback sent successfully: {message}")
return True
else:
logger.warning(f"Feedback failed with status {response.status_code}: {response.text}")
return False
except requests.exceptions.RequestException as e:
logger.error(f"Failed to send feedback: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error sending feedback: {e}")
return False
def send_playlist_check_feedback(config, playlist_version=None):
"""
Send feedback when playlist is checked for updates.
Args:
config (dict): Configuration containing server details
playlist_version (int, optional): Current playlist version
Returns:
bool: True if feedback sent successfully, False otherwise
"""
player_name = config.get("screen_name", "unknown")
version_info = f"v{playlist_version}" if playlist_version else "unknown"
message = f"player {player_name}, is active, Playing {version_info}"
return send_player_feedback(
config=config,
message=message,
status="active",
playlist_version=playlist_version
)
def send_playlist_restart_feedback(config, playlist_version=None):
"""
Send feedback when playlist loop ends and restarts.
Args:
config (dict): Configuration containing server details
playlist_version (int, optional): Current playlist version
Returns:
bool: True if feedback sent successfully, False otherwise
"""
player_name = config.get("screen_name", "unknown")
version_info = f"v{playlist_version}" if playlist_version else "unknown"
message = f"player {player_name}, playlist loop completed, restarting {version_info}"
return send_player_feedback(
config=config,
message=message,
status="restarting",
playlist_version=playlist_version
)
def send_player_error_feedback(config, error_message, playlist_version=None):
"""
Send feedback when an error occurs in the player.
Args:
config (dict): Configuration containing server details
error_message (str): Description of the error
playlist_version (int, optional): Current playlist version
Returns:
bool: True if feedback sent successfully, False otherwise
"""
player_name = config.get("screen_name", "unknown")
message = f"player {player_name}, error occurred"
return send_player_feedback(
config=config,
message=message,
status="error",
playlist_version=playlist_version,
error_details=error_message
)
def send_playing_status_feedback(config, playlist_version=None, current_media=None):
"""
Send feedback about current playing status.
Args:
config (dict): Configuration containing server details
playlist_version (int, optional): Current playlist version
current_media (str, optional): Currently playing media file
Returns:
bool: True if feedback sent successfully, False otherwise
"""
player_name = config.get("screen_name", "unknown")
version_info = f"v{playlist_version}" if playlist_version else "unknown"
media_info = f" - {current_media}" if current_media else ""
message = f"player {player_name}, is active, Playing {version_info}{media_info}"
return send_player_feedback(
config=config,
message=message,
status="playing",
playlist_version=playlist_version
)
def fetch_server_playlist(config):
"""Fetch the updated playlist from the server using a config dict."""
server = config.get("server_ip", "")
host = config.get("screen_name", "")
quick = config.get("quickconnect_key", "")
port = config.get("port", "")
try:
# Remove protocol if already present
server_clean = server.replace('http://', '').replace('https://', '')
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
if re.match(ip_pattern, server_clean):
server_url = f'http://{server_clean}:{port}/api/playlists'
else:
# Use original server if it has protocol, otherwise add http://
if server.startswith(('http://', 'https://')):
server_url = f'{server}/api/playlists'
else:
server_url = f'http://{server}/api/playlists'
params = {
'hostname': host,
'quickconnect_code': quick
}
logger.info(f"Fetching playlist from URL: {server_url} with params: {params}")
response = requests.get(server_url, params=params)
if response.status_code == 200:
response_data = response.json()
logger.info(f"Server response: {response_data}")
playlist = response_data.get('playlist', [])
version = response_data.get('playlist_version', None)
hashed_quickconnect = response_data.get('hashed_quickconnect', None)
if version is not None and hashed_quickconnect is not None:
if bcrypt.checkpw(quick.encode('utf-8'), hashed_quickconnect.encode('utf-8')):
logger.info("Fetched updated playlist from server.")
return {'playlist': playlist, 'version': version}
else:
logger.error("Quickconnect code validation failed.")
else:
logger.error("Failed to retrieve playlist or hashed quickconnect from the response.")
else:
logger.error(f"Failed to fetch playlist. Status Code: {response.status_code}")
except requests.exceptions.RequestException as e:
logger.error(f"Failed to fetch playlist: {e}")
return {'playlist': [], 'version': 0}
def save_playlist_with_version(playlist_data, playlist_dir):
version = playlist_data.get('version', 0)
playlist_file = os.path.join(playlist_dir, f'server_playlist_v{version}.json')
with open(playlist_file, 'w') as f:
json.dump(playlist_data, f, indent=2)
logger.info(f"Playlist saved to {playlist_file}")
return playlist_file
def download_media_files(playlist, media_dir):
"""Download media files from the server and save them to media_dir."""
if not os.path.exists(media_dir):
os.makedirs(media_dir)
logger.info(f"Created directory {media_dir} for media files.")
updated_playlist = []
for media in playlist:
file_name = media.get('file_name', '')
file_url = media.get('url', '')
duration = media.get('duration', 10)
local_path = os.path.join(media_dir, file_name)
logger.info(f"Preparing to download {file_name} from {file_url}...")
if os.path.exists(local_path):
logger.info(f"File {file_name} already exists. Skipping download.")
else:
try:
response = requests.get(file_url, timeout=10)
if response.status_code == 200:
with open(local_path, 'wb') as file:
file.write(response.content)
logger.info(f"Successfully downloaded {file_name} to {local_path}")
else:
logger.error(f"Failed to download {file_name}. Status Code: {response.status_code}")
continue
except requests.exceptions.RequestException as e:
logger.error(f"Error downloading {file_name}: {e}")
continue
updated_media = {
'file_name': file_name,
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
'duration': duration
}
updated_playlist.append(updated_media)
return updated_playlist
def delete_old_playlists_and_media(current_version, playlist_dir, media_dir, keep_versions=1):
"""
Delete old playlist files and media files not referenced by the latest playlist version.
keep_versions: number of latest versions to keep (default 1)
"""
# Find all playlist files
playlist_files = [f for f in os.listdir(playlist_dir) if f.startswith('server_playlist_v') and f.endswith('.json')]
# Keep only the latest N versions
versions = sorted([int(f.split('_v')[-1].split('.json')[0]) for f in playlist_files], reverse=True)
keep = set(versions[:keep_versions])
# Delete old playlist files
for f in playlist_files:
v = int(f.split('_v')[-1].split('.json')[0])
if v not in keep:
os.remove(os.path.join(playlist_dir, f))
# Collect all media files referenced by the kept playlists
referenced = set()
for v in keep:
path = os.path.join(playlist_dir, f'server_playlist_v{v}.json')
if os.path.exists(path):
with open(path, 'r') as f:
data = json.load(f)
for item in data.get('playlist', []):
referenced.add(item.get('file_name'))
# Delete media files not referenced
for f in os.listdir(media_dir):
if f not in referenced:
try:
os.remove(os.path.join(media_dir, f))
except Exception as e:
logger.warning(f"Failed to delete media file {f}: {e}")
def update_playlist_if_needed(local_playlist_path, config, media_dir, playlist_dir):
"""
Fetch the server playlist once, compare versions, and update if needed.
Returns True if updated, False if already up to date.
Also sends feedback to server about playlist check.
"""
server_data = fetch_server_playlist(config)
server_version = server_data.get('version', 0)
if not os.path.exists(local_playlist_path):
local_version = 0
else:
with open(local_playlist_path, 'r') as f:
local_data = json.load(f)
local_version = local_data.get('version', 0)
logger.info(f"Local playlist version: {local_version}, Server playlist version: {server_version}")
# Send feedback about playlist check
send_playlist_check_feedback(config, server_version if server_version > 0 else local_version)
if local_version != server_version:
if server_data and server_data.get('playlist'):
updated_playlist = download_media_files(server_data['playlist'], media_dir)
server_data['playlist'] = updated_playlist
save_playlist_with_version(server_data, playlist_dir)
# Delete old playlists and unreferenced media
delete_old_playlists_and_media(server_version, playlist_dir, media_dir)
# Send feedback about playlist update
player_name = config.get("screen_name", "unknown")
update_message = f"player {player_name}, playlist updated to v{server_version}"
send_player_feedback(config, update_message, "active", server_version)
return True
else:
logger.warning("No playlist data fetched from server or playlist is empty.")
# Send error feedback
send_player_error_feedback(config, "No playlist data fetched from server or playlist is empty", local_version)
return False
else:
logger.info("Local playlist is already up to date.")
return False
def is_playlist_up_to_date(local_playlist_path, config):
"""
Compare the version of the local playlist with the server playlist.
Returns True if up-to-date, False otherwise.
"""
if not os.path.exists(local_playlist_path):
logger.info(f"Local playlist file not found: {local_playlist_path}")
return False
with open(local_playlist_path, 'r') as f:
local_data = json.load(f)
local_version = local_data.get('version', 0)
server_data = fetch_server_playlist(config)
server_version = server_data.get('version', 0)
logger.info(f"Local playlist version: {local_version}, Server playlist version: {server_version}")
return local_version == server_version
File diff suppressed because it is too large Load Diff
-81
View File
@@ -1,81 +0,0 @@
{
"count": 6,
"player_id": 2,
"player_name": "Windows-Player1",
"playlist": [
{
"audio": "off",
"description": null,
"duration": 30,
"edit_on_player": false,
"file_name": "anders-jilden-cYrMQA7a3Wc-unsplash.jpg",
"id": 9,
"muted": true,
"position": 1,
"type": "image",
"url": "http://192.168.0.107:8080/static/uploads/anders-jilden-cYrMQA7a3Wc-unsplash.jpg"
},
{
"audio": "off",
"description": null,
"duration": 14,
"edit_on_player": true,
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
"id": 2,
"muted": true,
"position": 2,
"type": "image",
"url": "http://192.168.0.107:8080/static/uploads/sean-oulashin-KMn4VEeEPR8-unsplash.jpg"
},
{
"audio": "on",
"description": null,
"duration": 31,
"edit_on_player": false,
"file_name": "sample-30s.mp4",
"id": 4,
"muted": false,
"position": 3,
"type": "video",
"url": "http://192.168.0.107:8080/static/uploads/sample-30s.mp4"
},
{
"audio": "off",
"description": null,
"duration": 50,
"edit_on_player": true,
"file_name": "edited_media/5/eye_e_v2.jpg",
"id": 5,
"muted": true,
"position": 4,
"type": "image",
"url": "http://192.168.0.107:8080/static/uploads/edited_media/5/eye_e_v2.jpg"
},
{
"audio": "off",
"description": "https://moto-adv.com/",
"duration": 30,
"edit_on_player": false,
"file_name": "weblink-ecc2705c34e5",
"id": 7,
"muted": true,
"position": 5,
"type": "weblink",
"url": "https://moto-adv.com/"
},
{
"audio": "off",
"description": null,
"duration": 30,
"edit_on_player": false,
"file_name": "jack-anstey-XVoyX7l9ocY-unsplash.jpg",
"id": 8,
"muted": true,
"position": 6,
"type": "image",
"url": "http://192.168.0.107:8080/static/uploads/jack-anstey-XVoyX7l9ocY-unsplash.jpg"
}
],
"playlist_id": 1,
"playlist_version": 32
}

Some files were not shown because too many files have changed in this diff Show More