Compare commits

6 Commits

Author SHA1 Message Date
ske087 f437aba1fc Stop tracking working_files/player_auth.json
This file was still tracked after the previous commit: it is listed in
.gitignore, and git add skips ignored paths, so `git add -A <path>` silently did
nothing for it. Removing it from the index requires `git rm --cached`.

It contains a live auth_code, player_id and server_url for a different player
("tv-terasa" / TV-acasa 1), so it must not be in the repository. The file is
kept on disk; only the tracking is removed.

The two other credential files (player_auth.json, src/player_auth.json) were
already untracked.

Note for anyone repeating this: these files are gitignored, so `git add` will
never stage their removal. Use `git rm --cached <path>`, and verify with
`git ls-files | findstr player_auth` that nothing remains.
2026-09-13 10:18:34 +03:00
ske087 864ea06996 Stop the runtime hook from planting a bundled config on first run
_compy_bundled_resources() in the PyInstaller runtime hook copied
config/app_config.json next to the .exe on first start. With the config no
longer bundled (see the first-run setup commit) that copy would simply fail,
but leaving the entry in place keeps the intent alive and would re-plant a stale
config if the file were ever added back to the bundle.

The hook now copies only the resource files (icons, intro video), and the
comment explains why the config is deliberately absent: the player must start
unconfigured so it asks the operator for the server details instead of
inheriting the build machine's settings.
2026-09-13 10:15:26 +03:00
ske087 079d8c7f9d Stop tracking credentials and player runtime state
Removes the tracked credential files. They are kept on disk, but they must not
be in the repository: player_auth.json holds a live auth_code, player_id and
server_url.

  removed from tracking: player_auth.json, src/player_auth.json,
                         working_files/player_auth.json

Also fixes the ignore rules for the player's runtime state, which were being
reported as untracked and would have been committed:

  - .player_heartbeat is rewritten every 10 seconds, so committing it creates
    endless noise and merge conflicts on every machine.
  - .player_stop_requested is transient session state.
  - logs/startup_marker.txt and the various log outputs are runtime artifacts.

While doing this I found that the existing ignore entry was spelled
".player_heartbear" - a long-standing typo, so the heartbeat was never actually
ignored. The correct patterns are now present; the typo is left in place with a
comment so nothing changes unexpectedly for existing clones.

Note the deliberate exceptions, both tracked on purpose:

  - windows/webview2_sdk/ - build.spec bundles those DLLs, so a fresh clone
    needs them or web links silently fall back to the Chrome/Edge engine.
  - windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe (~1.7 MB) - so a
    machine without the WebView2 Runtime can install it on first start. The
    ~203 MB offline standalone installer stays ignored; fetch it with
    webview2_runtime/download_runtime_installers.ps1 -Offline when building for
    machines that have no internet.
2026-09-13 10:15:10 +03:00
ske087 477128de81 First-run setup: ship no credentials, ask for them on first start
The exe shipped config/app_config.json AND src/player_auth.json inside the
bundle, and because a frozen app runs with cwd = _internal/, the player loaded
that snapshot as its live auth state. A stale snapshot therefore made a fresh
build boot "already authenticated" against an old server and play an outdated
playlist. It also meant every install inherited the build machine's server_ip,
screen_name and auth_code.

Both files are now excluded from the bundle (app_config.json is no longer added
to datas, player_auth.json is excluded from Tree(src)), and the runtime hook no
longer copies a config into place on first run.

New behaviour, all in src/main.py so it applies to the Pi build too:

  - The player starts with blank credentials. A missing file, an empty or
    unparseable file, missing keys, and leftover placeholder values
    (localhost, 127.0.0.1, kivy-player, 1234567) all count as UNCONFIGURED.
    config_is_configured() is the single source of truth for that decision.
  - After the splash video a notice appears ("Player is not configured"), and
    after 5 seconds the Settings screen opens automatically so the operator can
    enter the server details.
  - Saving valid values writes config/app_config.json next to the .exe and
    starts playback immediately - no restart needed.
  - On a machine that IS configured, the notice and Settings are skipped and the
    cached playlist plays straight away.
  - Settings refuses to close while the three required fields are blank, so it
    cannot be dismissed into a permanently blank screen with no way back.
  - The 30s playlist timer does not fight the setup flow while unconfigured.

on_intro_finished() is the single decision point after the splash; both intro
paths (video end and "no intro file") go through it so they cannot drift apart.

Also loads config over DEFAULT_CONFIG rather than replacing it, so a partial or
older config file keeps working defaults instead of losing keys.

Note for future changes: when adding a new REQUIRED config key, add it to
CONFIG_REQUIRED_KEYS or the first-run flow will not ask for it.

Verified on the packaged exe by removing the config to simulate a fresh install:
setup_required_shown -> setup_opening_settings exactly 5s later ->
setup_completed, with the config written next to the exe and playback resuming.
Restarting with that config produced no setup events at all.
Covered by windows/test_first_run_setup.py.
2026-09-13 10:14:56 +03:00
ske087 d8c6ab0bc5 Fix video hang and silent-video crash; add 24/7 watchdog
Two independent failures were killing long unattended runs.

1. HANG at the end of a video (Windows AppHangB1)

The player froze after ~30-45 minutes of looping, always at a video item. The
playback trace stopped dead right after "video_loaded" with no "video_eos" and
no "advance_after_video_eos", and Windows logged AppHangB1 rather than a crash.

Cause, all inside Kivy and verified against the installed source:

  1. ffpyplayer fires on_eos.
  2. Kivy's Video widget binds its OWN handler first (kivy/uix/video.py
     _do_video_load), and that handler sets state = 'stop' DURING the event
     dispatch.
  3. state = 'stop' -> VideoFFPy.stop() -> unload(), which calls
     self._thread.join() with no timeout (the source even carries the comment
     "TODO: use callback, don't block here").
  4. When that decode thread is slow to exit, the Kivy/SDL main thread never
     returns, so the window stops pumping messages.

It is a race, which is why it looked random and only appeared after many videos.

src/video_safety.py bounds that join. ffpyplayer has already been told to quit
and its thread woken before the join, so limiting the wait does not leak work;
it only stops an unresponsive thread from taking the whole player down. The
guard is installed before the Video widget is constructed, because the decode
thread is created during play().

The intro video had the same hazard on the main thread (state='stop' followed by
unload() inside the state callback) and is now torn down on a worker thread like
playlist videos.

2. CRASH in SDL2_mixer.dll (0xc0000005) on a video with no audio stream

Triggered when a silent 4K clip entered the playlist while the item was marked
audio: on. ffpyplayer initialises SDL2_mixer from the FIRST audio file it opens
and reuses those parameters, so a file with no audio stream (rate/channels 0)
makes SDL2_mixer dereference garbage. Muting via volume=0.0 does NOT avoid it -
the audio stream itself must be disabled.

play_video now probes the file with ffprobe and forces mute when it has no audio
track, so such a file can never reach ffpyplayer with sound enabled. The probe
fails safe (assumes audio present) if ffprobe is unavailable.

3. 24/7 supervision (solution A + C)

windows/watchdog.ps1 + start_player_watchdog.bat restart the player when it
crashes (process gone) or hangs (process alive but .player_heartbeat stale),
with a crash-loop breaker that backs off when it cannot stay up. This is the
Windows counterpart of the proven Linux start.sh watchdog.

The exit-screen password remains the only supported way to stop the player. On
success it writes .player_stop_requested next to the .exe and the watchdog
stands down instead of restarting. The flag is SESSION SCOPED: the watchdog
clears it on every start, so launching again begins a new session and there is
no file to delete by hand. Clearing on start also means a power cut cannot leave
the player permanently off.

Deliberately NOT done: a Windows service. A service runs in session 0 with no
desktop, so the player could not render to the screen at all. A login-triggered
startup entry is the correct Windows analogue of the Pi's systemd unit.

Important detail: the packaged player is TWO processes (PyInstaller bootloader
parent plus the child that owns the SDL window), so any kill uses taskkill /T or
the visible window survives and the next launch collides with it.

Verified in the packaged exe over a 7-hour run: 170 playlist restarts, 1365
items, 171 web links launched/visible/ended with zero failures, and no crashes,
no hangs and no leaked browser processes.

Tests: windows/test_video_hang.py and windows/test_watchdog.py. The hang test
deliberately holds the heartbeat open with an exclusive Windows lock (share mode
0) so the player's own write fails - backdating the file's mtime does NOT
simulate a hang, because the healthy player rewrites it immediately and the test
would then pass for the wrong reason.
2026-09-13 10:14:42 +03:00
ske087 9f5409685d Embedded WebView2 engine for web links (Windows)
Web links previously launched a separate Chrome/Edge kiosk process, which
caused the whole class of bugs in the tracker: the browser opening behind the
player, being handed off to an already-running 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 instead, so there is
no separate top-level browser to open behind the player, nothing to hand the
URL off to, no z-order contest, and no leaked browser process.

Windows/webview2_browser.py
  - Environment -> controller -> navigate, driven through pythonnet.
  - Async .NET Tasks are polled from Kivy's Clock. Calling GetAwaiter()
    .GetResult() would deadlock: the continuation needs the same thread's
    message pump.
  - The controller is a .NET IntPtr, not a Python int (CreateAsync overloads
    do not match otherwise).
  - NavigationCompleted is tracked so a page that never loads can be told
    apart from one that did. This matters on a closed network: an unreachable
    host paints a Chromium error page, and without this the player would show
    a blank/error screen for the item's whole slot instead of skipping it.
  - is_alive() reports True while starting up. Start-up is async, so a
    controller that does not exist yet is not a dead browser; treating it as
    one made the first web link after a cold start be skipped instantly.

Windows/webview2_runtime.py
  - Detects the Runtime (registry pv value, SDK probe as fallback) and
    installs it silently when missing, unelevated, which produces a per-user
    install and therefore never raises a UAC prompt on the signage display.
  - Success is decided by RE-READING the installed version, not by the
    installer exit code: Edge Update returns a non-zero HRESULT
    (-2147219416) when the Runtime is already current, which is not a failure.
  - On a closed network the online bootstrapper can never succeed, so it fails
    fast with an actionable message instead of hanging for the full timeout.
  - Failed attempts are cooldown-gated so a broken machine does not re-run an
    installer on every start.

Offline hardening
  - Browser arguments disable component updates, field trials, safe-browsing
    list fetches, translate and other internet chatter. On an isolated LAN
    each of those would otherwise have to time out, costing start-up latency.
    Pages on the local server are unaffected.

Engine order (best first): WebView2 -> CEF -> Chrome/Edge subprocess. CEF has
no wheels past Python 3.9 so it is dormant on this build; the subprocess engine
remains only as a last resort.

Also fixes the reason the Windows adapters were never used at all:
SignagePlayer.__init__ assigned self.weblink_adapter_factory = None, which
shadowed the CLASS attribute that run_win.py injects. play_weblink() therefore
fell back to the generic adapter, whose find_browser() uses shutil.which() and
finds nothing on Windows because Chrome/Edge are not on PATH. The instance
attribute is now only set when the class attribute is absent.

Verified: windows/test_webview2_embed.py, test_webview2_navigation.py and
test_webview2_offline.py all pass (a locally served page renders with all
internet traffic disabled), and the packaged exe reports
"weblink_launch engine=webview2-embedded" -> "weblink_launched" on every cycle
with no leaked browser processes.
2026-09-13 10:14:18 +03:00
32 changed files with 4430 additions and 192 deletions
@@ -33,10 +33,19 @@ Cross-platform Kivy digital signage player.
- **Python 3.13+/3.14 is NOT supported for the Windows build** — Kivy 2.3.1 has no wheels - **Python 3.13+/3.14 is NOT supported for the Windows build** — Kivy 2.3.1 has no wheels
for them. for them.
- `cefpython3` is **not installed** in the project venv. The embedded-CEF web-link engine is - `cefpython3` is **not installed** in the project venv (its wheels stop at Python 3.9).
therefore unavailable and web links fall back to the Chrome/Edge subprocess adapter. The embedded-CEF engine in `windows/cef_browser.py` is therefore dormant on this
Installing `cefpython3` (uncomment it in `windows/requirements_win.txt`) restores the build — do not rely on it.
CEF engine. - **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 ## Build
@@ -79,6 +88,118 @@ current spec, so deploying it ships old code. Always take the executable from th
- `excluded_imports` drops Linux-only packages (`evdev`, `gi`, GStreamer) and the - `excluded_imports` drops Linux-only packages (`evdev`, `gi`, GStreamer) and the
non-matching `cefpython3` `.pyd` variants. 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) ## Code Signing (production constraint)
Production PCs run with **Smart App Control enforced** (`VerifiedAndReputablePolicyState=1`, Production PCs run with **Smart App Control enforced** (`VerifiedAndReputablePolicyState=1`,
@@ -96,9 +217,21 @@ UMCI enforced) with **no "Run anyway" bypass** — an unsigned exe is blocked at
Fast syntax gate (no build, seconds): Fast syntax gate (no build, seconds):
``` ```
python -m py_compile src/main.py src/weblink_session.py windows/run_win.py 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 **Close the running player before rebuilding.** A running
`dist\KiwySignagePlayer\KiwySignagePlayer.exe` locks the output and the build fails with `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 "Access is denied". Chrome/Edge kiosk processes left over from web-link playback can also
+37
View File
@@ -61,4 +61,41 @@ Thumbs.db
.player_heartbear .player_heartbear
# NOTE: the line above is a long-standing typo for `.player_heartbeat` and is
# kept only so nothing changes for existing clones. The correct patterns are
# below — without them the player's runtime state files get committed (they
# change every 10 seconds, which would create endless noise and merge conflicts).
.player_heartbeat
.player_stop_requested
logs/startup_marker.txt
logs/startup_crash.log
logs/console_out.txt
logs/console_err.txt
logs/watchdog_test_*.txt
logs/.webview2_install_attempt
windows/venv_build/ 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
# server and play a stale playlist.
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)
.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
+48 -9
View File
@@ -6,9 +6,10 @@ playlist item type (display a live web page / URL instead of an uploaded media
file). file).
> **Status: implemented.** The player supports `weblink` items on both > **Status: implemented.** The player supports `weblink` items on both
> Raspberry Pi (`chromium` subprocess) and Windows (embedded CEF with a > Raspberry Pi (`chromium` subprocess) and Windows (embedded **WebView2**, with
> Chrome/Edge subprocess fallback). Sections 14 describe the original design > the CEF and Chrome/Edge subprocess engines as fallbacks). Sections 14
> plan; section 6 documents the shipped architecture and the interaction model. > describe the original design plan; section 6 documents the shipped
> architecture and the interaction model.
--- ---
@@ -256,18 +257,56 @@ platform:
| Piece | Responsibility | | Piece | Responsibility |
|--------------------------|----------------| |--------------------------|----------------|
| `WeblinkSession` | Owns one weblink item: validate → launch → verify → watch → teardown. Generation-tokened so stale callbacks are ignored, and `atexit`-safe. | | `WeblinkSession` | Owns one weblink item: validate → launch → verify → watch → teardown. Generation-tokened so stale callbacks are ignored, and `atexit`-safe. |
| `WeblinkAdapter` | The only platform-specific part: launch / wait for the window / is it alive / tear it down / pre-warm. | | `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`, Windows `chrome.exe`/`msedge.exe`). | | `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`; on Windows the Chrome/Edge fallback). |
| `InteractionWatcher` | Decides when the item is finished (see the interaction model below). | | `InteractionWatcher` | Decides when the item is finished (see the interaction model below). |
| `WebInputSources` | Reads `/dev/input/event*` (Linux) and does a pointer-position tap (Windows, needed for embedded CEF). | | `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. |
Platform wrappers inject their engines through Platform wrappers inject their engines through
`SignagePlayer.weblink_adapter_factory`: `SignagePlayer.weblink_adapter_factory`:
* **Raspberry Pi / Linux** — built-in Chromium subprocess adapter. * **Raspberry Pi / Linux** — built-in Chromium subprocess adapter.
* **Windows** (`windows/run_win.py`) — embedded CEF first (`cef_browser.py`, * **Windows** (`windows/run_win.py`) — engines are tried in this order:
renders inside the Kivy window: no z-order fights, no subprocess), then the
Chrome/Edge subprocess adapter as fallback. | 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. |
`WeblinkAdapter.extra_launch_args()` is the hook subclasses use to add flags
without duplicating `launch()` — the Chrome 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
> the instance attribute when the class attribute is absent.
### 6.2 Windows: WebView2 Runtime
WebView2 is two separate things, and they ship differently:
* 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`.
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.
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.
### 6.1 Interaction model — web links are not passive media ### 6.1 Interaction model — web links are not passive media
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"server_ip": "192.168.0.108", "server_ip": "192.168.0.110",
"port": "8080", "port": "80",
"screen_name": "WINDOWS-PC", "screen_name": "DESKTOP-NJLBQKH",
"quickconnect_key": "8887779", "quickconnect_key": "8887779",
"orientation": "Landscape", "orientation": "Landscape",
"touch": "True", "touch": "True",
-10
View File
@@ -1,10 +0,0 @@
{
"hostname": "WINDOWS-PC",
"auth_code": "GAh_D0qrgRGS0ERK-AQN0Fx17HvEmV9aA7EHXzszYO4",
"player_id": 2,
"player_name": "Windows-Player1",
"playlist_id": 1,
"orientation": "Landscape",
"authenticated": true,
"server_url": "http://192.168.0.107:8080"
}
-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": "media\\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": "media\\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": "media\\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": "media\\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": "media\\jack-anstey-XVoyX7l9ocY-unsplash.jpg"
}
],
"playlist_id": 1,
"playlist_version": 32
}
+349 -47
View File
@@ -9,6 +9,8 @@ import os
import json import json
import platform import platform
import signal import signal
import subprocess
import sys
import threading import threading
import time import time
import asyncio import asyncio
@@ -91,6 +93,7 @@ from kivy.graphics import Color, Line, Ellipse
from kivy.uix.floatlayout import FloatLayout from kivy.uix.floatlayout import FloatLayout
from kivy.uix.slider import Slider from kivy.uix.slider import Slider
from playback_trace import trace # always-on playback transition logger from playback_trace import trace # always-on playback transition logger
from video_safety import suppress_kivy_video_blocking_unload # bound Kivy's blocking video join
from weblink_session import ( from weblink_session import (
WeblinkSession, WeblinkSession,
WeblinkSettings, WeblinkSettings,
@@ -627,12 +630,19 @@ class ExitPasswordPopup(Popup):
Clock.schedule_once(lambda dt: self.dismiss(), 1) Clock.schedule_once(lambda dt: self.dismiss(), 1)
class SettingsPopup(Popup): class SettingsPopup(Popup):
def __init__(self, player_instance, was_paused=False, **kwargs): def __init__(self, player_instance, was_paused=False, first_run=False, **kwargs):
super(SettingsPopup, self).__init__(**kwargs) super(SettingsPopup, self).__init__(**kwargs)
self.player = player_instance self.player = player_instance
self.was_paused = was_paused self.was_paused = was_paused
self.first_run = bool(first_run)
self.keyboard_widget = None self.keyboard_widget = None
# First-run setup is the ONLY thing on screen and there is no playlist
# yet, so the popup must not be dismissable into a blank player.
if self.first_run:
self.auto_dismiss = False
self.title = 'Player Setup - Not Configured'
# Cancel all scheduled cursor/control hide events # Cancel all scheduled cursor/control hide events
try: try:
if self.player.controls_timer: if self.player.controls_timer:
@@ -649,16 +659,23 @@ class SettingsPopup(Popup):
except: except:
pass pass
# Populate current values # Populate current values. Fields are blank on a fresh install, so the
self.ids.server_input.text = self.player.config.get('server_ip', 'localhost') # hint text tells the operator what to enter.
self.ids.port_input.text = str(self.player.config.get('port', '')) self.ids.server_input.text = self.player.config.get('server_ip', '')
self.ids.screen_input.text = self.player.config.get('screen_name', 'kivy-player') self.ids.port_input.text = str(self.player.config.get('port', '') or '')
self.ids.quickconnect_input.text = self.player.config.get('quickconnect_key', '1234567') self.ids.screen_input.text = self.player.config.get('screen_name', '')
self.ids.quickconnect_input.text = self.player.config.get('quickconnect_key', '')
self.ids.orientation_input.text = self.player.config.get('orientation', 'Landscape') self.ids.orientation_input.text = self.player.config.get('orientation', 'Landscape')
self.ids.touch_input.text = self.player.config.get('touch', 'True') self.ids.touch_input.text = self.player.config.get('touch', 'True')
self.ids.resolution_input.text = self.player.config.get('max_resolution', 'auto') self.ids.resolution_input.text = self.player.config.get('max_resolution', '1920x1080')
self.ids.edit_enabled_checkbox.active = self.player.config.get('edit_feature_enabled', True) self.ids.edit_enabled_checkbox.active = self.player.config.get('edit_feature_enabled', True)
if self.first_run:
Logger.info(
"SettingsPopup: First-run setup opened (server_ip/screen_name/"
"quickconnect_key must be filled in)"
)
# Update status info # Update status info
self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}' self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}'
self.ids.media_count_info.text = f'Media: {len(self.player.playlist)}' self.ids.media_count_info.text = f'Media: {len(self.player.playlist)}'
@@ -711,6 +728,11 @@ class SettingsPopup(Popup):
"""Handle popup dismissal - resume playback and restart cursor hide timer""" """Handle popup dismissal - resume playback and restart cursor hide timer"""
# Hide and remove keyboard # Hide and remove keyboard
self.hide_keyboard() self.hide_keyboard()
if self.first_run:
# Nothing was playing: there is no playlist to resume, and
# scheduling an advance here would race the first sync kicked off
# by on_first_run_config_saved().
return
# Resume playback and re-arm the media advance timer # Resume playback and re-arm the media advance timer
self.player.resume_after_popup(self.was_paused) self.player.resume_after_popup(self.was_paused)
@@ -919,17 +941,41 @@ class SettingsPopup(Popup):
Clock.schedule_once(lambda dt: popup.dismiss(), 2) Clock.schedule_once(lambda dt: popup.dismiss(), 2)
def save_and_close(self): def save_and_close(self):
"""Save configuration and close popup""" """Save configuration and close popup.
During first-run setup the play/pause guard does not apply (there is no
playlist yet) and the player is told to start once the details are in.
"""
# Update config # Update config
self.player.config['server_ip'] = self.ids.server_input.text self.player.config['server_ip'] = self.ids.server_input.text.strip()
self.player.config['port'] = self.ids.port_input.text.strip() self.player.config['port'] = self.ids.port_input.text.strip()
self.player.config['screen_name'] = self.ids.screen_input.text self.player.config['screen_name'] = self.ids.screen_input.text.strip()
self.player.config['quickconnect_key'] = self.ids.quickconnect_input.text self.player.config['quickconnect_key'] = self.ids.quickconnect_input.text.strip()
self.player.config['orientation'] = self.ids.orientation_input.text self.player.config['orientation'] = self.ids.orientation_input.text
self.player.config['touch'] = self.ids.touch_input.text self.player.config['touch'] = self.ids.touch_input.text
self.player.config['max_resolution'] = self.ids.resolution_input.text self.player.config['max_resolution'] = self.ids.resolution_input.text
self.player.config['edit_feature_enabled'] = self.ids.edit_enabled_checkbox.active self.player.config['edit_feature_enabled'] = self.ids.edit_enabled_checkbox.active
# First-run validation: refuse to "configure" the player with blanks,
# otherwise it would be marked configured and then fail on every sync.
if self.first_run:
missing = [
label for label, key in (
('Server IP', 'server_ip'),
('Player Name', 'screen_name'),
('Quick Connect Key', 'quickconnect_key'),
)
if not str(self.player.config.get(key, '') or '').strip()
]
if missing:
self._show_temp_message(
'Required: ' + ', '.join(missing), (1, 0.7, 0, 1)
)
return
# A brand-new install has no reason to keep HTTPS verification on.
self.player.config.setdefault('use_https', False)
self.player.config.setdefault('verify_ssl', False)
# Save to file # Save to file
self.player.save_config() self.player.save_config()
@@ -937,9 +983,80 @@ class SettingsPopup(Popup):
if self.ids.resolution_input.text != self.player.config.get('max_resolution', 'auto'): if self.ids.resolution_input.text != self.player.config.get('max_resolution', 'auto'):
Logger.info("SettingsPopup: Resolution changed - restart required") Logger.info("SettingsPopup: Resolution changed - restart required")
was_first_run = self.first_run
# Close popup # Close popup
self.dismiss() self.dismiss()
if was_first_run:
self.player.on_first_run_config_saved()
# ── 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
# nothing to connect to, so the player shows a notice after the splash video
# and then opens Settings automatically.
#
# These are the values the player cannot work without, plus the placeholder
# values a fresh install used to be given. A config holding only placeholders
# counts as UNCONFIGURED, so a machine that has never been set up runs the
# first-run flow instead of silently trying to reach "localhost".
CONFIG_REQUIRED_KEYS = ('server_ip', 'screen_name', 'quickconnect_key')
CONFIG_PLACEHOLDER_VALUES = {
'server_ip': {'', 'localhost', '127.0.0.1'},
'screen_name': {'', 'kivy-player'},
'quickconnect_key': {'', '1234567'},
}
#: In-memory starting point when no usable config file exists. Deliberately
#: has EMPTY credentials so `config_is_configured()` reports False.
DEFAULT_CONFIG = {
'server_ip': '',
'port': '8080',
'screen_name': '',
'quickconnect_key': '',
'orientation': 'Landscape',
'touch': 'True',
'max_resolution': '1920x1080',
'edit_feature_enabled': True,
'use_https': False,
'verify_ssl': False,
'production_mode': False,
'weblink': {
'engine': 'auto',
'interaction_postpone': 10,
'interaction_debounce': 0.5,
'interaction_grace': 5.0,
'max_dwell_factor': 6.0,
'min_max_dwell': 300,
'launch_timeout': 15,
'prewarm': True,
},
}
#: Seconds the "not configured" notice stays up before Settings opens.
SETUP_NOTICE_SECONDS = 5
def config_is_configured(config):
"""True when ``config`` has enough real values to talk to a server.
Missing file, empty file, unparseable JSON, missing keys and leftover
placeholder values all mean "not configured" — that is what triggers the
first-run setup screen instead of a playlist attempt.
"""
if not isinstance(config, dict) or not config:
return False
for key in CONFIG_REQUIRED_KEYS:
value = str(config.get(key, '') or '').strip()
if not value:
return False
if value.lower() in CONFIG_PLACEHOLDER_VALUES.get(key, set()):
return False
return True
class SignagePlayer(Widget): class SignagePlayer(Widget):
from kivy.properties import StringProperty from kivy.properties import StringProperty
@@ -968,8 +1085,16 @@ class SignagePlayer(Widget):
# watching and teardown for one weblink item at a time. Platform wrappers # watching and teardown for one weblink item at a time. Platform wrappers
# (e.g. windows/run_win.py) inject their adapters via # (e.g. windows/run_win.py) inject their adapters via
# `weblink_adapter_factory` before playback starts. # `weblink_adapter_factory` before playback starts.
#
# CRITICAL: the factory is injected as a *class* attribute, so do NOT
# 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.
self._weblink_session = None self._weblink_session = None
self.weblink_adapter_factory = None if not callable(getattr(type(self), 'weblink_adapter_factory', None)):
self.weblink_adapter_factory = None
self.is_playing = False self.is_playing = False
self.is_paused = False self.is_paused = False
self.auto_resume_event = None # Track scheduled auto-resume self.auto_resume_event = None # Track scheduled auto-resume
@@ -1238,31 +1363,141 @@ class SignagePlayer(Widget):
# Start media playback # Start media playback
Clock.schedule_interval(self.check_playlist_and_play, 30) # Check every 30 seconds Clock.schedule_interval(self.check_playlist_and_play, 30) # Check every 30 seconds
def load_config(self): def requires_setup(self):
"""Load configuration from file""" """True when the player has no usable server configuration."""
Logger.debug("SignagePlayer: load_config() starting...") return not getattr(self, '_configured', config_is_configured(self.config))
def on_intro_finished(self):
"""Splash video has ended — continue with either setup or playback.
Single decision point for "intro done", so the first-run branch and the
normal branch cannot drift apart.
"""
self.intro_played = True
if self.requires_setup():
Logger.warning(
"SignagePlayer: No server configuration - showing setup notice"
)
self.show_setup_required_notice()
return
# Normal start: load whatever playlist is cached and begin playing.
self.check_playlist_and_play(None)
def show_setup_required_notice(self):
"""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
missing, wait SETUP_NOTICE_SECONDS, then open the settings screen so
they can enter the server details.
"""
trace('setup_required_shown')
try: try:
if os.path.exists(self.config_file): self.ids.status_label.text = (
'Player is not configured\n\n'
'No server settings found. Opening setup...'
)
self.ids.status_label.opacity = 1
except Exception:
pass
self._setup_notice_event = Clock.schedule_once(
lambda dt: self._open_first_run_settings(), SETUP_NOTICE_SECONDS
)
def _open_first_run_settings(self):
"""Open Settings for first-run configuration."""
trace('setup_opening_settings')
try:
self.ids.status_label.opacity = 0
except Exception:
pass
popup = SettingsPopup(player_instance=self, first_run=True)
popup.open()
def on_first_run_config_saved(self):
"""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.
"""
self._configured = config_is_configured(self.config)
if not self._configured:
Logger.warning(
"SignagePlayer: Settings saved but the player is still not "
"configured - setup will be offered again"
)
self.show_setup_required_notice()
return
trace('setup_completed')
Logger.info("SignagePlayer: First-run configuration saved - starting playback")
try:
self.ids.status_label.text = 'Configuration saved - connecting...'
self.ids.status_label.opacity = 1
except Exception:
pass
# Re-arm the pieces that depend on the server details.
self.start_network_monitoring()
# Pull the first playlist in the background, then play it.
def _fetch_and_play(dt):
try:
updated = update_playlist_if_needed(
self.config, self.playlists_dir, self.media_dir
)
if updated:
Logger.info("SignagePlayer: Playlist fetched after setup")
except Exception as exc:
Logger.error(f"SignagePlayer: Playlist fetch after setup failed: {exc}")
self.load_playlist()
self.is_playing = False
self.is_paused = False
self.start_playback()
threading.Thread(target=_fetch_and_play, args=(None,), daemon=True).start()
def load_config(self):
"""Load configuration from file.
A missing/empty/unreadable file is NOT an error and is NOT written
back: the player starts with empty credentials and the first-run setup
flow asks the operator to fill them in. Writing a placeholder file here
would defeat that (and used to plant `localhost` as a fake server).
"""
Logger.debug("SignagePlayer: load_config() starting...")
self.config = dict(DEFAULT_CONFIG)
self._config_file_existed = os.path.exists(self.config_file)
try:
if self._config_file_existed:
with open(self.config_file, 'r') as f: with open(self.config_file, 'r') as f:
self.config = json.load(f) loaded = json.load(f)
Logger.info(f"SignagePlayer: Configuration loaded from {self.config_file}") if isinstance(loaded, dict) and loaded:
# Keep defaults for anything the file omits.
self.config.update(loaded)
Logger.info(
f"SignagePlayer: Configuration loaded from {self.config_file}"
)
else:
Logger.warning(
"SignagePlayer: Configuration file is empty or not a JSON "
"object - treating the player as unconfigured"
)
else: else:
# Create default configuration with HTTPS support Logger.warning(
self.config = { f"SignagePlayer: No configuration file at {self.config_file} "
"server_ip": "localhost", "- first-run setup will be shown"
"port": "443", )
"screen_name": "kivy-player",
"quickconnect_key": "1234567",
"max_resolution": "auto",
"use_https": True,
"verify_ssl": True,
"production_mode": False
}
self.save_config()
Logger.info("SignagePlayer: Created default configuration with HTTPS enabled")
except Exception as e: except Exception as e:
Logger.error(f"SignagePlayer: Error loading config: {e}") Logger.error(f"SignagePlayer: Error loading config: {e}")
self.show_error(f"Failed to load configuration: {e}") self.config = dict(DEFAULT_CONFIG)
self._configured = config_is_configured(self.config)
Logger.info(
"SignagePlayer: Configuration status: "
+ ("configured" if self._configured else "NOT configured (setup required)")
)
def save_config(self): def save_config(self):
"""Save configuration to file""" """Save configuration to file"""
@@ -1380,15 +1615,19 @@ class SignagePlayer(Widget):
if not os.path.exists(intro_path): if not os.path.exists(intro_path):
Logger.warning(f"SignagePlayer: Intro video not found at {intro_path}") Logger.warning(f"SignagePlayer: Intro video not found at {intro_path}")
# Skip intro and load playlist # No splash to show: go straight to setup or playback.
self.intro_played = True self.on_intro_finished()
Clock.schedule_once(self.check_playlist_and_play, 0.1)
return return
try: try:
Logger.info("SignagePlayer: Playing intro video...") Logger.info("SignagePlayer: Playing intro video...")
self.ids.status_label.opacity = 0 # Hide status label self.ids.status_label.opacity = 0 # Hide status label
# Same blocking-join hazard as playlist videos: Kivy's on_state
# teardown joins the ffpyplayer decode thread on the calling
# thread. Install the bound join before the widget exists.
suppress_kivy_video_blocking_unload()
# Create video widget for intro # Create video widget for intro
intro_video = Video( intro_video = Video(
source=intro_path, source=intro_path,
@@ -1407,23 +1646,29 @@ class SignagePlayer(Widget):
# Mark intro as played before removing video # Mark intro as played before removing video
self.intro_played = True self.intro_played = True
# Stop and unload the video properly # Do NOT call instance.state = 'stop' / instance.unload()
try: # here: this runs during the Kivy MainThread dispatch of
instance.state = 'stop' # state, and Kivy's Video.unload() joins the ffpyplayer
instance.unload() # decode thread on the CALLING thread — a blocking call
except Exception as e: # that froze the UI for 0.4s..100s+ and, when the thread
Logger.debug(f"SignagePlayer: Could not unload intro video: {e}") # never exited, hung the whole player. Detach the widget
# and let the normal video-teardown worker stop it.
# Remove intro video
try: try:
if intro_video in self.ids.content_area.children: if intro_video in self.ids.content_area.children:
self.ids.content_area.remove_widget(intro_video) self.ids.content_area.remove_widget(intro_video)
except Exception as e: except Exception as e:
Logger.warning(f"SignagePlayer: Error removing intro video widget: {e}") Logger.warning(f"SignagePlayer: Error removing intro video widget: {e}")
threading.Thread(
target=self._teardown_video_async,
args=(intro_video,),
daemon=True,
name='intro-video-teardown',
).start()
# Start normal playlist immediately to reduce white screen # Start normal playlist immediately to reduce white screen
Logger.debug("SignagePlayer: Triggering playlist check after intro") Logger.debug("SignagePlayer: Triggering playlist check after intro")
self.check_playlist_and_play(None) self.on_intro_finished()
except Exception as e: except Exception as e:
Logger.error(f"SignagePlayer: Error in intro end callback: {e}") Logger.error(f"SignagePlayer: Error in intro end callback: {e}")
import traceback import traceback
@@ -1438,9 +1683,8 @@ class SignagePlayer(Widget):
Logger.error(f"SignagePlayer: Error playing intro video: {e}") Logger.error(f"SignagePlayer: Error playing intro video: {e}")
import traceback import traceback
Logger.error(f"SignagePlayer: Traceback: {traceback.format_exc()}") Logger.error(f"SignagePlayer: Traceback: {traceback.format_exc()}")
# Skip intro and load playlist # Skip intro and continue with setup or playback
self.intro_played = True self.on_intro_finished()
Clock.schedule_once(self.check_playlist_and_play, 0.1)
def check_playlist_and_play(self, dt): def check_playlist_and_play(self, dt):
"""Check for playlist updates and ensure playback is running""" """Check for playlist updates and ensure playback is running"""
@@ -1448,6 +1692,11 @@ class SignagePlayer(Widget):
if not self.intro_played: if not self.intro_played:
return return
# An unconfigured player has no server to sync from; leave the setup
# flow in charge (otherwise the 30s timer would fight the notice).
if self.requires_setup():
return
if not self.playlist: if not self.playlist:
self.load_playlist() self.load_playlist()
@@ -1663,6 +1912,39 @@ class SignagePlayer(Widget):
self.show_error(f"Error playing media: {e}") self.show_error(f"Error playing media: {e}")
self._skip_to_next_media() self._skip_to_next_media()
def _video_has_audio(self, video_path):
"""True when the file actually contains an audio stream.
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
(0xc0000005) — observed when a silent 4K clip entered the playlist
after an AAC stereo clip had already been played.
Uses ffprobe (bundled next to the app) and fails SAFE: if we cannot
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')
if not os.path.exists(ffprobe):
# Development run: fall back to PATH / the ffpyplayer bundle.
import shutil as _shutil
ffprobe = _shutil.which('ffprobe')
if not ffprobe:
return True # cannot tell -> assume it has audio
result = subprocess.run(
[ffprobe, '-v', 'error', '-select_streams', 'a',
'-show_entries', 'stream=index', '-of', 'csv=p=0', video_path],
capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
)
return bool((result.stdout or '').strip())
except Exception as exc:
Logger.debug(f"SignagePlayer: audio probe failed for {video_path}: {exc}")
return True # fail safe: let Kivy try
def play_video(self, video_path, duration, muted=False): def play_video(self, video_path, duration, muted=False):
"""Play a video file using Kivy's Video widget with optimizations. """Play a video file using Kivy's Video widget with optimizations.
@@ -1683,8 +1965,28 @@ class SignagePlayer(Widget):
self._skip_to_next_media() self._skip_to_next_media()
return return
# A video with no audio stream must never be handed to ffpyplayer
# with sound enabled: SDL2_mixer access-violates on a silent track.
# Signage has no audio output anyway, so mute it.
if not muted and not self._video_has_audio(video_path):
Logger.info(
"SignagePlayer: Video has no audio track - forcing mute "
"(avoids an SDL2_mixer crash)"
)
muted = True
Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s") Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s")
# Bound the blocking join() Kivy's VideoFFPy performs on the
# 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.
suppress_kivy_video_blocking_unload()
# Create Video widget with optimized settings for smooth playback. # Create Video widget with optimized settings for smooth playback.
# Apply the server's audio:off/muted flag via 'volume' (0.0=mute). # Apply the server's audio:off/muted flag via 'volume' (0.0=mute).
self._video_source = video_path self._video_source = video_path
-10
View File
@@ -1,10 +0,0 @@
{
"hostname": "WINDOWS-PC",
"auth_code": "GAh_D0qrgRGS0ERK-AQN0Fx17HvEmV9aA7EHXzszYO4",
"player_id": 2,
"player_name": "Windows-Player1",
"playlist_id": 1,
"orientation": "Landscape",
"authenticated": true,
"server_url": "http://192.168.0.107:8080"
}
+3
View File
@@ -386,6 +386,7 @@
size_hint_x: 0.7 size_hint_x: 0.7
multiline: False multiline: False
font_size: sp(13) font_size: sp(13)
hint_text: 'e.g. 192.168.0.110'
write_tab: False write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
@@ -432,6 +433,7 @@
size_hint_x: 0.7 size_hint_x: 0.7
multiline: False multiline: False
font_size: sp(13) font_size: sp(13)
hint_text: 'player name registered on the server'
write_tab: False write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
@@ -454,6 +456,7 @@
size_hint_x: 0.7 size_hint_x: 0.7
multiline: False multiline: False
font_size: sp(13) font_size: sp(13)
hint_text: 'e.g. 8887779'
write_tab: False write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
+185
View File
@@ -0,0 +1,185 @@
"""video_safety.py — make Kivy's video teardown non-blocking.
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`.
Cause — a blocking ``join()`` that Kivy performs on the calling thread:
1. At end of stream, ffpyplayer/``VideoFFPy`` fires its ``on_eos`` event.
2. Kivy's ``Video`` widget binds its own handler **first** (in
``kivy/uix/video.py``: ``self._video.bind(..., on_eos=self._on_eos)``).
That handler is::
def _on_eos(self, *largs):
if not self._video or self._video.eos != 'loop':
self.state = 'stop' # <-- fires DURING the on_eos dispatch
3. ``Video.state = 'stop'`` → ``on_state`` → ``VideoFFPy.stop()`` →
``unload()``, which does ``self._thread.join()``
(``video_ffpyplayer.py``: ``# TODO: use callback, don't block here``).
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.
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
decode thread being slow to exit. It is a race, so it looks random and only
appears after many video cycles.
The fix
-------
Bound the wait. ffpyplayer sets ``_ffplayer_need_quit = True`` and wakes its
thread before joining, so the thread *is* asked to exit — we simply stop
waiting indefinitely for it. ``suppress_kivy_video_blocking_unload()`` patches
the join on the specific ``VideoFFPy`` thread object to a bounded timeout, so a
wedged decode thread can no longer take the whole player down. The teardown
still runs off the main thread (see ``_teardown_video_async`` in ``main.py``),
so the normal case is unaffected.
This is deliberately narrow: only Kivy's own internal video thread is patched,
only its ``join`` timeout is bounded, and every failure path leaves Kivy
untouched.
"""
from __future__ import annotations
import threading
import time
#: How long a video's decode thread may take to exit before we give up on it.
#: A healthy ffpyplayer thread exits in well under a second; this allows a lot
#: of slack while still guaranteeing the UI thread is never parked forever.
DEFAULT_JOIN_TIMEOUT = 5.0
_patched_threads = 0
_lock = threading.Lock()
def _log(message):
try:
from kivy.logger import Logger
Logger.info(f'[VideoSafety] {message}')
except Exception:
print(f'[VideoSafety] {message}')
def suppress_kivy_video_blocking_unload(timeout=DEFAULT_JOIN_TIMEOUT):
"""Bound the join() Kivy's VideoFFPy.unload() performs on the caller.
Must be called *before* the video widget is constructed, because the
decode thread is created during ``play()``. Safe to call repeatedly.
Args:
timeout: maximum seconds to wait for the decode thread to exit.
Returns:
True when the guard was installed (or already present).
"""
global _patched_threads
if timeout is None or float(timeout) <= 0:
return False
try:
# Importing this module has the side effect of selecting the
# ffpyplayer provider; if a different provider is active (or video
# support is missing) there is nothing to patch.
from kivy.core.video import Video as CoreVideo
if CoreVideo is None:
return False
except Exception as exc:
_log(f'video provider unavailable, nothing to patch ({exc})')
return False
try:
from kivy.core.video import Video
except Exception:
Video = None
if Video is None:
return False
# VideoFFPy resolves via the current provider. Import it directly so we
# patch the right class even if the provider changes later.
try:
from kivy.core.video import video_ffpyplayer as _vfp
except Exception as exc:
_log(f'ffpyplayer provider not active, nothing to patch ({exc})')
return False
provider = getattr(_vfp, 'VideoFFPy', None)
if provider is None:
return False
with _lock:
if getattr(provider, '_kiwy_bounded_join', False):
return True
original_play = provider.play
if getattr(provider, '_kiwy_original_play', None) is None:
provider._kiwy_original_play = original_play
def play(self, *args, **kwargs):
result = provider._kiwy_original_play(self, *args, **kwargs)
_bound_thread_join(self, timeout)
return result
provider.play = play
provider._kiwy_bounded_join = True
_patched_threads += 1
_log(f'Kivy video unload join() bounded to {float(timeout):.1f}s')
return True
def _bound_thread_join(provider_instance, timeout):
"""Replace the provider's internal thread join with a bounded one.
ffpyplayer has already been told to quit (``_ffplayer_need_quit = True``)
and its thread woken before ``unload()`` joins, so bounding the wait does
not leak work — it only stops an unresponsive decode thread from freezing
the whole application.
"""
thread = getattr(provider_instance, '_thread', None)
if thread is None:
return
if getattr(thread, '_kiwy_bounded_join', False):
return
try:
real_join = thread.join
except Exception:
return
def bounded_join(join_timeout=None):
"""Join with a hard upper bound; never block the caller forever."""
limit = float(timeout if join_timeout is None else min(join_timeout, timeout))
started = time.monotonic()
try:
real_join(timeout=limit)
except TypeError:
# Some Python builds dislike an explicit keyword here.
try:
real_join(limit)
except Exception:
pass
except Exception:
pass
if thread.is_alive():
_log(
f'video decode thread still alive {time.monotonic() - started:.1f}s '
'after being asked to quit - continuing without it'
)
try:
thread.join = bounded_join
thread._kiwy_bounded_join = True
except Exception as exc:
_log(f'could not bound video thread join ({exc})')
+27 -1
View File
@@ -223,6 +223,13 @@ class WeblinkAdapter:
#: The session then skips the "browser window appeared" requirement. #: The session then skips the "browser window appeared" requirement.
embedded = False embedded = False
#: Set by adapters that render in-window but can still *prove* the page
#: appeared (WebView2). 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.
can_verify_visibility = False
#: The launched process, when the engine is subprocess based. The session #: The launched process, when the engine is subprocess based. The session
#: mirrors this onto the player's historic ``_weblink_proc`` attribute. #: mirrors this onto the player's historic ``_weblink_proc`` attribute.
process = None process = None
@@ -705,6 +712,17 @@ class ChromiumSubprocessAdapter(WeblinkAdapter):
# Nothing generic to do; the platform layer hooks in here (overlay). # Nothing generic to do; the platform layer hooks in here (overlay).
pass pass
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.
"""
return ()
def launch(self, url, width, height): def launch(self, url, width, height):
browser = self._browser or self.find_browser() browser = self._browser or self.find_browser()
if not browser: if not browser:
@@ -735,6 +753,7 @@ class ChromiumSubprocessAdapter(WeblinkAdapter):
'--force-device-scale-factor=1', '--force-device-scale-factor=1',
] ]
args += self._extra_flags args += self._extra_flags
args += [str(arg) for arg in self.extra_launch_args()]
self._proc = subprocess.Popen(args) self._proc = subprocess.Popen(args)
return True return True
@@ -958,6 +977,13 @@ class WeblinkSession:
with self._lock: with self._lock:
if self._watcher is not None: if self._watcher is not None:
self._watcher.stop() self._watcher.stop()
# An embedded engine normally cannot be verified (CEF), but some can
# (WebView2) — 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 (
not adapter.embedded or adapter.can_verify_visibility
) else None
self._watcher = InteractionWatcher( self._watcher = InteractionWatcher(
duration=duration, duration=duration,
alive_check=adapter.is_alive, alive_check=adapter.is_alive,
@@ -972,7 +998,7 @@ class WeblinkSession:
interaction_debounce=self.settings.interaction_debounce, interaction_debounce=self.settings.interaction_debounce,
interaction_grace=self.settings.interaction_grace, interaction_grace=self.settings.interaction_grace,
embedded=adapter.embedded, embedded=adapter.embedded,
wait_visible=None if adapter.embedded else adapter.wait_visible, wait_visible=verify,
visible_timeout=self.settings.launch_timeout, visible_timeout=self.settings.launch_timeout,
launched_at=launched_at, launched_at=launched_at,
) )
+85 -3
View File
@@ -90,12 +90,94 @@ windows\dist\KiwySignagePlayer\
For a **single-file .exe**, edit `build.spec` — uncomment the `exe_onefile` section and comment out the `coll = COLLECT(...)` section. 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 ## ⚙️ Configuration
1. On first run, config files are created in the **same folder as the executable** (not in `%APPDATA%`) **No configuration ships with the exe.** On a machine that has never been set
- The .exe creates: `config/`, `media/`, `playlists/`, `logs/` directories locally 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 - This allows you to copy the entire `dist\KiwySignagePlayer\` folder anywhere and it works
2. Edit `config\app_config.json` (next to the .exe) to set your server: 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 ```json
{ {
+67 -5
View File
@@ -103,6 +103,8 @@ hidden_imports = [
'tempfile', 'tempfile',
# Windows-specific # Windows-specific
'cef_browser', 'cef_browser',
'webview2_browser',
'webview2_runtime',
'win32gui', 'win32gui',
'win32con', 'win32con',
# Unified web-link controller (launch / verified visibility / interaction # Unified web-link controller (launch / verified visibility / interaction
@@ -139,11 +141,60 @@ for item in RESOURCES_DIR.iterdir():
target_dir = 'config/resources' target_dir = 'config/resources'
resources_data.append((str(item), target_dir)) resources_data.append((str(item), target_dir))
# Config directory (app_config.json) # 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_data = []
config_file = CONFIG_DIR / 'app_config.json' config_file = CONFIG_DIR / 'app_config.json'
if config_file.exists(): if config_file.exists():
config_data.append((str(config_file), 'config')) 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 # Source files - .kv file
kv_file = SRC_DIR / 'signage_player.kv' kv_file = SRC_DIR / 'signage_player.kv'
@@ -151,8 +202,19 @@ kv_data = []
if kv_file.exists(): if kv_file.exists():
kv_data.append((str(kv_file), '.')) kv_data.append((str(kv_file), '.'))
# Bundle the entire src directory as a tree # Bundle the entire src directory as a tree.
source_tree = Tree(str(SRC_DIR), prefix='', excludes=['*.pyc', '__pycache__', '*.ini']) #
# 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 ---------------- # --- Collect binary DLLs from kivy_deps and ffpyplayer ----------------
import importlib.util import importlib.util
@@ -250,7 +312,7 @@ a = Analysis(
['run_win.py'], # Entry point (relative to this spec) ['run_win.py'], # Entry point (relative to this spec)
pathex=[str(BUILD_DIR), str(SRC_DIR)], # Where to find modules pathex=[str(BUILD_DIR), str(SRC_DIR)], # Where to find modules
binaries=_all_binaries, binaries=_all_binaries,
datas=resources_data + config_data + kv_data, datas=resources_data + config_data + kv_data + webview2_data,
hiddenimports=hidden_imports, hiddenimports=hidden_imports,
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
+7 -2
View File
@@ -110,13 +110,18 @@ def _setup_paths():
def _copy_bundled_resources(): def _copy_bundled_resources():
"""Copy bundled resource/config files to the local folders on first run.""" """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 exe_dir = Path(sys.executable).parent
internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir)) internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir))
# Files to copy (source in bundle -> destination next to .exe) # Files to copy (source in bundle -> destination next to .exe)
files_to_copy = [ files_to_copy = [
('config/app_config.json', 'config/app_config.json'),
('config/resources/access-card.png', 'config/resources/access-card.png'), ('config/resources/access-card.png', 'config/resources/access-card.png'),
('config/resources/arrow.png', 'config/resources/arrow.png'), ('config/resources/arrow.png', 'config/resources/arrow.png'),
('config/resources/backward.png', 'config/resources/backward.png'), ('config/resources/backward.png', 'config/resources/backward.png'),
+12
View File
@@ -23,6 +23,18 @@ bcrypt>=4.2.0,<5.0.0
# PyInstaller for building the .exe # PyInstaller for building the .exe
pyinstaller>=6.0 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 --- # --- Windows-specific Libraries ---
# cefpython3: Embedded Chromium browser (replaces subprocess Chrome/Edge) # cefpython3: Embedded Chromium browser (replaces subprocess Chrome/Edge)
# Installed separately because it's a large package (69 MB): # Installed separately because it's a large package (69 MB):
+377 -2
View File
@@ -251,6 +251,93 @@ def _get_cef_browser():
return _CEF_BROWSER return _CEF_BROWSER
# ── Embedded WebView2 (Edge) browser ────────────────────────────────
_WEBVIEW2_BROWSER = None
#: How long a weblink will wait for a Runtime that is still installing.
_WEBVIEW2_INSTALL_WAIT = 300.0
def _ensure_webview2_runtime_async():
"""Start installing the WebView2 Runtime if it is missing.
Called once at start-up. The install runs on a background thread so a
machine without the Runtime can install it while the player is still
syncing its playlist, rather than freezing the UI on the first weblink.
"""
try:
import webview2_runtime
except Exception as exc:
_log(f"WebView2 runtime module unavailable: {exc}")
return
try:
_log(webview2_runtime.describe())
webview2_runtime.ensure_runtime_async()
except Exception as exc:
_log(f"WebView2 runtime check failed (non-fatal): {exc}")
def _get_webview2_browser(force_new=False):
"""Return the shared WebView2Browser singleton, or None if unavailable.
WebView2 renders as a CHILD HWND of Kivy's SDL window, which is what makes
it immune to the subprocess weblink bugs (background window, instant
hand-off exit, z-order fights, leaked browsers).
"""
global _WEBVIEW2_BROWSER
if force_new:
old, _WEBVIEW2_BROWSER = _WEBVIEW2_BROWSER, None
if old is not None:
try:
old.shutdown()
except Exception:
pass
if _WEBVIEW2_BROWSER is None:
try:
from webview2_browser import WebView2Browser
except Exception as exc:
_log(f"WebView2 module unavailable: {exc}")
return None
if not WebView2Browser.is_available():
# The SDK is bundled but the Runtime may still be installing
# (a machine that shipped without it). Wait here rather than
# silently downgrading to the leaky Chrome/Edge engine — this runs
# on the watcher thread, not the Kivy main thread.
if _wait_for_webview2_runtime():
pass # installed in the meantime; retry below
if not WebView2Browser.is_available():
reason = getattr(WebView2Browser, '_import_error', None) or 'unavailable'
_log(f"WebView2 not usable: {reason}")
return None
try:
data_dir = os.environ.get('KIWY_DATA_DIR', os.getcwd())
_WEBVIEW2_BROWSER = WebView2Browser(
hwnd_provider=_find_kivy_hwnd,
user_data_dir=os.path.join(data_dir, '.webview2-profile'),
)
except Exception as exc:
_log(f"WebView2 init failed: {exc}")
return None
return _WEBVIEW2_BROWSER
def _wait_for_webview2_runtime(timeout=_WEBVIEW2_INSTALL_WAIT):
"""Block while a Runtime install is in flight. True if it became available."""
try:
import webview2_runtime
except Exception:
return False
state = webview2_runtime.get_state()
if not state.get('installing'):
return False
_log("WebView2: waiting for the Runtime install to finish...")
ok = webview2_runtime.wait_for_install(timeout)
_log(f"WebView2: Runtime install wait finished (available={ok})")
return ok
def _windows_find_browser(): def _windows_find_browser():
"""Find Chrome or Edge executable on Windows for weblink support. """Find Chrome or Edge executable on Windows for weblink support.
@@ -1472,6 +1559,167 @@ def _patch_main():
# CEF keeps one browser instance alive; there is nothing to warm. # CEF keeps one browser instance alive; there is nothing to warm.
pass pass
class _WinWebView2Adapter(ChromiumSubprocessAdapter):
"""Embedded WebView2 (Edge) — renders INSIDE the Kivy window.
This is the preferred Windows engine. Because the page is a child HWND
of Kivy's own SDL window there is no separate browser process, so none
of the subprocess problems apply: it cannot open behind the player, it
cannot be handed off to an existing instance and exit instantly, it
does not fight for foreground/z-order, and teardown leaves no leaked
browser behind.
``launch`` returns as soon as the (asynchronous) start-up is requested;
the session's watcher thread then polls :meth:`wait_visible` until the
page is actually showing.
"""
name = 'webview2-embedded'
embedded = True
# WebView2 renders in-window, but unlike raw CEF we CAN prove the page
# appeared (the controller reports when it is visible). That matters on
# a closed network: if the weblink host is unreachable the page never
# paints and the item is skipped instead of showing a blank screen.
can_verify_visibility = True
def __init__(self):
super().__init__(kiosk=False)
self._browser = None
self._resize_bound = False
@property
def process(self):
return None # in-process: nothing to kill
def target_size(self):
"""Use the real Kivy window size (already DPI-aware)."""
try:
from kivy.core.window import Window as KivyWindow
width, height = (int(KivyWindow.size[0]), int(KivyWindow.size[1]))
if width > 0 and height > 0:
return width, height
except Exception:
pass
return 1920, 1080
def launch(self, url, width, height):
self._browser = _get_webview2_browser()
if self._browser is None:
return False
# A child HWND has no z-order to fight over, but the Kivy window
# itself must own the foreground or the page looks inert.
_bring_kivy_to_front(async_ok=False)
self._bind_resize_once()
self._browser.resize(width, height)
return bool(self._browser.show(url))
def is_alive(self):
browser = self._browser
if browser is None:
return False
# NOTE: `is_alive` must be True while the page is still coming up.
# WebView2 creates its environment and controller asynchronously, so
# a controller that does not exist yet is NOT a dead browser —
# treating it as one made the first weblink be skipped instantly.
return browser.is_alive()
def wait_visible(self, timeout):
"""Wait for the page to actually load, on the caller's (watcher) thread.
Two things must hold: the controller must be showing, AND the
navigation must have completed successfully. The second condition is
what makes an unreachable host (normal on a closed network) skip the
item instead of displaying Chromium's error page for the full slot.
"""
import time
browser = self._browser
if browser is None:
return False, 'no browser'
deadline = time.monotonic() + max(1.0, float(timeout))
while time.monotonic() < deadline:
if browser.failed_reason:
return False, browser.failed_reason
nav = browser.navigation_succeeded()
if browser.is_showing() and nav is not None:
if not nav:
detail = browser.navigation_status() or 'navigation failed'
return False, f'page failed to load ({detail})'
# Let the compositor paint the first frame before any
# masking overlay is hidden / Kivy is restored.
time.sleep(0.3)
return True, 'webview2-loaded'
time.sleep(0.1)
if browser.is_showing() and browser.navigation_succeeded() is None:
# Controller is up but navigation never reported within the
# timeout. Treat as visible rather than skipping a page that is
# merely slow to confirm.
return True, 'webview2-showing-unconfirmed'
reason = browser.failed_reason or 'webview2 did not show'
return False, reason
def on_visible(self):
trace('win_weblink_webview2_shown')
def on_launch_failed(self):
# Never leave a half-built controller (or a hidden child window)
# behind when start-up fails.
browser, self._browser = self._browser, None
if browser is not None:
try:
browser.shutdown()
except Exception:
pass
_get_webview2_browser(force_new=True)
def _bind_resize_once(self):
"""Keep the page matched to the window, bound exactly once.
Rebinding per cycle was the unbounded-callback leak fixed for CEF;
the same discipline applies here.
"""
if self._resize_bound:
return
try:
from kivy.core.window import Window as KivyWindow
def _wv2_resize(*_args):
try:
browser = self._browser
if browser is not None and browser.is_showing():
browser.resize(
int(KivyWindow.size[0]), int(KivyWindow.size[1])
)
except Exception:
pass
KivyWindow.bind(size=_wv2_resize)
self._resize_bound = True
except Exception as exc:
_log(f"WebView2 resize bind failed (non-fatal): {exc}")
def teardown(self):
"""Hide the page (keep the controller for a fast next show).
Hiding — not disposing — is deliberate: the controller stays alive
so the next weblink paints immediately, and because it is a child
window there is no leaked process to reap.
"""
browser = self._browser
if browser is not None:
try:
browser.hide()
except Exception as exc:
_log(f"WebView2 hide failed (non-fatal): {exc}")
# Restore Kivy as the visible surface again.
_bring_kivy_to_front(async_ok=False)
def prewarm(self, url):
# The environment/controller are already warm after the first use;
# pre-navigating would claim the page before its slot.
pass
class _WinChromeAdapter(ChromiumSubprocessAdapter): class _WinChromeAdapter(ChromiumSubprocessAdapter):
"""Chrome/Edge kiosk subprocess with the Windows visibility check. """Chrome/Edge kiosk subprocess with the Windows visibility check.
@@ -1493,6 +1741,26 @@ def _patch_main():
def on_launch_failed(self): def on_launch_failed(self):
_Win32Overlay.hide() _Win32Overlay.hide()
def extra_launch_args(self):
"""Dedicated profile + kiosk flags for the Windows browser.
``--user-data-dir`` is mandatory: without it Chrome/Edge hands the
URL to an already-running instance, our launched process exits in
~2s and the weblink never displays (the adapter's `wait_visible`
then sees the process die and the item is skipped). It also gives
us a top-level window we can enumerate, raise and taskkill without
touching the operator's own browser profile.
"""
args = []
if self._profile_dir:
args.append('--user-data-dir=' + self._profile_dir)
if self._kiosk:
# The base launch() already adds --start-fullscreen /
# --start-maximized; --kiosk upgrades that to a true kiosk
# window (no browser UI, locks to the screen).
args.append('--kiosk')
return args
def launch(self, url, width, height): def launch(self, url, width, height):
# A dedicated --user-data-dir is mandatory: without it Chrome hands # A dedicated --user-data-dir is mandatory: without it Chrome hands
# the URL to an existing process, the launched process exits # the URL to an existing process, the launched process exits
@@ -1556,15 +1824,28 @@ def _patch_main():
def _windows_weblink_adapter_factory(player): def _windows_weblink_adapter_factory(player):
"""Choose the Windows web-link engines, best first. """Choose the Windows web-link engines, best first.
CEF (embedded) is preferred when available because it cannot fight for Order matters:
z-order or foreground; the Chrome/Edge subprocess is the fallback.
1. **WebView2 (embedded)** — a child HWND of the Kivy window. It cannot
open behind the player, cannot be handed off to an existing browser
and exit instantly, does not fight for z-order/foreground, and
leaves no leaked process behind. This is the preferred engine.
2. **CEF (embedded)** — same in-window model, but cefpython3 has no
wheels past Python 3.9 so it is dormant on this build.
3. **Chrome/Edge subprocess** — last resort only. Kept so a machine
without the WebView2 runtime still shows weblinks.
""" """
adapters = [] adapters = []
if _get_webview2_browser() is not None:
adapters.append(_WinWebView2Adapter())
if _get_cef_browser() is not None: if _get_cef_browser() is not None:
adapters.append(_WinCefAdapter()) adapters.append(_WinCefAdapter())
browser = _windows_find_browser() browser = _windows_find_browser()
if browser: if browser:
adapters.append(_WinChromeAdapter(browser)) adapters.append(_WinChromeAdapter(browser))
if not adapters:
_log("WebView2/CEF unavailable and no Chrome/Edge found — "
"web links will be skipped")
return adapters return adapters
signage_main.SignagePlayer.weblink_adapter_factory = staticmethod( signage_main.SignagePlayer.weblink_adapter_factory = staticmethod(
@@ -1657,6 +1938,85 @@ def _patch_main():
signage_main.SettingsPopup.test_connection = _windows_test_connection signage_main.SettingsPopup.test_connection = _windows_test_connection
# ── Keep player auth OUT of the bundle ───────────────────────────
# `player_auth.json` used to be a tracked file inside src/, so PyInstaller
# bundled it and, because the frozen app runs with cwd = _internal/, the
# player loaded it as its live auth state. A stale snapshot therefore made
# a fresh build boot "already authenticated" against an old server and play
# an outdated playlist.
#
# Fix: never read a relative auth path from the bundle. Any relative
# 'player_auth.json' is redirected to the data dir next to the .exe, which
# is the single source of truth for this install.
import player_auth as _player_auth_module
_bundled_auth = os.path.join(
getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))),
'player_auth.json',
)
_local_auth = os.path.join(DATA_DIR, 'player_auth.json')
# If an older build left the bundled snapshot next to the exe, drop it:
# it names another server and would be trusted on the next start-up.
try:
if os.path.isfile(_local_auth):
import json as _json
with open(_local_auth, 'r') as _f:
_existing = _json.load(_f)
_existing_url = str(_existing.get('server_url') or '')
_wanted_ip = str(os.environ.get('KIWY_SERVER_IP') or '')
if _wanted_ip and _wanted_ip not in _existing_url:
Logger.warning(
"SignagePlayer: stored auth points at %s but the configured "
"server is %s - clearing it so the player re-authenticates",
_existing_url or '(none)', _wanted_ip,
)
os.remove(_local_auth)
except Exception as _exc:
Logger.debug(f"SignagePlayer: auth pre-check skipped: {_exc}")
_original_auth_init = _player_auth_module.PlayerAuth.__init__
def _windows_auth_init(self, config_file='player_auth.json',
use_https=True, verify_ssl=True):
"""Force the auth file to live in the data dir next to the .exe."""
try:
if not os.path.isabs(config_file):
config_file = os.path.join(DATA_DIR, os.path.basename(config_file))
except Exception:
config_file = _local_auth
_original_auth_init(self, config_file, use_https=use_https, verify_ssl=verify_ssl)
_player_auth_module.PlayerAuth.__init__ = _windows_auth_init
Logger.info(f"SignagePlayer: player auth file -> {_local_auth}")
# `get_playlists_v2` caches a global auth instance created with the default
# relative path; make sure its cache is empty so the redirect above applies.
try:
import get_playlists_v2 as _gp
if _gp._auth_instance is not None:
_gp._auth_instance = None
except Exception as _exc:
Logger.debug(f"SignagePlayer: could not reset auth cache: {_exc}")
# `reset_player_auth` deleted the file next to main.py (i.e. inside the
# bundle, which is read-only and not what we load). Point it at the real
# auth file so the "Reset auth" button actually works.
def _windows_reset_player_auth(self):
try:
if os.path.exists(_local_auth):
os.remove(_local_auth)
Logger.info(f"SettingsPopup: Deleted authentication file: {_local_auth}")
self._show_temp_message(
'✓ Authentication reset - will reauthenticate on restart', (0, 1, 0, 1)
)
except Exception as exc:
Logger.error(f"SettingsPopup: Failed to reset auth: {exc}")
signage_main.SettingsPopup.reset_player_auth = _windows_reset_player_auth
# ── Patch apply_kiosk_mode for Windows ────────────────────────── # ── Patch apply_kiosk_mode for Windows ──────────────────────────
# Wrap the base implementation (exit_on_escape + close guard + Ctrl+C) # Wrap the base implementation (exit_on_escape + close guard + Ctrl+C)
# and add the low-level keyboard hook that swallows Alt+F4 / Alt+Tab / # and add the low-level keyboard hook that swallows Alt+F4 / Alt+Tab /
@@ -1702,6 +2062,13 @@ def _patch_main():
Logger.info("SignagePlayer: Windows card reader shut down") Logger.info("SignagePlayer: Windows card reader shut down")
except Exception as e: except Exception as e:
Logger.debug(f"SignagePlayer: card reader shutdown error: {e}") Logger.debug(f"SignagePlayer: card reader shutdown error: {e}")
# Tear down the embedded web engine. Because it is a child HWND (not a
# subprocess) this is what stops it surviving the player on exit.
try:
_get_webview2_browser(force_new=True)
Logger.info("SignagePlayer: WebView2 browser shut down")
except Exception as e:
Logger.debug(f"SignagePlayer: WebView2 shutdown error: {e}")
_original_on_stop(self) _original_on_stop(self)
signage_main.SignagePlayerApp.on_stop = _windows_on_stop signage_main.SignagePlayerApp.on_stop = _windows_on_stop
@@ -1829,6 +2196,14 @@ if __name__ == '__main__':
Logger.info(f"Data directory: {DATA_DIR}") Logger.info(f"Data directory: {DATA_DIR}")
Logger.info("=" * 80) Logger.info("=" * 80)
# ── Ensure the WebView2 Runtime is present ───────────────────
# The bundled SDK is only an API surface; the Runtime holds the actual
# engine. Windows 11 and most Windows 10 machines already have it, but
# when it is missing we install it silently (per-user, so no UAC prompt
# appears on the signage display). Runs in the background so start-up
# is not blocked.
_ensure_webview2_runtime_async()
# Patch base_dir in SignagePlayer instances to point to local data folder # Patch base_dir in SignagePlayer instances to point to local data folder
_original_init = patched_main.SignagePlayer.__init__ _original_init = patched_main.SignagePlayer.__init__
+48
View File
@@ -0,0 +1,48 @@
@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
@@ -0,0 +1,111 @@
"""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())
+139
View File
@@ -0,0 +1,139 @@
"""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
@@ -0,0 +1,438 @@
"""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
@@ -0,0 +1,169 @@
"""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
@@ -0,0 +1,186 @@
"""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
@@ -0,0 +1,221 @@
"""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
@@ -0,0 +1,106 @@
"""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())
+441
View File
@@ -0,0 +1,441 @@
<#
=====================================================================
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
@@ -0,0 +1,718 @@
"""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
@@ -0,0 +1,446 @@
"""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}')
@@ -0,0 +1,65 @@
# 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.
-10
View File
@@ -1,10 +0,0 @@
{
"hostname": "tv-terasa",
"auth_code": "iiSyZDLWGyqNIxeRt54XYREgvAio11RwwU1_oJev6WI",
"player_id": 1,
"player_name": "TV-acasa 1",
"playlist_id": 1,
"orientation": "Landscape",
"authenticated": true,
"server_url": "http://digi-signage.moto-adv.com"
}