Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f437aba1fc | |||
| 864ea06996 | |||
| 079d8c7f9d | |||
| 477128de81 | |||
| d8c6ab0bc5 | |||
| 9f5409685d | |||
| eb8e66e427 |
@@ -0,0 +1,273 @@
|
|||||||
|
---
|
||||||
|
description: "Use when building, compiling, packaging or releasing the Kiwy Signage Player: PyInstaller build, .exe generation, build.spec, hiddenimports, code signing, Smart App Control, Windows development, Raspberry Pi deployment, or adding new modules under src/. Covers the exact build commands, environment constraints, bundling rules and verification steps."
|
||||||
|
name: "Kiwy Build & Development"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kiwy Signage Player — Build & Development
|
||||||
|
|
||||||
|
Cross-platform Kivy digital signage player.
|
||||||
|
|
||||||
|
- **Raspberry Pi / Linux** — `src/main.py` is the entry point (root `install.sh`, `start.sh`).
|
||||||
|
- **Windows** — `windows/run_win.py` is the entry point; it sets Windows env vars, imports
|
||||||
|
`main.py`, then monkey-patches platform differences. Packaged to `.exe` with PyInstaller.
|
||||||
|
|
||||||
|
## Ground Rules
|
||||||
|
|
||||||
|
- **Keep `src/main.py` cross-platform.** Windows-specific behaviour belongs in
|
||||||
|
`windows/run_win.py` (see `_patch_main()`), Pi-specific behaviour in `main.py` guarded
|
||||||
|
by capability checks. Do not add Windows-only imports to `main.py`.
|
||||||
|
- **Rebuild is mandatory.** The `.exe` bundles `src/`, so Python edits are invisible until
|
||||||
|
you rebuild. There is no hot reload in the packaged app.
|
||||||
|
- **`dist/` is a runtime folder, not just build output.** It holds `config/`, `media/`,
|
||||||
|
`playlists/`, `logs/`, `.kiosk-profile/` and `.player_heartbeat`. Never wipe `dist/`
|
||||||
|
blindly; a `--clean` rebuild preserves it, but a manual delete destroys player state.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|-------|
|
||||||
|
| Interpreter | **Python 3.12.9** (64-bit) — project venv at `windows\venv` |
|
||||||
|
| Kivy | 2.3.1 |
|
||||||
|
| PyInstaller | 6.21.0 |
|
||||||
|
| Linux target | Python 3.13 (`repo/python-wheels/`, `repo/system-packages/`) |
|
||||||
|
|
||||||
|
- **Python 3.13+/3.14 is NOT supported for the Windows build** — Kivy 2.3.1 has no wheels
|
||||||
|
for them.
|
||||||
|
- `cefpython3` is **not installed** in the project venv (its wheels stop at Python 3.9).
|
||||||
|
The embedded-CEF engine in `windows/cef_browser.py` is therefore dormant on this
|
||||||
|
build — do not rely on it.
|
||||||
|
- **Web links use embedded WebView2** (`windows/webview2_browser.py`), driven through
|
||||||
|
`pythonnet` (installed). Two extra things ship with the exe:
|
||||||
|
- `windows/webview2_sdk/` — ~860 KB of SDK DLLs (`Microsoft.Web.WebView2.Core.dll`,
|
||||||
|
`WebView2Loader.dll`). **Tracked in git on purpose**: without them WebView2
|
||||||
|
silently downgrades to the Chrome/Edge subprocess engine.
|
||||||
|
- `windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe` — ~1.7 MB bootstrapper,
|
||||||
|
used to install the Runtime on a machine that lacks it. The ~203 MB offline
|
||||||
|
standalone installer is **git-ignored**; fetch it with
|
||||||
|
`windows/webview2_runtime/download_runtime_installers.ps1 -Offline` when you need
|
||||||
|
to deploy to machines with no internet (it makes the exe ~305 MB).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
From `windows\`:
|
||||||
|
|
||||||
|
```
|
||||||
|
venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||||
|
```
|
||||||
|
|
||||||
|
Or the full one-click path (creates venv, installs deps, builds, then signs):
|
||||||
|
|
||||||
|
```
|
||||||
|
build_win.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Output (folder mode, via `COLLECT`):
|
||||||
|
|
||||||
|
```
|
||||||
|
windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe <- the real build output
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deployment hazard:** `windows\dist\KiwySignagePlayer.exe` (one level up) is a stale
|
||||||
|
leftover from an older single-file build of the same name. It is not refreshed by the
|
||||||
|
current spec, so deploying it ships old code. Always take the executable from the
|
||||||
|
`KiwySignagePlayer\` subfolder, and delete the stray copy if it reappears.
|
||||||
|
|
||||||
|
### `build.spec` facts
|
||||||
|
|
||||||
|
- Entry point `run_win.py`; `pathex=[BUILD_DIR, SRC_DIR]` so `src/` modules resolve.
|
||||||
|
- `runtime_hooks=[pyi_runtime_hook.py]` — sets per-monitor DPI awareness **before** SDL/Kivy
|
||||||
|
initialise. Keep DPI work here; it must run before any window is created.
|
||||||
|
- `datas += Tree(src)` bundles all of `src/`. `console=True` so startup errors and the Kivy
|
||||||
|
log remain visible — do not flip this to `False` without a reason.
|
||||||
|
- `hiddenimports` must list modules that PyInstaller's static analysis **cannot** see:
|
||||||
|
lazily imported ones (`cef_browser` is imported inside a function) or dynamic imports
|
||||||
|
(`getattr`, `importlib`). Top-level imports such as `weblink_session` are found
|
||||||
|
automatically via `pathex`, but listing them is harmless insurance.
|
||||||
|
**When you add a module under `src/` that is imported lazily or by string, add it to
|
||||||
|
`hiddenimports` or the packaged exe will fail at runtime with `ModuleNotFoundError`.**
|
||||||
|
- `excluded_imports` drops Linux-only packages (`evdev`, `gi`, GStreamer) and the
|
||||||
|
non-matching `cefpython3` `.pyd` variants.
|
||||||
|
|
||||||
|
## First-run setup (no bundled credentials)
|
||||||
|
|
||||||
|
`config/app_config.json` is **deliberately NOT bundled** into the exe, and
|
||||||
|
`player_auth.json` is excluded from `Tree(src)`. Shipping either one planted the
|
||||||
|
build machine's `server_ip` / `screen_name` / `auth_code` into every install, so
|
||||||
|
a fresh machine silently connected to the wrong server (or played a stale
|
||||||
|
playlist) instead of asking.
|
||||||
|
|
||||||
|
What happens on a machine with no usable config:
|
||||||
|
|
||||||
|
1. The splash video (`config/resources/intro1.mp4`) plays.
|
||||||
|
2. A notice appears: *"Player is not configured"*.
|
||||||
|
3. After `SETUP_NOTICE_SECONDS` (**5 s**, `src/main.py`) the Settings screen
|
||||||
|
opens automatically.
|
||||||
|
4. Saving valid values writes `config/app_config.json` next to the **.exe** and
|
||||||
|
playback starts immediately — no restart needed.
|
||||||
|
|
||||||
|
On a machine that **is** configured, the notice and Settings are skipped and the
|
||||||
|
cached playlist plays straight away.
|
||||||
|
|
||||||
|
Key functions in `src/main.py`:
|
||||||
|
|
||||||
|
| Name | Role |
|
||||||
|
|------|------|
|
||||||
|
| `config_is_configured(config)` | Single source of truth. Missing file, empty/unparseable JSON, missing keys and leftover placeholders (`localhost`, `kivy-player`, `1234567`, `127.0.0.1`) all count as **unconfigured**. |
|
||||||
|
| `DEFAULT_CONFIG` | Blank-credential starting point, so a fresh install can never look configured. |
|
||||||
|
| `SignagePlayer.on_intro_finished()` | The one decision point after the splash: setup vs. playback. Both intro paths (video end and "no intro file") go through it. |
|
||||||
|
| `show_setup_required_notice()` | Shows the notice, then opens Settings after 5 s. |
|
||||||
|
| `on_first_run_config_saved()` | Re-syncs and starts playback. |
|
||||||
|
|
||||||
|
`config_is_configured()` is covered by `windows/test_first_run_setup.py` — run it
|
||||||
|
after touching that logic.
|
||||||
|
|
||||||
|
> When adding a new **required** config key, add it to `CONFIG_REQUIRED_KEYS`
|
||||||
|
> (and to `CONFIG_PLACEHOLDER_VALUES` if it has a placeholder default), or the
|
||||||
|
> first-run flow will not ask for it.
|
||||||
|
|
||||||
|
## 24/7 Supervision (Windows)
|
||||||
|
|
||||||
|
A signage player must survive crashes and hangs unattended. On **Linux/Pi** this is
|
||||||
|
`start.sh` (systemd or `./start.sh`). On **Windows** it is `windows/watchdog.ps1`,
|
||||||
|
launched by `windows/start_player_watchdog.bat`.
|
||||||
|
|
||||||
|
It restarts the player when it:
|
||||||
|
|
||||||
|
- **crashed** — the process disappeared; or
|
||||||
|
- **hung** — the process is alive but `.player_heartbeat` has gone stale
|
||||||
|
(the player rewrites it every 10 s; the watchdog treats >60 s as hung). A frozen
|
||||||
|
player is otherwise indistinguishable from a working one.
|
||||||
|
|
||||||
|
It also backs off if the player cannot stay up (crash-loop breaker:
|
||||||
|
`CrashLoopMaxFailures` failures in `CrashLoopWindowMin` minutes without ever
|
||||||
|
becoming healthy → `CrashLoopBackoffMin` minutes of quiet).
|
||||||
|
|
||||||
|
### The stop flag is SESSION-SCOPED
|
||||||
|
|
||||||
|
The only supported way to stop the player is the **exit-screen password**. On
|
||||||
|
success the player writes `.player_stop_requested` next to its `.exe`, and the
|
||||||
|
watchdog then **stands down instead of restarting**.
|
||||||
|
|
||||||
|
The flag is cleared **every time the watchdog starts**, which is what makes it
|
||||||
|
session-scoped: the password exit ends the *current* session, and the next launch
|
||||||
|
begins a new one. There is no file to delete by hand.
|
||||||
|
|
||||||
|
> **Ordering matters:** the flag is the operationally-correct choice over a
|
||||||
|
> "remember the last exit" setting, because a reboot must come back up playing.
|
||||||
|
> Clearing on start also means a power cut cannot leave the player permanently off.
|
||||||
|
|
||||||
|
### Two failure modes that must not be confused
|
||||||
|
|
||||||
|
| Situation | Correct reaction |
|
||||||
|
|---|---|
|
||||||
|
| Was healthy, then heartbeat went stale | **Restart promptly** (this is a hang) |
|
||||||
|
| Running but never became healthy (slow start, bad install, the **first-run setup screen**) | **Be patient**, then restart after `2 × StartupGraceSec` |
|
||||||
|
|
||||||
|
Never kill a player that has not yet been healthy — it may be an operator entering
|
||||||
|
server settings on the first-run setup screen.
|
||||||
|
|
||||||
|
### Important Windows-specific facts
|
||||||
|
|
||||||
|
- The packaged player is **TWO processes** (PyInstaller bootloader parent + the
|
||||||
|
child that owns the SDL window). Any kill must use `taskkill /T` or the visible
|
||||||
|
window survives and the next launch collides with it.
|
||||||
|
- The watchdog can only *restart* an install that already has a working config.
|
||||||
|
It cannot help before the player has first run.
|
||||||
|
- Hiding the console window (`console=False`) is **not** done: the console is the
|
||||||
|
only way to see a start-up failure, and the build instructions warn against
|
||||||
|
flipping it without reason.
|
||||||
|
- **No Windows service.** A service runs in session 0 with no desktop, so the
|
||||||
|
player could not render to the screen. A login-triggered task is the correct
|
||||||
|
Windows counterpart to the Pi's systemd unit — that is what
|
||||||
|
`start_player_watchdog.bat` is for (add it to the Startup folder for autostart).
|
||||||
|
|
||||||
|
### Verify the watchdog
|
||||||
|
|
||||||
|
```
|
||||||
|
cd windows
|
||||||
|
venv\Scripts\python.exe test_watchdog.py # crash + hang + stop-flag + new-session
|
||||||
|
```
|
||||||
|
|
||||||
|
Check state at any time (read-only):
|
||||||
|
|
||||||
|
```
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File windows\watchdog.ps1 -Status
|
||||||
|
```
|
||||||
|
|
||||||
|
> When testing a hang, do **not** just backdate the heartbeat file's mtime — the
|
||||||
|
> healthy player rewrites it immediately, so nothing is actually stale and the
|
||||||
|
> test passes for the wrong reason. Hold the file open with an exclusive Windows
|
||||||
|
> lock (share mode 0) so the player's own write fails; that reproduces a genuine
|
||||||
|
> stale heartbeat while the process stays alive.
|
||||||
|
|
||||||
|
## Code Signing (production constraint)
|
||||||
|
|
||||||
|
Production PCs run with **Smart App Control enforced** (`VerifiedAndReputablePolicyState=1`,
|
||||||
|
UMCI enforced) with **no "Run anyway" bypass** — an unsigned exe is blocked at kernel level.
|
||||||
|
|
||||||
|
- A **self-signed certificate does NOT satisfy Smart App Control.** A cert from a reputable
|
||||||
|
public CA is required. `create_self_signed_cert.ps1` is dev-only.
|
||||||
|
- Provide a cert as `KIWY_SIGN_PFX` (+ optional `KIWY_SIGN_PFX_PASSWORD`) or drop
|
||||||
|
`kiwy_signing.pfx` in `windows\`; `build_win.bat` then signs via `sign_exe.ps1`
|
||||||
|
(signtool with RFC3161 timestamp when available).
|
||||||
|
- See `documentation/CODE_SIGNING_SMART_APP_CONTROL.md`.
|
||||||
|
|
||||||
|
## Verify Before Committing
|
||||||
|
|
||||||
|
Fast syntax gate (no build, seconds):
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m py_compile src/main.py src/weblink_session.py windows/run_win.py windows/webview2_browser.py windows/webview2_runtime.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Web-link engine checks (no build, no player):
|
||||||
|
|
||||||
|
```
|
||||||
|
cd windows
|
||||||
|
venv\Scripts\python.exe test_webview2_embed.py # embeds WebView2 in a bare Win32 window
|
||||||
|
venv\Scripts\python.exe test_webview2_runtime.py # Runtime detection + installer discovery
|
||||||
|
```
|
||||||
|
|
||||||
|
Rebuild warning: the bundled offline installer makes the exe ~305 MB. If you do not
|
||||||
|
need offline machines, delete
|
||||||
|
`windows/webview2_runtime/MicrosoftEdgeWebView2RuntimeInstallerX64.exe` before building.
|
||||||
|
|
||||||
|
**Close the running player before rebuilding.** A running
|
||||||
|
`dist\KiwySignagePlayer\KiwySignagePlayer.exe` locks the output and the build fails with
|
||||||
|
"Access is denied". Chrome/Edge kiosk processes left over from web-link playback can also
|
||||||
|
hold locks; if the build fails, check for stray `msedge.exe` / `chrome.exe` before retrying.
|
||||||
|
|
||||||
|
Because `.exe` is a **reserved Windows device name** as well as a real file, use
|
||||||
|
`Test-Path -LiteralPath` when probing for the executable, otherwise the path silently
|
||||||
|
resolves to the console device.
|
||||||
|
|
||||||
|
Development run — **use this on a SAC-locked host**, since the unsigned exe cannot launch:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd windows
|
||||||
|
venv\Scripts\activate
|
||||||
|
python run_win.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Diagnose playback transitions via the trace log (`logs\playback_trace.log`), written by
|
||||||
|
`src/playback_trace.py`.
|
||||||
|
|
||||||
|
## Release Checklist
|
||||||
|
|
||||||
|
1. Bump `PLAYER_VERSION` in `src/main.py` **and** `filevers`/`prodvers`/`FileVersion`/
|
||||||
|
`ProductVersion` in `windows\version_info.txt`; keep them in sync.
|
||||||
|
2. Rebuild (`build_win.bat`).
|
||||||
|
3. Confirm the exe is signed (`Get-AuthenticodeSignature`); unsigned builds are blocked on
|
||||||
|
production hosts.
|
||||||
|
4. Smoke-test a mixed playlist: image → video → weblink → image, verifying durations, audio
|
||||||
|
(`audio: off` / `muted`), and a clean exit/restart.
|
||||||
|
|
||||||
|
## Commit Hygiene
|
||||||
|
|
||||||
|
- Never commit `windows\build\` or `windows\dist\` (git-ignored). Tracked build output such
|
||||||
|
as `windows\archive_list.txt` and `windows\build_last.txt` is the exception, not the rule.
|
||||||
|
- **Do not commit `player_auth.json` or `src/player_auth.json`** — they contain live
|
||||||
|
credentials (`auth_code`, `player_id`, `server_url`). These are currently tracked; treat any
|
||||||
|
future change to them as a deliberate, reviewed decision.
|
||||||
|
- Do not commit `config\app_config.json` values that are host-specific without checking
|
||||||
|
whether they belong in the repo default.
|
||||||
+37
@@ -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
|
||||||
|
|||||||
@@ -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 1–4 describe the original design
|
> the CEF and Chrome/Edge subprocess engines as fallbacks). Sections 1–4
|
||||||
> 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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
+353
-51
@@ -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,15 +659,22 @@ 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}'
|
||||||
@@ -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,27 +941,122 @@ 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()
|
||||||
|
|
||||||
# Notify user that resolution change requires restart
|
# Notify user that resolution change requires restart
|
||||||
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,14 +1615,18 @@ 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(
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -1682,9 +1964,29 @@ class SignagePlayer(Widget):
|
|||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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={},
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
@@ -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__
|
||||||
|
|
||||||
|
|||||||
@@ -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%
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
|
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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.'
|
||||||
@@ -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)'
|
||||||
@@ -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}')
|
||||||
Binary file not shown.
@@ -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.
@@ -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"
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user