Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f437aba1fc | |||
| 864ea06996 | |||
| 079d8c7f9d | |||
| 477128de81 | |||
| d8c6ab0bc5 | |||
| 9f5409685d | |||
| eb8e66e427 | |||
| 7e880421c9 | |||
| 5d9aa02c07 | |||
| a0704efa3c | |||
| 6dc79828bc | |||
| a19627885c | |||
| 31ad592e98 | |||
| 5c2b3f545f | |||
| d0ea94447a | |||
| 12f2880201 | |||
| 5a030671a2 | |||
| a2add88f04 | |||
| c4e8381898 | |||
| ced6e10919 | |||
| 844e5eeebb | |||
| 7efc023327 | |||
| 6abde5a767 | |||
| 362f5096a0 | |||
| 3845830a86 | |||
| f7b889be11 | |||
| 39565c5761 | |||
| fb9f46a94f | |||
| 9d8eb0cf21 | |||
| 64e49886df |
@@ -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.
|
||||
+40
@@ -25,6 +25,7 @@ wheels/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
windows/venv312/
|
||||
|
||||
# Kivy
|
||||
*.pyc
|
||||
@@ -59,3 +60,42 @@ playlists/server_playlist_*.json
|
||||
Thumbs.db
|
||||
|
||||
.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/
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
1782752485.750587
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#\!/bin/bash
|
||||
SCRIPT_DIR="/home/pi/kiwy-signage"
|
||||
LOG_FILE="/home/pi/kiwy-signage/logs/player-background.log"
|
||||
mkdir -p "."
|
||||
echo "[2026-07-17 10:54:53] Starting player in background..." >> ""
|
||||
cd "/home/pi/kiwy-signage" || exit 1
|
||||
nohup bash start.sh >> "" 2>&1 &
|
||||
PLAYER_PID=$\!
|
||||
echo "[2026-07-17 10:54:53] Player started with PID: $PLAYER_PID" >> ""
|
||||
echo "Player started in background (PID: $PLAYER_PID)"
|
||||
echo "Logs: $LOG_FILE"
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/bin/bash
|
||||
# Wait for desktop environment to be ready
|
||||
#\!/bin/bash
|
||||
sleep 15
|
||||
|
||||
# Start the player
|
||||
cd "/home/pi/Desktop/Kiwy-Signage" && bash start.sh
|
||||
cd /home/pi/kiwy-signage && bash start.sh
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
# Web Link Playlist Items — Player Integration Guide
|
||||
|
||||
This document describes the changes required on the **Kiwy-Signage player**
|
||||
(<https://gitea.moto-adv.com/ske087/Kiwy-Signage.git>) to support a new
|
||||
playlist item type: **`weblink`** (display a live web page / URL instead of an
|
||||
uploaded media file).
|
||||
This document describes how the **Kiwy-Signage player**
|
||||
(<https://gitea.moto-adv.com/ske087/Kiwy-Signage.git>) supports the **`weblink`**
|
||||
playlist item type (display a live web page / URL instead of an uploaded media
|
||||
file).
|
||||
|
||||
> The DigiServer (this repo, `digiserver-v2`) side will be updated to emit
|
||||
> `weblink` items in the playlist API. The player does **not** yet support them.
|
||||
> Use this guide to implement the player side later.
|
||||
> **Status: implemented.** The player supports `weblink` items on both
|
||||
> Raspberry Pi (`chromium` subprocess) and Windows (embedded **WebView2**, with
|
||||
> the CEF and Chrome/Edge subprocess engines as fallbacks). Sections 1–4
|
||||
> describe the original design plan; section 6 documents the shipped
|
||||
> architecture and the interaction model.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background — how items flow today
|
||||
## 1. Background — how items flow
|
||||
|
||||
```
|
||||
DigiServer API ──JSON──▶ player sync (get_playlists_v2.py) ──▶ playlist.json ──▶ main.py renders
|
||||
/api/playlists downloads files to media/ by file extension
|
||||
/api/playlists downloads files to media/ by item type
|
||||
```
|
||||
|
||||
Each playlist item the server returns currently looks like:
|
||||
@@ -243,3 +245,136 @@ Recommended options, in order of robustness:
|
||||
depth).
|
||||
- Consider running Chromium with `--incognito` (no persistent cookies/cache) as
|
||||
shown above.
|
||||
|
||||
---
|
||||
|
||||
## 6. Shipped architecture (`src/weblink_session.py`)
|
||||
|
||||
The player-side implementation lives in **one** module, so launch, verification,
|
||||
timing and teardown have a single owner instead of being duplicated per
|
||||
platform:
|
||||
|
||||
| Piece | Responsibility |
|
||||
|--------------------------|----------------|
|
||||
| `WeblinkSession` | Owns one weblink item: validate → launch → verify → watch → teardown. Generation-tokened so stale callbacks are ignored, and `atexit`-safe. |
|
||||
| `WeblinkAdapter` | The only platform-specific part: launch / wait for the window / is it alive / tear it down / pre-warm. `extra_launch_args()` lets a subclass add browser flags without copying `launch()`. |
|
||||
| `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`; on Windows the Chrome/Edge fallback). |
|
||||
| `InteractionWatcher` | Decides when the item is finished (see the interaction model below). |
|
||||
| `WebInputSources` | Reads `/dev/input/event*` (Linux) and does a pointer-position tap (Windows, needed for embedded engines). |
|
||||
| `webview2_browser.py` | **Windows, preferred**: embeds WebView2 as a child HWND of the Kivy window. |
|
||||
| `webview2_runtime.py` | **Windows**: detects the WebView2 Runtime and installs it silently when missing. |
|
||||
|
||||
Platform wrappers inject their engines through
|
||||
`SignagePlayer.weblink_adapter_factory`:
|
||||
|
||||
* **Raspberry Pi / Linux** — built-in Chromium subprocess adapter.
|
||||
* **Windows** (`windows/run_win.py`) — engines are tried in this order:
|
||||
|
||||
| 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
|
||||
|
||||
`duration` on a weblink is **not** a hard cut-off. The player advances only when
|
||||
**both** conditions are true:
|
||||
|
||||
1. the configured `duration` has elapsed; **and**
|
||||
2. the viewer has not interacted with the page for `interaction_postpone`
|
||||
seconds (default **10 s**), measured from the **most recent** interaction.
|
||||
|
||||
Consequences:
|
||||
|
||||
- A viewer who taps, scrolls or navigates the page during the final seconds of
|
||||
the slot **keeps the page on screen** — the advance is pushed 10 s past that
|
||||
touch, and every further touch pushes it again. The link is never pulled out
|
||||
from under someone who is using it.
|
||||
- An untouched page still advances on schedule, exactly like a media item.
|
||||
- A multi-event burst (a drag, a page transition) counts as **one** interaction
|
||||
but the countdown is measured from the **last** event of that burst, so an
|
||||
item can never be cut off mid-gesture.
|
||||
- `max_dwell` (duration × `max_dwell_factor`, at least `min_max_dwell`) is an
|
||||
absolute backstop so a wedged browser or a jammed touchscreen cannot park the
|
||||
playlist forever.
|
||||
|
||||
**Pause/play does not apply to web links.** A web link is an interactive
|
||||
surface, so `toggle_pause()` is a no-op while one is on screen — the interaction
|
||||
watcher owns its lifecycle. The pause button continues to work normally for
|
||||
images and videos.
|
||||
|
||||
### 6.2 Verified start-up
|
||||
|
||||
Launching a browser is not the same as displaying a page. The session therefore
|
||||
does **not** report success immediately after spawning the process (that used to
|
||||
reset the error counter and leave a black screen for the whole duration). The
|
||||
watcher thread — never the Kivy main thread — waits for the browser window to
|
||||
appear, and if it never does the item is reported as failed and skipped.
|
||||
|
||||
### 6.3 Configuration
|
||||
|
||||
All timings are tunable in `config/app_config.json` under `weblink`:
|
||||
|
||||
```json
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Meaning |
|
||||
|-----|---------|
|
||||
| `engine` | Preferred engine (`auto`, `cef`, `subprocess`). |
|
||||
| `interaction_postpone` | Seconds the advance is postponed, measured from each interaction (default 10). |
|
||||
| `interaction_debounce` | Logging/trace throttle for continuous drags (default 0.5). |
|
||||
| `interaction_grace` | Settle window after the last raw event still counted as interacting (default 5). |
|
||||
| `max_dwell_factor` | Hard ceiling = `duration × factor`. |
|
||||
| `min_max_dwell` | Floor for that hard ceiling, in seconds. |
|
||||
| `launch_timeout` | How long to wait for the browser window to appear. |
|
||||
| `prewarm` | Pre-warm the next weblink (disabled on Windows). |
|
||||
|
||||
### 6.4 Diagnostics
|
||||
|
||||
The watcher traces structured events through `playback_trace.py`:
|
||||
`weblink_launch`, `weblink_visible`, `weblink_interaction`, `weblink_end`
|
||||
(with reason `viewer_idle`, `browser_exited` or `max_dwell`),
|
||||
`weblink_not_visible` and `weblink_failed`.
|
||||
|
||||
+17
-4
@@ -1,12 +1,25 @@
|
||||
{
|
||||
"server_ip": "192.168.0.159",
|
||||
"port": "8080",
|
||||
"screen_name": "rpi-Receptie",
|
||||
"server_ip": "192.168.0.110",
|
||||
"port": "80",
|
||||
"screen_name": "DESKTOP-NJLBQKH",
|
||||
"quickconnect_key": "8887779",
|
||||
"orientation": "Landscape",
|
||||
"touch": "True",
|
||||
"max_resolution": "1920x1080",
|
||||
"edit_feature_enabled": true,
|
||||
"use_https": false,
|
||||
"verify_ssl": false
|
||||
"verify_ssl": false,
|
||||
"card_reader_mode": "auto",
|
||||
"card_reader_device": "",
|
||||
"card_reader_timeout": 5,
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
# Kiwy Signage Player — Code Signing & Smart App Control (Production)
|
||||
|
||||
> **TL;DR:** If production PCs have **Smart App Control (SAC) ON** and you
|
||||
> cannot disable it, the player `.exe` **must be signed by a certificate from a
|
||||
> reputable public CA**. There is no other way — SAC blocks unsigned binaries at
|
||||
> the kernel level (no "Run anyway" button). Self-signed certs and Defender
|
||||
> exclusions do **not** satisfy SAC.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why Smart App Control blocks the app
|
||||
|
||||
- SAC (Windows 11 22H2+, "Smart App Control" in **Windows Security → App &
|
||||
browser control**) only runs apps that are **signed by a reputable publisher**.
|
||||
- Your locally-built `KiwySignagePlayer.exe` is **unsigned**
|
||||
(`Get-AuthenticodeSignature` → `NotSigned`), so SAC refuses to launch it and
|
||||
shows "An Application Control policy has blocked this file."
|
||||
- Unlike classic SmartScreen, SAC has **no "Run anyway" button** and cannot be
|
||||
bypassed per-file. Disabling SAC is **permanent** and only possible with admin
|
||||
rights — so it is not viable for locked-down production PCs.
|
||||
|
||||
---
|
||||
|
||||
## 2. The solution for production: a real code-signing certificate
|
||||
|
||||
1. **Buy an OV code-signing certificate** from a reputable CA, e.g.:
|
||||
- Sectigo Code Signing
|
||||
- SSL.com Code Signing
|
||||
- DigiCert Code Signing
|
||||
- GlobalSign Code Signing
|
||||
OV is sufficient for SAC; EV gives the highest trust level. Cost is roughly
|
||||
USD 100–300/yr. The CA will issue a `.pfx`/`.p12` (or `.cer`+key).
|
||||
|
||||
2. **Sign the exe** after each build. Place your pfx at
|
||||
`windows\kiwy_signing.pfx` (or set `KIWY_SIGN_PFX` env var) — `build_win.bat`
|
||||
will then auto-sign via `sign_exe.ps1`:
|
||||
|
||||
```powershell
|
||||
# One-off, from the windows\ folder:
|
||||
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -CertPassword "yourpwd"
|
||||
```
|
||||
|
||||
The script:
|
||||
- locates `signtool.exe` (Windows SDK) — install with
|
||||
`winget install Microsoft.WindowsSDK.10.0.26100` if missing,
|
||||
- signs with **SHA256** + **RFC3161 timestamp** (required for SAC and to
|
||||
keep the signature valid after the cert expires),
|
||||
- verifies the result with `Get-AuthenticodeSignature`.
|
||||
|
||||
3. **Test** — confirm on one production PC:
|
||||
```powershell
|
||||
Get-AuthenticodeSignature "dist\KiwySignagePlayer\KiwySignagePlayer.exe"
|
||||
# Status must be: Valid
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Dev / test machines (where you have admin rights)
|
||||
|
||||
If a test PC has SAC **off**, you can make the app trusted locally without
|
||||
buying a cert:
|
||||
|
||||
```powershell
|
||||
# Run as Administrator
|
||||
.\create_self_signed_cert.ps1
|
||||
```
|
||||
|
||||
This creates a self-signed code-signing cert, exports `kiwy_dev_signing.pfx`,
|
||||
and installs it into **Trusted Root + Trusted Publisher + Trusted People** for
|
||||
the current user, so the player runs without SmartScreen/Defender prompts on
|
||||
that dev PC.
|
||||
|
||||
⚠️ **This does NOT satisfy SAC.** It is only for machines where SAC is off or
|
||||
where you have admin rights.
|
||||
|
||||
---
|
||||
|
||||
## 4. Build → sign → verify workflow
|
||||
|
||||
```bat
|
||||
:: 1. Build (produces dist\KiwySignagePlayer\KiwySignagePlayer.exe)
|
||||
cd windows
|
||||
venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||
|
||||
:: 2. Sign (auto if kiwy_signing.pfx present, else manual)
|
||||
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -CertPassword "..."
|
||||
|
||||
:: 3. Verify
|
||||
Get-AuthenticodeSignature "dist\KiwySignagePlayer\KiwySignagePlayer.exe"
|
||||
```
|
||||
|
||||
`build_win.bat` now does step 1 + step 2 automatically when a pfx is present.
|
||||
|
||||
---
|
||||
|
||||
## 5. Important caveats
|
||||
|
||||
- **Timestamping is mandatory.** The sign script timestamps by default
|
||||
(`http://timestamp.digicert.com`). Without a timestamp, the signature becomes
|
||||
invalid once the certificate expires and SAC will block the app.
|
||||
- **SAC reputation takes time.** Even a validly signed exe from a brand-new
|
||||
certificate may be blocked until the CA's reputation builds. EV certificates
|
||||
and well-known CAs (DigiCert, Sectigo, SSL.com) pass immediately.
|
||||
- **Re-sign after every build.** PyInstaller creates a new exe each time; the
|
||||
old signature is lost. The auto-sign step in `build_win.bat` handles this.
|
||||
- **Do not use UPX** on the signed exe — it invalidates the signature and can
|
||||
trigger false positives. (`upx=True` in the spec currently does nothing
|
||||
because UPX is not installed; if you ever install UPX, set it to False.)
|
||||
Regular → Executable
+590
-562
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 386 KiB |
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"count": 2,
|
||||
"player_id": 1,
|
||||
"player_name": "Receptie TV",
|
||||
"playlist": [
|
||||
{
|
||||
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
||||
"type": "image",
|
||||
"url": "media/sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
||||
"duration": 25,
|
||||
"edit_on_player": false
|
||||
},
|
||||
{
|
||||
"file_name": "robert-lukeman-zNN6ubHmruI-unsplash.jpg",
|
||||
"type": "image",
|
||||
"url": "media/robert-lukeman-zNN6ubHmruI-unsplash.jpg",
|
||||
"duration": 20,
|
||||
"edit_on_player": false
|
||||
}
|
||||
],
|
||||
"playlist_id": 1,
|
||||
"playlist_version": 15
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Python 3.12.9
|
||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
-4
@@ -1,7 +1,6 @@
|
||||
kivy>=2.3.0
|
||||
ffpyplayer
|
||||
requests==2.32.4
|
||||
bcrypt==4.2.1
|
||||
aiohttp==3.9.1
|
||||
asyncio==3.4.3
|
||||
requests>=2.32.0,<3.0.0
|
||||
bcrypt>=4.2.0,<5.0.0
|
||||
aiohttp>=3.9.0,<4.0.0
|
||||
evdev>=1.6.0
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Start Kivy Signage Player
|
||||
cd "$(dirname "$0")" && source .venv/bin/activate
|
||||
cd "$(dirname "$0")/src"
|
||||
python3 main.py
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MARKER_FILE="/etc/kiwy-signage/pi_boot_config_patched.marker"
|
||||
|
||||
is_pi() {
|
||||
if [[ -f /proc/device-tree/model ]] && grep -qi "raspberry pi" /proc/device-tree/model; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if grep -qi "raspberry" /proc/cpuinfo 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
find_boot_config() {
|
||||
if [[ -f /boot/firmware/config.txt ]]; then
|
||||
echo "/boot/firmware/config.txt"
|
||||
elif [[ -f /boot/config.txt ]]; then
|
||||
echo "/boot/config.txt"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
upsert_key() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local file="$3"
|
||||
|
||||
if grep -Eq "^[#[:space:]]*${key}=" "$file"; then
|
||||
sed -i -E "s|^[#[:space:]]*${key}=.*|${key}=${value}|" "$file"
|
||||
else
|
||||
echo "${key}=${value}" >> "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "[pi-boot-patch] Please run as root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! is_pi; then
|
||||
echo "[pi-boot-patch] Not a Raspberry Pi. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -f "$MARKER_FILE" ]]; then
|
||||
echo "[pi-boot-patch] Patch already applied. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BOOT_CONFIG="$(find_boot_config)"
|
||||
if [[ -z "$BOOT_CONFIG" ]]; then
|
||||
echo "[pi-boot-patch] Boot config not found. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[pi-boot-patch] Applying patch to ${BOOT_CONFIG}"
|
||||
|
||||
# Graphics and memory settings for smoother Chromium playback on Pi.
|
||||
upsert_key "dtoverlay" "vc4-kms-v3d" "$BOOT_CONFIG"
|
||||
upsert_key "gpu_mem" "256" "$BOOT_CONFIG"
|
||||
upsert_key "disable_overscan" "1" "$BOOT_CONFIG"
|
||||
|
||||
# Keep HDMI and display active for kiosk use.
|
||||
upsert_key "hdmi_blanking" "0" "$BOOT_CONFIG"
|
||||
upsert_key "hdmi_ignore_cec_init" "1" "$BOOT_CONFIG"
|
||||
upsert_key "hdmi_force_hotplug" "1" "$BOOT_CONFIG"
|
||||
|
||||
mkdir -p "$(dirname "$MARKER_FILE")"
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$MARKER_FILE"
|
||||
|
||||
echo "[pi-boot-patch] Patch complete. Marker written to ${MARKER_FILE}"
|
||||
+75
-13
@@ -82,11 +82,17 @@ class DrawingLayer(Widget):
|
||||
|
||||
class EditPopup(Popup):
|
||||
"""Popup for editing/annotating images"""
|
||||
def __init__(self, player_instance, image_path, user_card_data=None, **kwargs):
|
||||
def __init__(self, player_instance, image_path, user_card_data=None,
|
||||
media_id=None, original_filename=None, **kwargs):
|
||||
super(EditPopup, self).__init__(**kwargs)
|
||||
self.player = player_instance
|
||||
self.image_path = image_path
|
||||
self.user_card_data = user_card_data # Store card data to send to server on save
|
||||
# Server naming context: which media item (id) is being edited and what
|
||||
# its original file name is on the server. The server stores edited
|
||||
# media under 'edited_media/<media_id>/', so we must reproduce that.
|
||||
self.media_id = media_id
|
||||
self.original_filename = original_filename # server-side file_name
|
||||
|
||||
# Auto-close timer (5 minutes)
|
||||
self.auto_close_timeout = 300 # 5 minutes in seconds
|
||||
@@ -259,8 +265,15 @@ class EditPopup(Popup):
|
||||
def save_image(self, instance):
|
||||
"""Save the edited image"""
|
||||
try:
|
||||
# Create edited_media directory if it doesn't exist
|
||||
edited_dir = os.path.join(self.player.base_dir, 'media', 'edited_media')
|
||||
# Edited media is stored on the server under
|
||||
# 'edited_media/<media_id>/'. Reproduce that subfolder locally so
|
||||
# the upload naming matches what the server expects. Fall back to
|
||||
# the flat 'edited_media/' folder when no media_id is available.
|
||||
edited_base = os.path.join(self.player.base_dir, 'media', 'edited_media')
|
||||
if self.media_id is not None:
|
||||
edited_dir = os.path.join(edited_base, str(self.media_id))
|
||||
else:
|
||||
edited_dir = edited_base
|
||||
os.makedirs(edited_dir, exist_ok=True)
|
||||
|
||||
# Get original filename
|
||||
@@ -310,8 +323,22 @@ class EditPopup(Popup):
|
||||
# Overwrite the file
|
||||
shutil.copy2(output_path, self.image_path)
|
||||
|
||||
# Force file system sync to ensure data is written to disk
|
||||
os.sync()
|
||||
# Force file system sync to ensure data is written to disk.
|
||||
# NOTE: os.sync() is Linux-only and raises AttributeError on
|
||||
# Windows — that used to abort the whole pipeline before the
|
||||
# metadata/upload steps. Use a cross-platform fsync that is
|
||||
# best-effort and can never break the save/upload flow.
|
||||
try:
|
||||
if hasattr(os, 'sync'):
|
||||
os.sync()
|
||||
else:
|
||||
with open(output_path, 'rb') as _f:
|
||||
try:
|
||||
os.fsync(_f.fileno())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _sync_err:
|
||||
Logger.warning(f"EditPopup: File sync skipped ({_sync_err})")
|
||||
|
||||
# Verify the overwrite
|
||||
new_size = os.path.getsize(self.image_path)
|
||||
@@ -326,9 +353,15 @@ class EditPopup(Popup):
|
||||
self.ids.top_toolbar.opacity = 1
|
||||
self.ids.right_sidebar.opacity = 1
|
||||
|
||||
# Create and save metadata
|
||||
json_filename = self._save_metadata(edited_dir, new_name, base_name,
|
||||
new_version if version_match else 1, output_filename)
|
||||
# Create and save metadata. This runs in its own guarded
|
||||
# block so that a failure here cannot silently stop the
|
||||
# upload — the two steps are intentionally decoupled.
|
||||
json_filename = None
|
||||
try:
|
||||
json_filename = self._save_metadata(edited_dir, new_name, base_name,
|
||||
new_version if version_match else 1, output_filename)
|
||||
except Exception as meta_err:
|
||||
Logger.error(f"EditPopup: Metadata save failed: {meta_err}")
|
||||
|
||||
# Upload to server in background (continues after popup closes)
|
||||
upload_thread = threading.Thread(
|
||||
@@ -414,6 +447,14 @@ class EditPopup(Popup):
|
||||
'version': version,
|
||||
'user_card_data': self.user_card_data # Card data from reader (or None)
|
||||
}
|
||||
# Include the server-side file name and media id so the server can
|
||||
# attach the edit to the correct media item.
|
||||
if self.original_filename:
|
||||
metadata['original_filename'] = self.original_filename
|
||||
else:
|
||||
metadata['original_filename'] = os.path.basename(self.image_path)
|
||||
if self.media_id is not None:
|
||||
metadata['media_id'] = self.media_id
|
||||
|
||||
# Save metadata JSON
|
||||
json_filename = f"{new_name}_metadata.json"
|
||||
@@ -444,16 +485,37 @@ class EditPopup(Popup):
|
||||
Logger.warning("EditPopup: Missing server URL or auth code (upload skipped)")
|
||||
return False
|
||||
|
||||
# Load metadata from file
|
||||
with open(metadata_path, 'r') as meta_file:
|
||||
metadata = json.load(meta_file)
|
||||
# Load metadata from file (or build it in memory if the metadata
|
||||
# file was not written — the upload must still go through).
|
||||
metadata = None
|
||||
if metadata_path and os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, 'r') as meta_file:
|
||||
metadata = json.load(meta_file)
|
||||
except Exception as e:
|
||||
Logger.warning(f"EditPopup: Could not read metadata file: {e}")
|
||||
if not metadata:
|
||||
metadata = {
|
||||
'time_of_modification': datetime.now().isoformat(),
|
||||
'original_name': os.path.basename(image_path),
|
||||
'new_name': os.path.basename(image_path),
|
||||
'version': 1,
|
||||
'user_card_data': self.user_card_data,
|
||||
}
|
||||
if self.original_filename:
|
||||
metadata['original_filename'] = self.original_filename
|
||||
if self.media_id is not None:
|
||||
metadata['media_id'] = self.media_id
|
||||
|
||||
# Prepare upload URL - send to the original file endpoint
|
||||
upload_url = f"{server_url}/api/player-edit-media"
|
||||
headers = {'Authorization': f'Bearer {auth_code}'}
|
||||
|
||||
# Add the original filename to metadata so server knows which file was edited
|
||||
metadata['original_filename'] = os.path.basename(metadata['original_path'])
|
||||
# Ensure the original filename (server-side name) is present so the
|
||||
# server knows which file was edited. Prefer the media context we
|
||||
# captured when the edit popup opened.
|
||||
if not metadata.get('original_filename'):
|
||||
metadata['original_filename'] = os.path.basename(metadata.get('original_path', image_path))
|
||||
|
||||
# Disable SSL verification for self-signed certificates (like main code does)
|
||||
# Note: This is NOT recommended for production with untrusted servers
|
||||
|
||||
+27
-16
@@ -248,13 +248,11 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None)
|
||||
# Web-link items have no file to download — pass the link through unchanged.
|
||||
if item_type == 'weblink':
|
||||
logger.info(f"🔗 Web link item (no download): {file_url}")
|
||||
updated_playlist.append({
|
||||
'file_name': file_name,
|
||||
'type': 'weblink',
|
||||
'url': file_url, # keep the original web address (not a local path)
|
||||
'duration': duration,
|
||||
'edit_on_player': False,
|
||||
})
|
||||
# Preserve every server field (audio/muted/description/id/position/...)
|
||||
# instead of rebuilding a fixed dict, so nothing is silently dropped.
|
||||
weblink_item = dict(media)
|
||||
weblink_item['type'] = 'weblink'
|
||||
updated_playlist.append(weblink_item)
|
||||
continue
|
||||
|
||||
local_path = os.path.join(media_dir, file_name)
|
||||
@@ -318,14 +316,13 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None)
|
||||
# Don't skip - may still add to playlist
|
||||
|
||||
# Always add the media item to the playlist, even if download failed
|
||||
# (it might already exist or be available later)
|
||||
updated_media = {
|
||||
'file_name': file_name,
|
||||
'type': item_type, # Preserve media type (image/video/...)
|
||||
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
||||
'duration': duration,
|
||||
'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag
|
||||
}
|
||||
# (it might already exist or be available later).
|
||||
# Preserve EVERY server field (audio/muted/description/id/position/...)
|
||||
# by copying the original dict and only overriding the URL with the
|
||||
# local path — previously the fixed dict below dropped `audio`, `muted`,
|
||||
# `description`, `id` and `position` from the saved playlist.
|
||||
updated_media = dict(media)
|
||||
updated_media['url'] = os.path.relpath(local_path, os.path.dirname(media_dir))
|
||||
updated_playlist.append(updated_media)
|
||||
|
||||
return updated_playlist
|
||||
@@ -354,7 +351,9 @@ def delete_unused_media(playlist_data, media_dir):
|
||||
rel_path = os.path.relpath(full_path, media_dir)
|
||||
|
||||
# Skip if file is in current playlist
|
||||
if rel_path in referenced_files:
|
||||
# Normalize paths to handle Windows backslashes vs server forward slashes
|
||||
normalized_rel = rel_path.replace('\\', '/')
|
||||
if normalized_rel in referenced_files or rel_path in referenced_files:
|
||||
continue
|
||||
|
||||
# Delete unreferenced file
|
||||
@@ -454,6 +453,18 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
|
||||
return playlist_file
|
||||
else:
|
||||
logger.info("✓ Playlist is up to date")
|
||||
# Even when the playlist version matches, ensure media files exist locally.
|
||||
# The media folder might be empty (e.g. fresh install or deleted files).
|
||||
logger.info("📥 Checking for missing media files...")
|
||||
ssl_manager = auth.ssl_manager if config.get('use_https', True) else None
|
||||
server_url = auth.auth_data.get('server_url', '')
|
||||
downloaded = download_media_files(
|
||||
server_data.get('playlist', []), media_dir, ssl_manager, server_url
|
||||
)
|
||||
if downloaded:
|
||||
server_data['playlist'] = downloaded
|
||||
# Re-save playlist with updated URLs if needed
|
||||
save_playlist(server_data, playlist_dir)
|
||||
return playlist_file
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+1272
-413
File diff suppressed because it is too large
Load Diff
+163
-94
@@ -6,11 +6,16 @@ Checks server connectivity and manages WiFi restart on connection failure
|
||||
import subprocess
|
||||
import time
|
||||
import random
|
||||
import platform
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from kivy.logger import Logger
|
||||
from kivy.clock import Clock
|
||||
|
||||
# Detect platform once so the ping / WiFi-restart commands below can
|
||||
# pick the correct syntax (Linux vs Windows).
|
||||
IS_WINDOWS = platform.system() == 'Windows'
|
||||
|
||||
|
||||
class NetworkMonitor:
|
||||
"""Monitor network connectivity and manage WiFi restart"""
|
||||
@@ -99,9 +104,15 @@ class NetworkMonitor:
|
||||
|
||||
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
||||
|
||||
# Ping the server hostname with 3 attempts
|
||||
# Ping the server hostname with 3 attempts.
|
||||
# Windows ping uses -n for count and -w for timeout (ms),
|
||||
# while Linux uses -c and -W.
|
||||
if IS_WINDOWS:
|
||||
cmd = ['ping', '-n', '3', '-w', '3000', hostname]
|
||||
else:
|
||||
cmd = ['ping', '-c', '3', '-W', '3', hostname]
|
||||
result = subprocess.run(
|
||||
['ping', '-c', '3', '-W', '3', hostname],
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
@@ -123,8 +134,11 @@ class NetworkMonitor:
|
||||
|
||||
def _restart_wifi(self):
|
||||
"""
|
||||
Restart WiFi by turning it off for a specified duration then back on
|
||||
This runs in a separate thread to not block the main application
|
||||
Restart WiFi by turning it off for a specified duration then back on.
|
||||
Uses the platform-appropriate commands:
|
||||
- Linux (Raspberry Pi): sudo rfkill / ifconfig / dhclient
|
||||
- Windows: netsh wlan disconnect / connect
|
||||
This runs in a separate thread to not block the main application.
|
||||
"""
|
||||
def wifi_restart_thread():
|
||||
try:
|
||||
@@ -132,97 +146,10 @@ class NetworkMonitor:
|
||||
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
|
||||
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
||||
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'rfkill', 'block', 'wifi'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
||||
Logger.info("NetworkMonitor: WiFi is now DISABLED and will remain OFF")
|
||||
if IS_WINDOWS:
|
||||
self._restart_wifi_windows()
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
||||
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||
|
||||
# Fallback to ifconfig
|
||||
result2 = subprocess.run(
|
||||
['sudo', 'ifconfig', 'wlan0', 'down'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result2.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
||||
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
||||
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
|
||||
return
|
||||
|
||||
# Wait for the specified duration with WiFi OFF
|
||||
wait_minutes = self.wifi_restart_duration / 60
|
||||
Logger.info(f"NetworkMonitor: ====================================")
|
||||
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
|
||||
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
|
||||
Logger.info(f"NetworkMonitor: ====================================")
|
||||
|
||||
# Sleep while WiFi is OFF
|
||||
time.sleep(self.wifi_restart_duration)
|
||||
|
||||
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
|
||||
|
||||
# Turn WiFi back on after the wait period
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
|
||||
# Unblock WiFi using rfkill
|
||||
result = subprocess.run(
|
||||
['sudo', 'rfkill', 'unblock', 'wifi'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
|
||||
|
||||
# Also bring interface up
|
||||
result2 = subprocess.run(
|
||||
['sudo', 'ifconfig', 'wlan0', 'up'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result2.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
||||
|
||||
# Wait a bit for connection to establish
|
||||
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
||||
time.sleep(10)
|
||||
|
||||
# Try to restart DHCP
|
||||
Logger.info("NetworkMonitor: Requesting IP address...")
|
||||
subprocess.run(
|
||||
['sudo', 'dhclient', 'wlan0'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
||||
self._restart_wifi_linux()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
||||
@@ -233,3 +160,145 @@ class NetworkMonitor:
|
||||
import threading
|
||||
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _restart_wifi_windows(self):
|
||||
"""Windows WiFi restart using netsh. Turn off for the wait period,
|
||||
then turn back on so Windows reconnects to the preferred network."""
|
||||
wait_minutes = self.wifi_restart_duration / 60
|
||||
Logger.info(
|
||||
f"NetworkMonitor: Windows WiFi restart — off for {wait_minutes:.0f} min"
|
||||
)
|
||||
|
||||
# Turn WiFi OFF
|
||||
off = subprocess.run(
|
||||
['netsh', 'wlan', 'disconnect'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if off.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF (netsh wlan disconnect)")
|
||||
else:
|
||||
Logger.warning(
|
||||
f"NetworkMonitor: netsh wlan disconnect failed: {off.stderr.strip()}"
|
||||
)
|
||||
|
||||
# Wait with WiFi OFF
|
||||
Logger.info(
|
||||
f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes "
|
||||
f"(started {datetime.now().strftime('%H:%M:%S')})"
|
||||
)
|
||||
time.sleep(self.wifi_restart_duration)
|
||||
Logger.info(
|
||||
f"NetworkMonitor: Wait period completed at {datetime.now().strftime('%H:%M:%S')}"
|
||||
)
|
||||
|
||||
# Turn WiFi back ON — Windows reconnects to the preferred network
|
||||
on = subprocess.run(
|
||||
['netsh', 'wlan', 'connect'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if on.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi re-enabled (netsh wlan connect)")
|
||||
else:
|
||||
# 'netsh wlan connect' without a profile may return non-zero even
|
||||
# though the radio comes back on; log it but don't fail hard.
|
||||
Logger.warning(
|
||||
f"NetworkMonitor: netsh wlan connect returned {on.returncode}: "
|
||||
f"{on.stderr.strip()} (may reconnect automatically)"
|
||||
)
|
||||
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
|
||||
def _restart_wifi_linux(self):
|
||||
"""Linux (Raspberry Pi) WiFi restart using rfkill/ifconfig/dhclient."""
|
||||
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
||||
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'rfkill', 'block', 'wifi'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
||||
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||
|
||||
# Fallback to ifconfig
|
||||
result2 = subprocess.run(
|
||||
['sudo', 'ifconfig', 'wlan0', 'down'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result2.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
||||
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
||||
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
|
||||
return
|
||||
|
||||
# Wait for the specified duration with WiFi OFF
|
||||
wait_minutes = self.wifi_restart_duration / 60
|
||||
Logger.info(f"NetworkMonitor: ====================================")
|
||||
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
|
||||
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
|
||||
Logger.info(f"NetworkMonitor: ====================================")
|
||||
|
||||
# Sleep while WiFi is OFF
|
||||
time.sleep(self.wifi_restart_duration)
|
||||
|
||||
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
|
||||
|
||||
# Turn WiFi back on after the wait period
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
|
||||
# Unblock WiFi using rfkill
|
||||
result = subprocess.run(
|
||||
['sudo', 'rfkill', 'unblock', 'wifi'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
|
||||
|
||||
# Also bring interface up
|
||||
result2 = subprocess.run(
|
||||
['sudo', 'ifconfig', 'wlan0', 'up'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result2.returncode == 0:
|
||||
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
||||
|
||||
# Wait a bit for connection to establish
|
||||
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
||||
time.sleep(10)
|
||||
|
||||
# Try to restart DHCP
|
||||
Logger.info("NetworkMonitor: Requesting IP address...")
|
||||
subprocess.run(
|
||||
['sudo', 'dhclient', 'wlan0'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||
Logger.info("NetworkMonitor: ====================================")
|
||||
else:
|
||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
playback_trace.py — Always-on playback transition logger.
|
||||
|
||||
Kivy's log level is forced to 'warning' in main.py / run_win.py, which
|
||||
suppresses every Logger.info()/Logger.debug() line. That made it impossible
|
||||
to see why the player skips/crashes at the weblink->image and video->next
|
||||
transitions.
|
||||
|
||||
This module writes a plain-text trace file (logs/playback_trace.log) with
|
||||
timestamps, INDEPENDENT of Kivy's log level, so we can always see exactly
|
||||
what the player is doing. It is thread-safe (a lock guards the append) and
|
||||
never throws (all failures are swallowed) so it can never break playback.
|
||||
|
||||
Usage:
|
||||
from playback_trace import trace
|
||||
trace("play_current_media", index=3, name="foo.jpg", type="image")
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_LOG_PATH = None
|
||||
_OPENED = False
|
||||
|
||||
|
||||
def _ensure_path():
|
||||
global _LOG_PATH, _OPENED
|
||||
if _OPENED:
|
||||
return _LOG_PATH
|
||||
_OPENED = True
|
||||
try:
|
||||
# Respect the local data dir the launcher set (same place as logs/).
|
||||
base = os.environ.get('KIWY_DATA_DIR') or os.getcwd()
|
||||
log_dir = os.path.join(base, 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
_LOG_PATH = os.path.join(log_dir, 'playback_trace.log')
|
||||
except Exception:
|
||||
_LOG_PATH = None
|
||||
return _LOG_PATH
|
||||
|
||||
|
||||
def trace(event, **kwargs):
|
||||
"""Append one line to the playback trace log.
|
||||
|
||||
Args:
|
||||
event: short event name, e.g. 'next_media', 'eos', 'web_open'.
|
||||
**kwargs: key=value context, e.g. index=3, name='foo.jpg'.
|
||||
"""
|
||||
try:
|
||||
path = _ensure_path()
|
||||
if not path:
|
||||
return
|
||||
t = time.strftime('%H:%M:%S')
|
||||
ms = int((time.time() % 1) * 1000)
|
||||
parts = [f"{t}.{ms:03d}", event]
|
||||
for k, v in kwargs.items():
|
||||
parts.append(f"{k}={v}")
|
||||
with _LOCK:
|
||||
with open(path, 'a', encoding='utf-8') as f:
|
||||
f.write(" ".join(parts) + "\n")
|
||||
except Exception:
|
||||
pass # tracing must never break the player
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"hostname": "rpi-Receptie",
|
||||
"auth_code": "BrJrGzX_IT9oP_Lfke8Qgo4sDjMc49T1EhSnWmKKpDM",
|
||||
"player_id": 1,
|
||||
"player_name": "Receptie TV",
|
||||
"playlist_id": 1,
|
||||
"orientation": "Landscape",
|
||||
"authenticated": true,
|
||||
"server_url": "http://192.168.0.159:8080"
|
||||
}
|
||||
+316
-289
@@ -352,303 +352,330 @@
|
||||
# Settings popup content
|
||||
<SettingsPopup@Popup>:
|
||||
title: 'Player Settings'
|
||||
size_hint: 0.8, 0.8
|
||||
size_hint: 0.9, 0.85
|
||||
auto_dismiss: True
|
||||
|
||||
BoxLayout:
|
||||
orientation: 'vertical'
|
||||
padding: dp(20)
|
||||
spacing: dp(15)
|
||||
padding: [dp(15), dp(10)]
|
||||
spacing: dp(8)
|
||||
|
||||
# Server configuration
|
||||
ScrollView:
|
||||
BoxLayout:
|
||||
orientation: 'vertical'
|
||||
spacing: dp(8)
|
||||
size_hint_y: None
|
||||
height: self.minimum_height
|
||||
|
||||
# Server configuration
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Server IP:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: server_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(13)
|
||||
hint_text: 'e.g. 192.168.0.110'
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Server port
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Port:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: port_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(13)
|
||||
hint_text: '80 or 8080 (leave empty for default)'
|
||||
input_filter: 'int'
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Screen name
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Screen Name:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: screen_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(13)
|
||||
hint_text: 'player name registered on the server'
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Quickconnect key
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Quickconnect:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: quickconnect_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(13)
|
||||
hint_text: 'e.g. 8887779'
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Orientation
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Orientation:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: orientation_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(13)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Touch
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Touch:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: touch_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(13)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Resolution
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Max Resolution:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: resolution_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(13)
|
||||
hint_text: '1920x1080 or auto'
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Edit Feature Enable/Disable
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(36)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
text: 'Enable Edit:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
CheckBox:
|
||||
id: edit_enabled_checkbox
|
||||
size_hint_x: None
|
||||
width: dp(36)
|
||||
active: True
|
||||
on_active: root.on_edit_feature_toggle(self.active)
|
||||
|
||||
Label:
|
||||
text: '(Allow editing images)'
|
||||
size_hint_x: 0.4
|
||||
font_size: sp(11)
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
color: 0.7, 0.7, 0.7, 1
|
||||
|
||||
# Separator
|
||||
Widget:
|
||||
size_hint_y: None
|
||||
height: dp(5)
|
||||
|
||||
# Reset Buttons Section
|
||||
Label:
|
||||
text: 'Reset Options:'
|
||||
size_hint_y: None
|
||||
height: dp(26)
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
bold: True
|
||||
font_size: sp(14)
|
||||
|
||||
# Reset Buttons Row
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(44)
|
||||
spacing: dp(8)
|
||||
|
||||
Button:
|
||||
id: reset_auth_btn
|
||||
text: 'Reset Player Auth'
|
||||
background_color: 0.8, 0.4, 0.2, 1
|
||||
font_size: sp(12)
|
||||
on_press: root.reset_player_auth()
|
||||
|
||||
Button:
|
||||
id: reset_playlist_btn
|
||||
text: 'Reset Playlist to v0'
|
||||
background_color: 0.8, 0.4, 0.2, 1
|
||||
font_size: sp(12)
|
||||
on_press: root.reset_playlist_version()
|
||||
|
||||
Button:
|
||||
id: restart_player_btn
|
||||
text: 'Restart Player'
|
||||
background_color: 0.2, 0.6, 0.8, 1
|
||||
font_size: sp(12)
|
||||
on_press: root.restart_player()
|
||||
|
||||
# Test Connection + Production Mode Buttons
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(44)
|
||||
spacing: dp(8)
|
||||
|
||||
Button:
|
||||
id: test_connection_btn
|
||||
text: 'Test Server Connection'
|
||||
background_color: 0.2, 0.4, 0.8, 1
|
||||
font_size: sp(13)
|
||||
on_press: root.test_connection()
|
||||
|
||||
Button:
|
||||
id: production_mode_btn
|
||||
text: 'Enable Production'
|
||||
background_color: 0.4, 0.4, 0.4, 1 # grey = disabled
|
||||
font_size: sp(13)
|
||||
on_press: root.toggle_production_mode()
|
||||
|
||||
# Connection Status Label
|
||||
Label:
|
||||
id: connection_status
|
||||
text: 'Click button to test connection'
|
||||
size_hint_y: None
|
||||
height: dp(32)
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(11)
|
||||
color: 0.7, 0.7, 0.7, 1
|
||||
|
||||
# Separator
|
||||
Widget:
|
||||
size_hint_y: None
|
||||
height: dp(5)
|
||||
|
||||
# Status information row
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(26)
|
||||
spacing: dp(8)
|
||||
|
||||
Label:
|
||||
id: playlist_info
|
||||
text: 'Playlist: N/A'
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(11)
|
||||
|
||||
Label:
|
||||
id: media_count_info
|
||||
text: 'Media: 0'
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(11)
|
||||
|
||||
Label:
|
||||
id: status_info
|
||||
text: 'Status: Idle'
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(11)
|
||||
|
||||
# Action buttons (always visible, outside scroll)
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
|
||||
Label:
|
||||
text: 'Server IP:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: server_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Server port
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
|
||||
Label:
|
||||
text: 'Port:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: port_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
hint_text: '80 or 8080 (leave empty for default)'
|
||||
input_filter: 'int'
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Screen name
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
|
||||
Label:
|
||||
text: 'Screen Name:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: screen_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Quickconnect key
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
|
||||
Label:
|
||||
text: 'Quickconnect:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: quickconnect_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Orientation
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
|
||||
Label:
|
||||
text: 'Orientation:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: orientation_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Touch
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
|
||||
Label:
|
||||
text: 'Touch:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: touch_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Resolution
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
|
||||
Label:
|
||||
text: 'Max Resolution:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
TextInput:
|
||||
id: resolution_input
|
||||
size_hint_x: 0.7
|
||||
multiline: False
|
||||
font_size: sp(14)
|
||||
hint_text: '1920x1080 or auto'
|
||||
write_tab: False
|
||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||
|
||||
# Edit Feature Enable/Disable
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
spacing: dp(10)
|
||||
|
||||
Label:
|
||||
text: 'Enable Edit Feature:'
|
||||
size_hint_x: 0.3
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
|
||||
CheckBox:
|
||||
id: edit_enabled_checkbox
|
||||
size_hint_x: None
|
||||
width: dp(40)
|
||||
active: True
|
||||
on_active: root.on_edit_feature_toggle(self.active)
|
||||
|
||||
Label:
|
||||
text: '(Allow editing images on this player)'
|
||||
size_hint_x: 0.4
|
||||
font_size: sp(12)
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
color: 0.7, 0.7, 0.7, 1
|
||||
|
||||
Widget:
|
||||
size_hint_y: 0.05
|
||||
|
||||
# Reset Buttons Section
|
||||
Label:
|
||||
text: 'Reset Options:'
|
||||
size_hint_y: None
|
||||
height: dp(30)
|
||||
text_size: self.size
|
||||
halign: 'left'
|
||||
valign: 'middle'
|
||||
bold: True
|
||||
font_size: sp(16)
|
||||
|
||||
# Reset Buttons Row
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(50)
|
||||
spacing: dp(10)
|
||||
|
||||
Button:
|
||||
id: reset_auth_btn
|
||||
text: 'Reset Player Auth'
|
||||
background_color: 0.8, 0.4, 0.2, 1
|
||||
on_press: root.reset_player_auth()
|
||||
|
||||
Button:
|
||||
id: reset_playlist_btn
|
||||
text: 'Reset Playlist to v0'
|
||||
background_color: 0.8, 0.4, 0.2, 1
|
||||
on_press: root.reset_playlist_version()
|
||||
|
||||
Button:
|
||||
id: restart_player_btn
|
||||
text: 'Restart Player'
|
||||
background_color: 0.2, 0.6, 0.8, 1
|
||||
on_press: root.restart_player()
|
||||
|
||||
# Test Connection Button
|
||||
Button:
|
||||
id: test_connection_btn
|
||||
text: 'Test Server Connection'
|
||||
size_hint_y: None
|
||||
height: dp(50)
|
||||
background_color: 0.2, 0.4, 0.8, 1
|
||||
on_press: root.test_connection()
|
||||
|
||||
# Connection Status Label
|
||||
Label:
|
||||
id: connection_status
|
||||
text: 'Click button to test connection'
|
||||
size_hint_y: None
|
||||
height: dp(40)
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
color: 0.7, 0.7, 0.7, 1
|
||||
|
||||
Widget:
|
||||
size_hint_y: 0.05
|
||||
|
||||
# Status information row
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(30)
|
||||
spacing: dp(10)
|
||||
|
||||
Label:
|
||||
id: playlist_info
|
||||
text: 'Playlist: N/A'
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(12)
|
||||
|
||||
Label:
|
||||
id: media_count_info
|
||||
text: 'Media: 0'
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(12)
|
||||
|
||||
Label:
|
||||
id: status_info
|
||||
text: 'Status: Idle'
|
||||
text_size: self.size
|
||||
halign: 'center'
|
||||
valign: 'middle'
|
||||
font_size: sp(12)
|
||||
|
||||
Widget:
|
||||
size_hint_y: 0.05
|
||||
|
||||
# Action buttons
|
||||
BoxLayout:
|
||||
orientation: 'horizontal'
|
||||
size_hint_y: None
|
||||
height: dp(50)
|
||||
spacing: dp(20)
|
||||
height: dp(44)
|
||||
spacing: dp(15)
|
||||
|
||||
Button:
|
||||
text: 'Save & Close'
|
||||
|
||||
@@ -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})')
|
||||
File diff suppressed because it is too large
Load Diff
@@ -218,19 +218,6 @@ rm -f "$STOP_FLAG_FILE"
|
||||
# Change to the project directory
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Check if virtual environment exists
|
||||
if [ -d ".venv" ]; then
|
||||
log_message "✓ Virtual environment found"
|
||||
source .venv/bin/activate
|
||||
else
|
||||
log_message "⚠️ Creating virtual environment..."
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
log_message "📦 Installing dependencies..."
|
||||
pip3 install -r requirements.txt
|
||||
log_message "✓ Virtual environment ready"
|
||||
fi
|
||||
|
||||
# Check if configuration exists
|
||||
if [ ! -f "config/app_config.json" ]; then
|
||||
log_message "⚠️ WARNING: Configuration file not found!"
|
||||
@@ -250,6 +237,11 @@ while true; do
|
||||
# Clean old heartbeat
|
||||
rm -f "$HEARTBEAT_FILE"
|
||||
|
||||
# Activate virtual environment if it exists
|
||||
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
||||
source "$SCRIPT_DIR/.venv/bin/activate"
|
||||
fi
|
||||
|
||||
# Start the player
|
||||
cd "$SCRIPT_DIR/src"
|
||||
python3 main.py &
|
||||
@@ -292,8 +284,8 @@ while true; do
|
||||
log_message "⏳ Waiting ${RESTART_DELAY}s before restart..."
|
||||
sleep $RESTART_DELAY
|
||||
|
||||
# Cleanup any zombie processes
|
||||
pkill -9 -f "python3 main.py" 2>/dev/null
|
||||
# Ensure old player process is gone before restarting
|
||||
kill -9 $PLAYER_PID 2>/dev/null
|
||||
|
||||
done
|
||||
|
||||
@@ -301,6 +293,3 @@ log_message ""
|
||||
log_message "=========================================="
|
||||
log_message "Watchdog stopped"
|
||||
log_message "=========================================="
|
||||
|
||||
# Deactivate virtual environment (this line is never reached in watchdog mode)
|
||||
deactivate
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
Requirement already satisfied: ffpyplayer in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (4.5.3)
|
||||
Requirement already satisfied: requests in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2.34.2)
|
||||
Requirement already satisfied: aiohttp in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (3.14.3)
|
||||
Requirement already satisfied: bcrypt in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (5.0.0)
|
||||
Requirement already satisfied: certifi in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2026.7.22)
|
||||
Requirement already satisfied: pyinstaller in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (6.21.0)
|
||||
Requirement already satisfied: kivy[base] in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2.3.1)
|
||||
Requirement already satisfied: Kivy-Garden>=0.1.4 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.1.5)
|
||||
Requirement already satisfied: docutils in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.23)
|
||||
Requirement already satisfied: pygments in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (2.20.0)
|
||||
Requirement already satisfied: filetype in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (1.2.0)
|
||||
Requirement already satisfied: kivy-deps.angle~=0.4.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.4.0)
|
||||
Requirement already satisfied: kivy-deps.sdl2~=0.8.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.8.0)
|
||||
Requirement already satisfied: kivy-deps.glew~=0.3.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.3.1)
|
||||
Requirement already satisfied: pypiwin32 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (223)
|
||||
Requirement already satisfied: pillow<11,>=9.5.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (10.4.0)
|
||||
Requirement already satisfied: charset_normalizer<4,>=2 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (3.4.9)
|
||||
Requirement already satisfied: idna<4,>=2.5 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (3.18)
|
||||
Requirement already satisfied: urllib3<3,>=1.26 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (2.7.0)
|
||||
Requirement already satisfied: aiohappyeyeballs>=2.5.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (2.7.1)
|
||||
Requirement already satisfied: aiosignal>=1.4.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.4.0)
|
||||
Requirement already satisfied: attrs>=17.3.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (26.1.0)
|
||||
Requirement already satisfied: frozenlist>=1.1.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.8.0)
|
||||
Requirement already satisfied: multidict<7.0,>=4.5 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (6.7.1)
|
||||
Requirement already satisfied: propcache>=0.2.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (0.5.2)
|
||||
Requirement already satisfied: typing_extensions>=4.4 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (4.16.0)
|
||||
Requirement already satisfied: yarl<2.0,>=1.17.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.24.5)
|
||||
Requirement already satisfied: altgraph in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (0.17.5)
|
||||
Requirement already satisfied: packaging>=22.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (26.2)
|
||||
Requirement already satisfied: pefile>=2022.5.30 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (2024.8.26)
|
||||
Requirement already satisfied: pyinstaller-hooks-contrib>=2026.6 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (2026.6)
|
||||
Requirement already satisfied: pywin32-ctypes>=0.2.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (0.2.3)
|
||||
Requirement already satisfied: setuptools>=42.0.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (83.0.0)
|
||||
Requirement already satisfied: pywin32>=223 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pypiwin32->kivy[base]) (312)
|
||||
@@ -0,0 +1,286 @@
|
||||
# Kiwy Signage Player - Windows Edition
|
||||
|
||||
Build and run the Kiwy digital signage player on Windows as a standalone `.exe`.
|
||||
|
||||
## 📋 Requirements Analysis
|
||||
|
||||
The original app was built for **Raspberry Pi (Linux)**, using these technologies:
|
||||
|
||||
| Component | Original (RPi/Linux) | Windows Equivalent |
|
||||
|-----------|---------------------|-------------------|
|
||||
| **GUI** | Kivy 2.3+ | Kivy 2.3+ (works cross-platform) |
|
||||
| **Video** | ffpyplayer | ffpyplayer (needs FFmpeg DLLs) |
|
||||
| **Card Reader** | evdev (Linux input) | ✅ Raw Input API + LL-hook fallback |
|
||||
| **Screen Keep-Awake** | xset, xdotool, Wayland | `SetThreadExecutionState` (Win32 API) |
|
||||
| **Weblink** | chromium-browser (kiosk) | Chrome/Edge (--kiosk mode) |
|
||||
| **Audio** | ALSA/PulseAudio | DirectSound |
|
||||
| **Window Backend** | SDL2 (Wayland/X11) | SDL2 (Windows native) |
|
||||
| **OpenGL** | Desktop GL | ANGLE (DirectX wrapper) |
|
||||
|
||||
### What works on Windows
|
||||
- ✅ Media playback (images, videos via ffpyplayer)
|
||||
- ✅ Playlist sync from DigiServer (HTTP/HTTPS)
|
||||
- ✅ Touch & mouse controls
|
||||
- ✅ Settings popup
|
||||
- ✅ Image editing/annotation
|
||||
- ✅ Password-protected exit
|
||||
- ✅ Web links (opens in Chrome/Edge kiosk)
|
||||
- ✅ Network monitoring
|
||||
- ✅ Auto-update playlist
|
||||
- ✅ Card reader authentication (Raw Input API — see below)
|
||||
|
||||
### What is disabled on Windows
|
||||
- ❌ HDMI power management (tvservice is RPi-specific)
|
||||
- ❌ WiFi restart (uses Linux `nmcli`)
|
||||
|
||||
## 🚀 Quick Start (Development)
|
||||
|
||||
### Prerequisites
|
||||
1. **Python 3.12+** (64-bit) — [python.org](https://python.org)
|
||||
- ⚠️ **Python 3.13+ is NOT supported** — Kivy 2.3.1 does not have pre-built wheels for it
|
||||
- ⚠️ **Python 3.14 is NOT supported** — no Kivy wheels available
|
||||
- ✅ **Python 3.12.9** is the recommended version (confirmed working)
|
||||
2. **FFmpeg** — for video codec support
|
||||
- Download from [ffmpeg.org](https://ffmpeg.org/download.html)
|
||||
- Add `bin\` folder to your PATH
|
||||
3. **Visual C++ Redistributable** — [latest](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||
|
||||
### Install & Run
|
||||
```batch
|
||||
cd windows
|
||||
|
||||
REM Create virtual environment with Python 3.12
|
||||
py -3.12 -m venv venv
|
||||
:: OR specify full path:
|
||||
:: "C:\Users\Dell-PC\AppData\Local\Programs\Python\Python312\python.exe" -m venv venv
|
||||
|
||||
venv\Scripts\activate
|
||||
|
||||
REM Install dependencies
|
||||
pip install -r requirements_win.txt
|
||||
|
||||
REM Run in development mode
|
||||
python run_win.py
|
||||
```
|
||||
|
||||
## 📦 Building the .exe
|
||||
|
||||
### One-Command Build
|
||||
```batch
|
||||
cd windows
|
||||
build_win.bat
|
||||
```
|
||||
|
||||
### Manual Build
|
||||
```batch
|
||||
cd windows
|
||||
venv\Scripts\activate
|
||||
pip install -r requirements_win.txt
|
||||
pyinstaller build.spec --clean --noconfirm
|
||||
```
|
||||
|
||||
### Output
|
||||
```
|
||||
windows\dist\KiwySignagePlayer\
|
||||
├── KiwySignagePlayer.exe # Main executable
|
||||
├── config/ # Config files (auto-copied)
|
||||
├── resources/ # Icons, intro video
|
||||
└── ... (supporting DLLs)
|
||||
```
|
||||
|
||||
For a **single-file .exe**, edit `build.spec` — uncomment the `exe_onefile` section and comment out the `coll = COLLECT(...)` section.
|
||||
|
||||
## 🌐 Web Links (embedded WebView2)
|
||||
|
||||
Web links render with **WebView2**, embedded as a child window *inside* the
|
||||
Kivy window. Because it is not a separate browser process, it cannot open
|
||||
behind the player, cannot be handed off to an existing browser and exit, and
|
||||
never leaves leaked `msedge.exe`/`chrome.exe` processes behind.
|
||||
|
||||
WebView2 has **two** parts, and they are handled differently:
|
||||
|
||||
| Part | What it is | How it ships |
|
||||
|------|------------|--------------|
|
||||
| **SDK** | `Microsoft.Web.WebView2.Core.dll` + `WebView2Loader.dll` — the API surface | Bundled in the exe (`windows\webview2_sdk\`, ~860 KB) |
|
||||
| **Runtime** | `msedgewebview2.exe` — the actual Chromium engine | Microsoft's evergreen component. Ships with Windows 11 and nearly all Windows 10 machines. **Installed automatically on first start if missing.** |
|
||||
|
||||
### Automatic Runtime installation
|
||||
|
||||
On start-up the player checks for the Runtime (registry `pv` value under the
|
||||
Edge Update client GUID, with a live SDK probe as fallback). If it is absent it
|
||||
runs the installer silently:
|
||||
|
||||
```
|
||||
MicrosoftEdgeWebview2Setup.exe /silent /install
|
||||
```
|
||||
|
||||
Deliberately **not elevated** — an unelevated run performs a *per-user* install,
|
||||
so no UAC dialog ever appears on the signage display.
|
||||
|
||||
Installers are looked up in this order, so you can drop a replacement next to
|
||||
the `.exe` without rebuilding:
|
||||
|
||||
1. `KIWY_WEBVIEW2_INSTALLER` environment variable
|
||||
2. `<exe dir>\webview2_runtime\`
|
||||
3. `<exe dir>\` (next to the executable)
|
||||
4. bundled copy inside the exe
|
||||
|
||||
| Installer | Size | Bundled? | Use when |
|
||||
|-----------|------|----------|----------|
|
||||
| `MicrosoftEdgeWebview2Setup.exe` | ~1.7 MB | ✅ yes | Machine has internet (downloads the Runtime) |
|
||||
| `MicrosoftEdgeWebView2RuntimeInstallerX64.exe` | ~203 MB | ⬜ opt-in | Machine is **offline** |
|
||||
|
||||
To bundle the offline installer (adds ~200 MB to the exe):
|
||||
|
||||
```powershell
|
||||
cd windows
|
||||
.\webview2_runtime\download_runtime_installers.ps1 -Offline
|
||||
venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||
```
|
||||
|
||||
Re-download the installers at any time (they are Microsoft-signed; the script
|
||||
verifies the signature):
|
||||
|
||||
```powershell
|
||||
.\webview2_runtime\download_runtime_installers.ps1
|
||||
```
|
||||
|
||||
### If WebView2 is unavailable
|
||||
|
||||
Web links fall back to the Chrome/Edge subprocess engine (see
|
||||
`src/weblink_session.py`). That engine still works, but reintroduces the old
|
||||
drawbacks — a separate browser window, possible background/z-order behaviour,
|
||||
and browser processes to clean up.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Web links show a black/blank page | Check `logs\` for `[WebView2]` lines. Confirm the Runtime version is reported. |
|
||||
| Runtime install did not happen | A failed attempt is not retried for 6 hours (marker: `logs\.webview2_install_attempt`). Delete that file to retry immediately. |
|
||||
| Need it working offline | Bundle the standalone installer (see above). |
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
**No configuration ships with the exe.** On a machine that has never been set
|
||||
up, `config\app_config.json` is absent, so after the splash video the player
|
||||
shows a *"Player is not configured"* notice, waits 5 seconds and then opens the
|
||||
**Settings** screen automatically. Enter the server details and playback starts
|
||||
right away — no restart required.
|
||||
|
||||
On a machine that already has a valid `config\app_config.json`, the first-run
|
||||
flow is skipped entirely and the cached playlist plays immediately.
|
||||
|
||||
1. Config is created next to the **executable** (not in `%APPDATA%`)
|
||||
- The .exe creates: `config/`, `media/`, `playlists/`, `logs/` locally
|
||||
- This allows you to copy the entire `dist\KiwySignagePlayer\` folder anywhere and it works
|
||||
2. The player is considered configured once `server_ip`, `screen_name` and
|
||||
`quickconnect_key` all hold real values. Placeholder values
|
||||
(`localhost`, `kivy-player`, `1234567`, `127.0.0.1`) count as unconfigured.
|
||||
3. Edit `config\app_config.json` (next to the .exe) to set your server:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_ip": "192.168.0.109",
|
||||
"port": "8080",
|
||||
"screen_name": "Birou_IT",
|
||||
"quickconnect_key": "8887779",
|
||||
"orientation": "Landscape",
|
||||
"touch": "True",
|
||||
"max_resolution": "1920x1080",
|
||||
"edit_feature_enabled": true,
|
||||
"use_https": false,
|
||||
"verify_ssl": false
|
||||
}
|
||||
```
|
||||
|
||||
## 💳 Card Reader (Windows Edition)
|
||||
|
||||
The card reader now works on Windows via the **Raw Input API** (with a
|
||||
low-level keyboard-hook fallback). It replaces the Linux-only `evdev`
|
||||
implementation automatically when `run_win.py` starts.
|
||||
|
||||
- Detection mirrors the Linux logic:
|
||||
1. A device named with `card` / `reader` / `rfid`
|
||||
2. A USB HID keyboard (non-PS/2) — most card readers enumerate this way
|
||||
3. Any remaining keyboard (excluding touchscreens/mice)
|
||||
- Only keystrokes from the **selected device** are captured, so the
|
||||
operator's real keyboard cannot pollute card data.
|
||||
- Card data ends on **Enter** (same as Linux).
|
||||
|
||||
### Card reader config (optional)
|
||||
|
||||
Add any of these to `config\app_config.json` next to the .exe:
|
||||
|
||||
```json
|
||||
{
|
||||
"card_reader_mode": "auto", // "auto" | "raw" | "hook"
|
||||
"card_reader_device": "", // e.g. "VID_08FF" to force a specific device
|
||||
"card_reader_timeout": 5 // seconds
|
||||
}
|
||||
```
|
||||
|
||||
- `card_reader_mode`: `auto` (default, tries Raw Input then falls back),
|
||||
`raw` (force Raw Input), or `hook` (force the low-level keyboard hook).
|
||||
- `card_reader_device`: optional substring of the device name to pin the
|
||||
reader (e.g. `VID_08FF`, `HID#VID_08FF`). Run the manual test below to see
|
||||
the exact device names on your host.
|
||||
- `card_reader_timeout`: how long the swipe popup waits (default 5 s).
|
||||
|
||||
### Manual card reader test (no GUI)
|
||||
|
||||
```batch
|
||||
cd windows
|
||||
venv\Scripts\activate
|
||||
python win_card_reader.py
|
||||
```
|
||||
|
||||
Swipe a card within 10 seconds — the tool prints the captured data, then
|
||||
exits. The detected devices are listed in the console/log.
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```batch
|
||||
cd windows
|
||||
venv\Scripts\activate
|
||||
python run_win.py
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| **"ffpyplayer not found"** | Install: `pip install ffpyplayer` |
|
||||
| **"No video" / black screen** | Install FFmpeg and add to PATH. Try `KIVY_GL_BACKEND=angle_sdl2` or `KIVY_GL_BACKEND=gl` |
|
||||
| **Kivy window doesn't open** | Run from command prompt to see error messages. Ensure GPU drivers are up to date. |
|
||||
| **Weblinks not opening** | Install Google Chrome or Microsoft Edge |
|
||||
| **Can't connect to server** | Check firewall. Try `use_https: false` and `verify_ssl: false` for testing |
|
||||
| **Antivirus flags .exe** | Add the output folder to antivirus exclusions. This is a false positive common with PyInstaller. |
|
||||
|
||||
## 📁 Project Structure (Build)
|
||||
|
||||
```
|
||||
Kiwy-Signage/
|
||||
├── windows/
|
||||
│ ├── run_win.py # Windows entry point (patches platform differences)
|
||||
│ ├── build.spec # PyInstaller configuration
|
||||
│ ├── build_win.bat # One-click build script
|
||||
│ ├── pyi_runtime_hook.py # PyInstaller runtime hook
|
||||
│ ├── requirements_win.txt # Windows Python dependencies
|
||||
│ └── README_WINDOWS_BUILD.md # This file
|
||||
├── src/
|
||||
│ ├── main.py # Main application (original)
|
||||
│ ├── get_playlists_v2.py # Playlist sync
|
||||
│ ├── player_auth.py # Authentication
|
||||
│ ├── ssl_utils.py # SSL/HTTPS
|
||||
│ ├── keyboard_widget.py # On-screen keyboard
|
||||
│ ├── network_monitor.py # Network monitoring
|
||||
│ ├── edit_popup.py # Image editing
|
||||
│ └── signage_player.kv # Kivy UI layout
|
||||
├── config/
|
||||
│ ├── app_config.json # Player configuration
|
||||
│ └── resources/ # Icons, images, intro video
|
||||
├── media/ # Downloaded media (created at runtime)
|
||||
├── playlists/ # Playlist files (created at runtime)
|
||||
└── logs/ # Log files (created at runtime)
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Quick syntax check for build files."""
|
||||
import ast, sys
|
||||
|
||||
files = [
|
||||
r'c:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\run_win.py',
|
||||
r'c:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\pyi_runtime_hook.py',
|
||||
]
|
||||
|
||||
for f in files:
|
||||
try:
|
||||
with open(f, encoding='utf-8') as fh:
|
||||
ast.parse(fh.read())
|
||||
print(f"OK: {f}")
|
||||
except SyntaxError as e:
|
||||
print(f"SYNTAX ERROR in {f}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print("All files OK")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,317 @@
|
||||
Options in 'KiwySignagePlayer.exe' (PKG/CArchive):
|
||||
pyi-contents-directory _internal
|
||||
Contents of 'KiwySignagePlayer.exe' (PKG/CArchive):
|
||||
position, length, uncompressed_length, is_compressed, typecode, name
|
||||
0, 233, 289, 1, 'm', 'struct'
|
||||
233, 2778, 4826, 1, 'm', 'pyimod01_archive'
|
||||
3011, 13580, 32114, 1, 'm', 'pyimod02_importers'
|
||||
16591, 2722, 6130, 1, 'm', 'pyimod03_ctypes'
|
||||
19313, 917, 1614, 1, 'm', 'pyimod04_pywin32'
|
||||
20230, 1110, 1921, 1, 's', 'pyiboot01_bootstrap'
|
||||
21340, 2848, 5575, 1, 's', 'pyi_runtime_hook'
|
||||
24188, 1432, 2700, 1, 's', 'pyi_rth_inspect'
|
||||
25620, 949, 1509, 1, 's', 'pyi_rth_pkgutil'
|
||||
26569, 1316, 2303, 1, 's', 'pyi_rth_multiprocessing'
|
||||
27885, 656, 998, 1, 's', 'pyi_rth_setuptools'
|
||||
28541, 160, 198, 1, 's', 'pyi_rth_ffpyplayer'
|
||||
28701, 423, 688, 1, 's', 'pyi_rth_kivy'
|
||||
29124, 28242, 65483, 1, 's', 'run_win'
|
||||
57366, 520, 930, 1, 'b', 'COPYING.txt'
|
||||
57886, 944666, 2343424, 1, 'b', 'PIL\\_imaging.cp312-win_amd64.pyd'
|
||||
1002552, 117575, 262656, 1, 'b', 'PIL\\_imagingcms.cp312-win_amd64.pyd'
|
||||
1120127, 900284, 1819648, 1, 'b', 'PIL\\_imagingft.cp312-win_amd64.pyd'
|
||||
2020411, 9107, 24064, 1, 'b', 'PIL\\_imagingmath.cp312-win_amd64.pyd'
|
||||
2029518, 6961, 14848, 1, 'b', 'PIL\\_imagingtk.cp312-win_amd64.pyd'
|
||||
2036479, 209833, 412160, 1, 'b', 'PIL\\_webp.cp312-win_amd64.pyd'
|
||||
2246312, 263, 433, 1, 'b', 'README-SDL.txt'
|
||||
2246575, 816508, 2509824, 1, 'b', 'SDL2.dll'
|
||||
3063083, 91632, 173568, 1, 'b', 'SDL2_image.dll'
|
||||
3154715, 143874, 285184, 1, 'b', 'SDL2_mixer.dll'
|
||||
3298589, 874449, 1799680, 1, 'b', 'SDL2_ttf.dll'
|
||||
4173038, 58181, 120400, 1, 'b', 'VCRUNTIME140.dll'
|
||||
4231219, 26333, 49744, 1, 'b', 'VCRUNTIME140_1.dll'
|
||||
4257552, 34004, 74088, 1, 'b', '_asyncio.pyd'
|
||||
4291556, 46620, 86888, 1, 'b', '_bz2.pyd'
|
||||
4338176, 59751, 127848, 1, 'b', '_ctypes.pyd'
|
||||
4397927, 124899, 259432, 1, 'b', '_decimal.pyd'
|
||||
4522826, 61874, 134648, 1, 'b', '_elementtree.pyd'
|
||||
4584700, 30861, 67576, 1, 'b', '_hashlib.pyd'
|
||||
4615561, 89779, 160616, 1, 'b', '_lzma.pyd'
|
||||
4705340, 20837, 37736, 1, 'b', '_multiprocessing.pyd'
|
||||
4726177, 28975, 58224, 1, 'b', '_overlapped.pyd'
|
||||
4755152, 19049, 33784, 1, 'b', '_queue.pyd'
|
||||
4774201, 41220, 85864, 1, 'b', '_socket.pyd'
|
||||
4815421, 71923, 179192, 1, 'b', '_ssl.pyd'
|
||||
4887344, 15676, 27128, 1, 'b', '_uuid.pyd'
|
||||
4903020, 21645, 39416, 1, 'b', '_wmi.pyd'
|
||||
4924665, 87854, 227328, 1, 'b', 'ada92cb5d92a588d1b93__mypyc.cp312-win_amd64.pyd'
|
||||
5012519, 105554, 260608, 1, 'b', 'aiohttp\\_http_parser.cp312-win_amd64.pyd'
|
||||
5118073, 21089, 44544, 1, 'b', 'aiohttp\\_http_writer.cp312-win_amd64.pyd'
|
||||
5139162, 16199, 34816, 1, 'b', 'aiohttp\\_websocket\\mask.cp312-win_amd64.pyd'
|
||||
5155361, 63949, 138752, 1, 'b', 'aiohttp\\_websocket\\reader_c.cp312-win_amd64.pyd'
|
||||
5219310, 12, 4, 1, 'b', 'attrs-26.1.0.dist-info\\INSTALLER'
|
||||
5219322, 3482, 8754, 1, 'b', 'attrs-26.1.0.dist-info\\METADATA'
|
||||
5222804, 1673, 3556, 1, 'b', 'attrs-26.1.0.dist-info\\RECORD'
|
||||
5224477, 92, 87, 1, 'b', 'attrs-26.1.0.dist-info\\WHEEL'
|
||||
5224569, 662, 1109, 1, 'b', 'attrs-26.1.0.dist-info\\licenses\\LICENSE'
|
||||
5225231, 26493495, 77325312, 1, 'b', 'avcodec-60.dll'
|
||||
31718726, 1747062, 3856896, 1, 'b', 'avdevice-60.dll'
|
||||
33465788, 20925644, 39591424, 1, 'b', 'avfilter-9.dll'
|
||||
54391432, 7870655, 16813056, 1, 'b', 'avformat-60.dll'
|
||||
62262087, 821334, 2193408, 1, 'b', 'avutil-58.dll'
|
||||
63083421, 396887, 1333532, 1, 'b', 'base_library.zip'
|
||||
63480308, 140181, 305152, 1, 'b', 'bcrypt\\_bcrypt.pyd'
|
||||
63620489, 131713, 240216, 1, 'b', 'certifi\\cacert.pem'
|
||||
63752202, 8, 0, 1, 'b', 'certifi\\py.typed'
|
||||
63752210, 4372, 10752, 1, 'b', 'charset_normalizer\\cd.cp312-win_amd64.pyd'
|
||||
63756582, 4370, 10752, 1, 'b', 'charset_normalizer\\md.cp312-win_amd64.pyd'
|
||||
63760952, 198, 286, 1, 'b', 'config\\app_config.json'
|
||||
63761150, 36525, 36970, 1, 'b', 'config\\resources\\access-card.png'
|
||||
63797675, 4402, 4404, 1, 'b', 'config\\resources\\arrow.png'
|
||||
63802077, 37299, 37689, 1, 'b', 'config\\resources\\backward.png'
|
||||
63839376, 281239, 288324, 1, 'b', 'config\\resources\\card-checked.png'
|
||||
64120615, 10025, 11406, 1, 'b', 'config\\resources\\edit-pen.png'
|
||||
64130640, 4034, 4023, 1, 'b', 'config\\resources\\exit.png'
|
||||
64134674, 38397, 38800, 1, 'b', 'config\\resources\\forward.png'
|
||||
64173071, 10979543, 10985920, 1, 'b', 'config\\resources\\intro1.mp4'
|
||||
75152614, 32847, 33152, 1, 'b', 'config\\resources\\pause.png'
|
||||
75185461, 14060, 14977, 1, 'b', 'config\\resources\\pencil.png'
|
||||
75199521, 35800, 36471, 1, 'b', 'config\\resources\\play.png'
|
||||
75235321, 25241, 25611, 1, 'b', 'config\\resources\\settings.png'
|
||||
75260562, 2132580, 4916728, 1, 'b', 'd3dcompiler_47.dll'
|
||||
77393142, 124, 151, 1, 'b', 'docutils\\docutils.conf'
|
||||
77393266, 311, 670, 1, 'b', 'docutils\\parsers\\rst\\include\\README.rst'
|
||||
77393577, 244, 433, 1, 'b', 'docutils\\parsers\\rst\\include\\html-roles.txt'
|
||||
77393821, 2167, 10925, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamsa.txt'
|
||||
77395988, 2013, 7242, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamsb.txt'
|
||||
77398001, 591, 1723, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamsc.txt'
|
||||
77398592, 1521, 6721, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamsn.txt'
|
||||
77400113, 1164, 3825, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamso.txt'
|
||||
77401277, 2796, 11763, 1, 'b', 'docutils\\parsers\\rst\\include\\isoamsr.txt'
|
||||
77404073, 640, 3101, 1, 'b', 'docutils\\parsers\\rst\\include\\isobox.txt'
|
||||
77404713, 824, 4241, 1, 'b', 'docutils\\parsers\\rst\\include\\isocyr1.txt'
|
||||
77405537, 502, 1882, 1, 'b', 'docutils\\parsers\\rst\\include\\isocyr2.txt'
|
||||
77406039, 434, 869, 1, 'b', 'docutils\\parsers\\rst\\include\\isodia.txt'
|
||||
77406473, 656, 3010, 1, 'b', 'docutils\\parsers\\rst\\include\\isogrk1.txt'
|
||||
77407129, 451, 1705, 1, 'b', 'docutils\\parsers\\rst\\include\\isogrk2.txt'
|
||||
77407580, 721, 2880, 1, 'b', 'docutils\\parsers\\rst\\include\\isogrk3.txt'
|
||||
77408301, 719, 3035, 1, 'b', 'docutils\\parsers\\rst\\include\\isogrk4-wide.txt'
|
||||
77409020, 252, 372, 1, 'b', 'docutils\\parsers\\rst\\include\\isogrk4.txt'
|
||||
77409272, 843, 4397, 1, 'b', 'docutils\\parsers\\rst\\include\\isolat1.txt'
|
||||
77410115, 1404, 8466, 1, 'b', 'docutils\\parsers\\rst\\include\\isolat2.txt'
|
||||
77411519, 641, 3334, 1, 'b', 'docutils\\parsers\\rst\\include\\isomfrk-wide.txt'
|
||||
77412160, 273, 519, 1, 'b', 'docutils\\parsers\\rst\\include\\isomfrk.txt'
|
||||
77412433, 470, 1931, 1, 'b', 'docutils\\parsers\\rst\\include\\isomopf-wide.txt'
|
||||
77412903, 292, 639, 1, 'b', 'docutils\\parsers\\rst\\include\\isomopf.txt'
|
||||
77413195, 649, 3231, 1, 'b', 'docutils\\parsers\\rst\\include\\isomscr-wide.txt'
|
||||
77413844, 315, 776, 1, 'b', 'docutils\\parsers\\rst\\include\\isomscr.txt'
|
||||
77414159, 1301, 4066, 1, 'b', 'docutils\\parsers\\rst\\include\\isonum.txt'
|
||||
77415460, 1443, 4613, 1, 'b', 'docutils\\parsers\\rst\\include\\isopub.txt'
|
||||
77416903, 2727, 9726, 1, 'b', 'docutils\\parsers\\rst\\include\\isotech.txt'
|
||||
77419630, 7648, 45428, 1, 'b', 'docutils\\parsers\\rst\\include\\mmlalias.txt'
|
||||
77427278, 2069, 9010, 1, 'b', 'docutils\\parsers\\rst\\include\\mmlextra-wide.txt'
|
||||
77429347, 1820, 6800, 1, 'b', 'docutils\\parsers\\rst\\include\\mmlextra.txt'
|
||||
77431167, 371, 1036, 1, 'b', 'docutils\\parsers\\rst\\include\\s5defs.txt'
|
||||
77431538, 1405, 6112, 1, 'b', 'docutils\\parsers\\rst\\include\\xhtml1-lat1.txt'
|
||||
77432943, 717, 1945, 1, 'b', 'docutils\\parsers\\rst\\include\\xhtml1-special.txt'
|
||||
77433660, 1859, 7028, 1, 'b', 'docutils\\parsers\\rst\\include\\xhtml1-symbol.txt'
|
||||
77435519, 2221, 7300, 1, 'b', 'docutils\\writers\\html4css1\\html4css1.css'
|
||||
77437740, 69, 114, 1, 'b', 'docutils\\writers\\html4css1\\template.txt'
|
||||
77437809, 467, 1145, 1, 'b', 'docutils\\writers\\html5_polyglot\\italic-field-names.css'
|
||||
77438276, 2018, 6219, 1, 'b', 'docutils\\writers\\html5_polyglot\\math.css'
|
||||
77440294, 2867, 8279, 1, 'b', 'docutils\\writers\\html5_polyglot\\minimal.css'
|
||||
77443161, 2760, 7531, 1, 'b', 'docutils\\writers\\html5_polyglot\\plain.css'
|
||||
77445921, 3791, 11887, 1, 'b', 'docutils\\writers\\html5_polyglot\\responsive.css'
|
||||
77449712, 69, 114, 1, 'b', 'docutils\\writers\\html5_polyglot\\template.txt'
|
||||
77449781, 3768, 12002, 1, 'b', 'docutils\\writers\\html5_polyglot\\tuftig.css'
|
||||
77453549, 276, 422, 1, 'b', 'docutils\\writers\\latex2e\\default.tex'
|
||||
77453825, 2571, 7548, 1, 'b', 'docutils\\writers\\latex2e\\docutils.sty'
|
||||
77456396, 299, 480, 1, 'b', 'docutils\\writers\\latex2e\\titlepage.tex'
|
||||
77456695, 268, 424, 1, 'b', 'docutils\\writers\\latex2e\\titlingpage.tex'
|
||||
77456963, 429, 675, 1, 'b', 'docutils\\writers\\latex2e\\xelatex.tex'
|
||||
77457392, 13789, 16500, 1, 'b', 'docutils\\writers\\odf_odt\\styles.odt'
|
||||
77471181, 1802, 6366, 1, 'b', 'docutils\\writers\\pep_html\\pep.css'
|
||||
77472983, 589, 1001, 1, 'b', 'docutils\\writers\\pep_html\\template.txt'
|
||||
77473572, 193, 278, 1, 'b', 'docutils\\writers\\s5_html\\themes\\README.rst'
|
||||
77473765, 40, 38, 1, 'b', 'docutils\\writers\\s5_html\\themes\\big-black\\__base__'
|
||||
77473805, 454, 910, 1, 'b', 'docutils\\writers\\s5_html\\themes\\big-black\\framing.css'
|
||||
77474259, 1351, 3605, 1, 'b', 'docutils\\writers\\s5_html\\themes\\big-black\\pretty.css'
|
||||
77475610, 464, 905, 1, 'b', 'docutils\\writers\\s5_html\\themes\\big-white\\framing.css'
|
||||
77476074, 1341, 3565, 1, 'b', 'docutils\\writers\\s5_html\\themes\\big-white\\pretty.css'
|
||||
77477415, 483, 1002, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\framing.css'
|
||||
77477898, 193, 261, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\opera.css'
|
||||
77478091, 371, 648, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\outline.css'
|
||||
77478462, 1569, 4383, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\pretty.css'
|
||||
77480031, 440, 818, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\print.css'
|
||||
77480471, 255, 450, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\s5-core.css'
|
||||
77480726, 177, 283, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\slides.css'
|
||||
77480903, 4542, 15801, 1, 'b', 'docutils\\writers\\s5_html\\themes\\default\\slides.js'
|
||||
77485445, 43, 41, 1, 'b', 'docutils\\writers\\s5_html\\themes\\medium-black\\__base__'
|
||||
77485488, 1431, 4029, 1, 'b', 'docutils\\writers\\s5_html\\themes\\medium-black\\pretty.css'
|
||||
77486919, 476, 943, 1, 'b', 'docutils\\writers\\s5_html\\themes\\medium-white\\framing.css'
|
||||
77487395, 1422, 3989, 1, 'b', 'docutils\\writers\\s5_html\\themes\\medium-white\\pretty.css'
|
||||
77488817, 42, 40, 1, 'b', 'docutils\\writers\\s5_html\\themes\\small-black\\__base__'
|
||||
77488859, 1434, 4028, 1, 'b', 'docutils\\writers\\s5_html\\themes\\small-black\\pretty.css'
|
||||
77490293, 472, 940, 1, 'b', 'docutils\\writers\\s5_html\\themes\\small-white\\framing.css'
|
||||
77490765, 1431, 3999, 1, 'b', 'docutils\\writers\\s5_html\\themes\\small-white\\pretty.css'
|
||||
77492196, 6437, 27642, 1, 'b', 'edit_popup.py'
|
||||
77498633, 175382, 381440, 1, 'b', 'ffmpeg.exe'
|
||||
77674015, 701649, 1808896, 1, 'b', 'ffplay.exe'
|
||||
78375664, 85267, 193536, 1, 'b', 'ffprobe.exe'
|
||||
78460931, 100772, 248320, 1, 'b', 'ffpyplayer\\pic.cp312-win_amd64.pyd'
|
||||
78561703, 20085, 44032, 1, 'b', 'ffpyplayer\\player\\clock.cp312-win_amd64.pyd'
|
||||
78581788, 63850, 141312, 1, 'b', 'ffpyplayer\\player\\core.cp312-win_amd64.pyd'
|
||||
78645638, 22718, 50176, 1, 'b', 'ffpyplayer\\player\\decoder.cp312-win_amd64.pyd'
|
||||
78668356, 27571, 60928, 1, 'b', 'ffpyplayer\\player\\frame_queue.cp312-win_amd64.pyd'
|
||||
78695927, 63687, 162816, 1, 'b', 'ffpyplayer\\player\\player.cp312-win_amd64.pyd'
|
||||
78759614, 22292, 49152, 1, 'b', 'ffpyplayer\\player\\queue.cp312-win_amd64.pyd'
|
||||
78781906, 34855, 80896, 1, 'b', 'ffpyplayer\\threading.cp312-win_amd64.pyd'
|
||||
78816761, 79458, 192000, 1, 'b', 'ffpyplayer\\tools.cp312-win_amd64.pyd'
|
||||
78896219, 51273, 116736, 1, 'b', 'ffpyplayer\\writer.cp312-win_amd64.pyd'
|
||||
78947492, 30805, 69632, 1, 'b', 'frozenlist\\_frozenlist.cp312-win_amd64.pyd'
|
||||
78978297, 4749, 19539, 1, 'b', 'get_playlists_v2.py'
|
||||
78983046, 120595, 464896, 1, 'b', 'glew32.dll'
|
||||
79103641, 1710, 6172, 1, 'b', 'keyboard_widget.py'
|
||||
79105351, 92036, 235520, 1, 'b', 'kivy\\_clock.cp312-win_amd64.pyd'
|
||||
79197387, 93303, 225792, 1, 'b', 'kivy\\_event.cp312-win_amd64.pyd'
|
||||
79290690, 24318, 51200, 1, 'b', 'kivy\\_metrics.cp312-win_amd64.pyd'
|
||||
79315008, 48723, 118784, 1, 'b', 'kivy\\core\\audio\\audio_sdl2.cp312-win_amd64.pyd'
|
||||
79363731, 16010, 34304, 1, 'b', 'kivy\\core\\clipboard\\_clipboard_sdl2.cp312-win_amd64.pyd'
|
||||
79379741, 30469, 66048, 1, 'b', 'kivy\\core\\image\\_img_sdl2.cp312-win_amd64.pyd'
|
||||
79410210, 33746, 75264, 1, 'b', 'kivy\\core\\text\\_text_sdl2.cp312-win_amd64.pyd'
|
||||
79443956, 56988, 134656, 1, 'b', 'kivy\\core\\text\\text_layout.cp312-win_amd64.pyd'
|
||||
79500944, 68847, 163840, 1, 'b', 'kivy\\core\\window\\_window_sdl2.cp312-win_amd64.pyd'
|
||||
79569791, 17971, 39424, 1, 'b', 'kivy\\core\\window\\window_info.cp312-win_amd64.pyd'
|
||||
79587762, 52367, 122880, 1, 'b', 'kivy\\graphics\\boxshadow.cp312-win_amd64.pyd'
|
||||
79640129, 20841, 44544, 1, 'b', 'kivy\\graphics\\buffer.cp312-win_amd64.pyd'
|
||||
79660970, 47050, 120320, 1, 'b', 'kivy\\graphics\\cgl.cp312-win_amd64.pyd'
|
||||
79708020, 81600, 253952, 1, 'b', 'kivy\\graphics\\cgl_backend\\cgl_debug.cp312-win_amd64.pyd'
|
||||
79789620, 18632, 43520, 1, 'b', 'kivy\\graphics\\cgl_backend\\cgl_gl.cp312-win_amd64.pyd'
|
||||
79808252, 20135, 45056, 1, 'b', 'kivy\\graphics\\cgl_backend\\cgl_glew.cp312-win_amd64.pyd'
|
||||
79828387, 15666, 35328, 1, 'b', 'kivy\\graphics\\cgl_backend\\cgl_mock.cp312-win_amd64.pyd'
|
||||
79844053, 16864, 38400, 1, 'b', 'kivy\\graphics\\cgl_backend\\cgl_sdl2.cp312-win_amd64.pyd'
|
||||
79860917, 28102, 61952, 1, 'b', 'kivy\\graphics\\compiler.cp312-win_amd64.pyd'
|
||||
79889019, 56063, 128512, 1, 'b', 'kivy\\graphics\\context.cp312-win_amd64.pyd'
|
||||
79945082, 108332, 308224, 1, 'b', 'kivy\\graphics\\context_instructions.cp312-win_amd64.pyd'
|
||||
80053414, 54592, 121856, 1, 'b', 'kivy\\graphics\\fbo.cp312-win_amd64.pyd'
|
||||
80108006, 39459, 91136, 1, 'b', 'kivy\\graphics\\gl_instructions.cp312-win_amd64.pyd'
|
||||
80147465, 74629, 182272, 1, 'b', 'kivy\\graphics\\instructions.cp312-win_amd64.pyd'
|
||||
80222094, 119725, 363520, 1, 'b', 'kivy\\graphics\\opengl.cp312-win_amd64.pyd'
|
||||
80341819, 35344, 79872, 1, 'b', 'kivy\\graphics\\opengl_utils.cp312-win_amd64.pyd'
|
||||
80377163, 47174, 114688, 1, 'b', 'kivy\\graphics\\scissor_instructions.cp312-win_amd64.pyd'
|
||||
80424337, 63296, 143872, 1, 'b', 'kivy\\graphics\\shader.cp312-win_amd64.pyd'
|
||||
80487633, 50731, 122880, 1, 'b', 'kivy\\graphics\\stencil_instructions.cp312-win_amd64.pyd'
|
||||
80538364, 178240, 405504, 1, 'b', 'kivy\\graphics\\svg.cp312-win_amd64.pyd'
|
||||
80716604, 88938, 194048, 1, 'b', 'kivy\\graphics\\tesselator.cp312-win_amd64.pyd'
|
||||
80805542, 143998, 340992, 1, 'b', 'kivy\\graphics\\texture.cp312-win_amd64.pyd'
|
||||
80949540, 51826, 121856, 1, 'b', 'kivy\\graphics\\transformation.cp312-win_amd64.pyd'
|
||||
81001366, 40775, 90112, 1, 'b', 'kivy\\graphics\\vbo.cp312-win_amd64.pyd'
|
||||
81042141, 23396, 50176, 1, 'b', 'kivy\\graphics\\vertex.cp312-win_amd64.pyd'
|
||||
81065537, 253190, 651264, 1, 'b', 'kivy\\graphics\\vertex_instructions.cp312-win_amd64.pyd'
|
||||
81318727, 170074, 440320, 1, 'b', 'kivy\\properties.cp312-win_amd64.pyd'
|
||||
81488801, 45575, 121344, 1, 'b', 'kivy\\weakproxy.cp312-win_amd64.pyd'
|
||||
81534376, 372872, 741536, 1, 'b', 'kivy_install\\data\\fonts\\DejaVuSans.ttf'
|
||||
81907248, 86778, 162464, 1, 'b', 'kivy_install\\data\\fonts\\Roboto-Bold.ttf'
|
||||
81994026, 90614, 163644, 1, 'b', 'kivy_install\\data\\fonts\\Roboto-BoldItalic.ttf'
|
||||
82084640, 90243, 161484, 1, 'b', 'kivy_install\\data\\fonts\\Roboto-Italic.ttf'
|
||||
82174883, 86502, 162876, 1, 'b', 'kivy_install\\data\\fonts\\Roboto-Regular.ttf'
|
||||
82261385, 66934, 114624, 1, 'b', 'kivy_install\\data\\fonts\\RobotoMono-Regular.ttf'
|
||||
82328319, 89, 98, 1, 'b', 'kivy_install\\data\\glsl\\default.fs'
|
||||
82328408, 74, 74, 1, 'b', 'kivy_install\\data\\glsl\\default.png'
|
||||
82328482, 154, 196, 1, 'b', 'kivy_install\\data\\glsl\\default.vs'
|
||||
82328636, 169, 241, 1, 'b', 'kivy_install\\data\\glsl\\header.fs'
|
||||
82328805, 221, 387, 1, 'b', 'kivy_install\\data\\glsl\\header.vs'
|
||||
82329026, 4670, 8723, 1, 'b', 'kivy_install\\data\\images\\background.jpg'
|
||||
82333696, 136, 138, 1, 'b', 'kivy_install\\data\\images\\cursor.png'
|
||||
82333832, 3589, 4053, 1, 'b', 'kivy_install\\data\\images\\defaultshape.png'
|
||||
82337421, 52717, 54001, 1, 'b', 'kivy_install\\data\\images\\defaulttheme-0.png'
|
||||
82390138, 1059, 3519, 1, 'b', 'kivy_install\\data\\images\\defaulttheme.atlas'
|
||||
82391197, 2419, 2890, 1, 'b', 'kivy_install\\data\\images\\image-loading.gif'
|
||||
82393616, 3859, 5744, 1, 'b', 'kivy_install\\data\\images\\image-loading.zip'
|
||||
82397475, 73, 73, 1, 'b', 'kivy_install\\data\\images\\testpattern.png'
|
||||
82397548, 873, 3615, 1, 'b', 'kivy_install\\data\\keyboards\\azerty.json'
|
||||
82398421, 1168, 5408, 1, 'b', 'kivy_install\\data\\keyboards\\de.json'
|
||||
82399589, 986, 5120, 1, 'b', 'kivy_install\\data\\keyboards\\de_CH.json'
|
||||
82400575, 962, 5092, 1, 'b', 'kivy_install\\data\\keyboards\\en_US.json'
|
||||
82401537, 1082, 5199, 1, 'b', 'kivy_install\\data\\keyboards\\es_ES.json'
|
||||
82402619, 985, 5120, 1, 'b', 'kivy_install\\data\\keyboards\\fr_CH.json'
|
||||
82403604, 778, 3382, 1, 'b', 'kivy_install\\data\\keyboards\\qwerty.json'
|
||||
82404382, 800, 3396, 1, 'b', 'kivy_install\\data\\keyboards\\qwertz.json'
|
||||
82405182, 3197, 3186, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-128.png'
|
||||
82408379, 403, 392, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-16.png'
|
||||
82408782, 549, 538, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-24.png'
|
||||
82409331, 7202, 7329, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-256.png'
|
||||
82416533, 735, 724, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-32.png'
|
||||
82417268, 1057, 1046, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-48.png'
|
||||
82418325, 15737, 16577, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-512.png'
|
||||
82434062, 4074, 34494, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-64.ico'
|
||||
82438136, 1479, 1468, 1, 'b', 'kivy_install\\data\\logo\\kivy-icon-64.png'
|
||||
82439615, 742, 2720, 1, 'b', 'kivy_install\\data\\settings_kivy.json'
|
||||
82440357, 7411, 44878, 1, 'b', 'kivy_install\\data\\style.kv'
|
||||
82447768, 3007, 9798, 1, 'b', 'kivy_install\\modules\\__init__.py'
|
||||
82450775, 5757, 12502, 1, 'b', 'kivy_install\\modules\\__pycache__\\__init__.cpython-312.pyc'
|
||||
82456532, 73583, 206420, 1, 'b', 'kivy_install\\modules\\__pycache__\\_webdebugger.cpython-312.pyc'
|
||||
82530115, 18521, 47723, 1, 'b', 'kivy_install\\modules\\__pycache__\\console.cpython-312.pyc'
|
||||
82548636, 1939, 3280, 1, 'b', 'kivy_install\\modules\\__pycache__\\cursor.cpython-312.pyc'
|
||||
82550575, 13250, 32506, 1, 'b', 'kivy_install\\modules\\__pycache__\\inspector.cpython-312.pyc'
|
||||
82563825, 5839, 13495, 1, 'b', 'kivy_install\\modules\\__pycache__\\joycursor.cpython-312.pyc'
|
||||
82569664, 1330, 2283, 1, 'b', 'kivy_install\\modules\\__pycache__\\keybinding.cpython-312.pyc'
|
||||
82570994, 2684, 5325, 1, 'b', 'kivy_install\\modules\\__pycache__\\monitor.cpython-312.pyc'
|
||||
82573678, 1696, 3413, 1, 'b', 'kivy_install\\modules\\__pycache__\\recorder.cpython-312.pyc'
|
||||
82575374, 4402, 9129, 1, 'b', 'kivy_install\\modules\\__pycache__\\screen.cpython-312.pyc'
|
||||
82579776, 678, 1080, 1, 'b', 'kivy_install\\modules\\__pycache__\\showborder.cpython-312.pyc'
|
||||
82580454, 2148, 4194, 1, 'b', 'kivy_install\\modules\\__pycache__\\touchring.cpython-312.pyc'
|
||||
82582602, 626, 874, 1, 'b', 'kivy_install\\modules\\__pycache__\\webdebugger.cpython-312.pyc'
|
||||
82583228, 71299, 205887, 1, 'b', 'kivy_install\\modules\\_webdebugger.py'
|
||||
82654527, 8272, 35417, 1, 'b', 'kivy_install\\modules\\console.py'
|
||||
82662799, 911, 2142, 1, 'b', 'kivy_install\\modules\\cursor.py'
|
||||
82663710, 5948, 26047, 1, 'b', 'kivy_install\\modules\\inspector.py'
|
||||
82669658, 2768, 10332, 1, 'b', 'kivy_install\\modules\\joycursor.py'
|
||||
82672426, 793, 1764, 1, 'b', 'kivy_install\\modules\\keybinding.py'
|
||||
82673219, 940, 2637, 1, 'b', 'kivy_install\\modules\\monitor.py'
|
||||
82674159, 907, 2575, 1, 'b', 'kivy_install\\modules\\recorder.py'
|
||||
82675066, 2356, 7659, 1, 'b', 'kivy_install\\modules\\screen.py'
|
||||
82677422, 346, 612, 1, 'b', 'kivy_install\\modules\\showborder.py'
|
||||
82677768, 935, 2665, 1, 'b', 'kivy_install\\modules\\touchring.py'
|
||||
82678703, 394, 607, 1, 'b', 'kivy_install\\modules\\webdebugger.py'
|
||||
82679097, 204710, 454656, 1, 'b', 'libEGL.dll'
|
||||
82883807, 2774926, 6930432, 1, 'b', 'libGLESv2.dll'
|
||||
85658733, 913283, 2259456, 1, 'b', 'libavif-16.dll'
|
||||
86572016, 1856079, 5232408, 1, 'b', 'libcrypto-3.dll'
|
||||
88428095, 23195, 39696, 1, 'b', 'libffi-8.dll'
|
||||
88451290, 276147, 612864, 1, 'b', 'libgme.dll'
|
||||
88727437, 21224, 35328, 1, 'b', 'libogg-0.dll'
|
||||
88748661, 212946, 370176, 1, 'b', 'libopus-0.dll'
|
||||
88961607, 25354, 51200, 1, 'b', 'libopusfile-0.dll'
|
||||
88986961, 282155, 792856, 1, 'b', 'libssl-3.dll'
|
||||
89269116, 134415, 387584, 1, 'b', 'libtiff-5.dll'
|
||||
89403531, 84996, 175616, 1, 'b', 'libwavpack-1.dll'
|
||||
89488527, 217175, 444416, 1, 'b', 'libwebp-7.dll'
|
||||
89705702, 11110, 24064, 1, 'b', 'libwebpdemux-2.dll'
|
||||
89716812, 207217, 387584, 1, 'b', 'libxmp.dll'
|
||||
89924029, 29936, 135281, 1, 'b', 'main.py'
|
||||
89953965, 33321, 80896, 1, 'b', 'multidict\\_multidict.cp312-win_amd64.pyd'
|
||||
89987286, 2944, 12877, 1, 'b', 'network_monitor.py'
|
||||
89990230, 1041, 2071, 1, 'b', 'playback_trace.py'
|
||||
89991271, 163, 232, 1, 'b', 'player_auth.json'
|
||||
89991434, 3160, 15723, 1, 'b', 'player_auth.py'
|
||||
89994594, 34958, 76288, 1, 'b', 'postproc-57.dll'
|
||||
90029552, 27945, 62976, 1, 'b', 'propcache\\_helpers_c.cp312-win_amd64.pyd'
|
||||
90057497, 99152, 204136, 1, 'b', 'pyexpat.pyd'
|
||||
90156649, 24308, 70504, 1, 'b', 'python3.dll'
|
||||
90180957, 2542616, 6920936, 1, 'b', 'python312.dll'
|
||||
92723573, 54552, 136192, 1, 'b', 'pywin32_system32\\pywintypes312.dll'
|
||||
92778125, 18840, 33128, 1, 'b', 'select.pyd'
|
||||
92796965, 672, 1335, 1, 'b', 'setuptools\\_vendor\\jaraco\\text\\Lorem ipsum.txt'
|
||||
92797637, 3800, 34773, 1, 'b', 'signage_player.kv'
|
||||
92801437, 2421, 9330, 1, 'b', 'ssl_utils.py'
|
||||
92803858, 192880, 437760, 1, 'b', 'swresample-4.dll'
|
||||
92996738, 200713, 642560, 1, 'b', 'swscale-7.dll'
|
||||
93197451, 743, 1980, 1, 'b', 'test_network_monitor.py'
|
||||
93198194, 418808, 1139704, 1, 'b', 'unicodedata.pyd'
|
||||
93617002, 57022, 138752, 1, 'b', 'win32\\win32api.pyd'
|
||||
93674024, 57386, 142848, 1, 'b', 'win32\\win32file.pyd'
|
||||
93731410, 83574, 223232, 1, 'b', 'win32\\win32gui.pyd'
|
||||
93814984, 22145, 53760, 1, 'b', 'win32\\win32process.pyd'
|
||||
93837129, 38500, 82944, 1, 'b', 'yarl\\_quoting_c.cp312-win_amd64.pyd'
|
||||
93875629, 9308477, 9308477, 0, 'z', 'PYZ.pyz'
|
||||
@@ -0,0 +1,369 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""
|
||||
PyInstaller spec file for Kiwy Signage Player (Windows .exe)
|
||||
|
||||
Build command (from windows/ directory):
|
||||
pyinstaller build.spec --clean --noconfirm
|
||||
|
||||
OR use the build script:
|
||||
build_win.bat
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# --- Paths -----------------------------------------------------------
|
||||
# This spec file is in windows/build.spec, so the project root is
|
||||
# always two levels up from this file's real location.
|
||||
# __file__ may not be available in PyInstaller spec context fallback to cwd.
|
||||
try:
|
||||
_spec_dir = Path(__file__).resolve().parent
|
||||
except NameError:
|
||||
_spec_dir = Path(os.getcwd()).resolve()
|
||||
# _spec_dir is now the absolute path to the windows/ directory
|
||||
BUILD_DIR = _spec_dir
|
||||
ROOT_DIR = BUILD_DIR.parent
|
||||
SRC_DIR = ROOT_DIR / 'src'
|
||||
CONFIG_DIR = ROOT_DIR / 'config'
|
||||
RESOURCES_DIR = CONFIG_DIR / 'resources'
|
||||
|
||||
# --- Determine hidden imports that PyInstaller might miss -------------
|
||||
hidden_imports = [
|
||||
# Kivy core modules
|
||||
'kivy.core.window',
|
||||
'kivy.core.video',
|
||||
'kivy.core.audio',
|
||||
'kivy.core.text',
|
||||
'kivy.core.image',
|
||||
'kivy.core.gl',
|
||||
'kivy.core.camera',
|
||||
'kivy.core.clipboard',
|
||||
'kivy.core.spelling',
|
||||
'kivy.core.text.markup',
|
||||
'kivy.core.window.window_sdl2',
|
||||
'kivy.core.image.img_sdl2',
|
||||
'kivy.core.video.video_ffpyplayer',
|
||||
'kivy.core.audio.audio_ffpyplayer',
|
||||
# Kivy modules
|
||||
'kivy.uix.video',
|
||||
'kivy.uix.vkeyboard',
|
||||
'kivy.uix.popup',
|
||||
'kivy.uix.image',
|
||||
'kivy.uix.button',
|
||||
'kivy.uix.label',
|
||||
'kivy.uix.textinput',
|
||||
'kivy.uix.boxlayout',
|
||||
'kivy.uix.floatlayout',
|
||||
'kivy.uix.slider',
|
||||
'kivy.uix.widget',
|
||||
'kivy.uix.checkbox',
|
||||
'kivy.graphics',
|
||||
'kivy.graphics.texture',
|
||||
'kivy.graphics.vertex_instructions',
|
||||
'kivy.graphics.context_instructions',
|
||||
'kivy.clock',
|
||||
'kivy.loader',
|
||||
'kivy.animation',
|
||||
'kivy.lang',
|
||||
'kivy.logger',
|
||||
'kivy.config',
|
||||
'kivy.properties',
|
||||
'kivy.metrics',
|
||||
'kivy.factory',
|
||||
# Graphics providers
|
||||
'kivy.graphics.opengl',
|
||||
'kivy.graphics.opengl_utils',
|
||||
'kivy.graphics.fbo',
|
||||
'kivy.graphics.gl_instructions',
|
||||
'kivy.graphics.stencil_instructions',
|
||||
'kivy.graphics.scissor_instructions',
|
||||
'kivy.graphics.buffer',
|
||||
'kivy.graphics.vbo',
|
||||
'kivy.graphics.shader',
|
||||
'kivy.graphics.compiler',
|
||||
# ffpyplayer
|
||||
'ffpyplayer',
|
||||
'ffpyplayer.player',
|
||||
'ffpyplayer.pic',
|
||||
'ffpyplayer.writer',
|
||||
# Networking
|
||||
'requests',
|
||||
'aiohttp',
|
||||
'urllib3',
|
||||
'certifi',
|
||||
'bcrypt',
|
||||
# Platform
|
||||
'ctypes',
|
||||
'ctypes.wintypes',
|
||||
'subprocess',
|
||||
'shutil',
|
||||
'glob',
|
||||
'selectors',
|
||||
'tempfile',
|
||||
# Windows-specific
|
||||
'cef_browser',
|
||||
'webview2_browser',
|
||||
'webview2_runtime',
|
||||
'win32gui',
|
||||
'win32con',
|
||||
# Unified web-link controller (launch / verified visibility / interaction
|
||||
# watcher / teardown) — imported by main.py and run_win.py
|
||||
'weblink_session',
|
||||
# Windows-native card reader (Raw Input API + LL-hook fallback)
|
||||
'win_card_reader',
|
||||
]
|
||||
|
||||
# Exclude Linux-only modules
|
||||
excluded_imports = [
|
||||
'gi', # GTK introspection (Linux)
|
||||
'gi.repository',
|
||||
'evdev', # We inject a fake evdev module in run_win.py
|
||||
# GStreamer — we use ffpyplayer, not GStreamer
|
||||
'kivy.lib.gstplayer',
|
||||
# cefpython3: keep only Python 3.12 .pyd, exclude other version .pyd files
|
||||
'cefpython3.cefpython_py27',
|
||||
'cefpython3.cefpython_py34',
|
||||
'cefpython3.cefpython_py35',
|
||||
'cefpython3.cefpython_py36',
|
||||
'cefpython3.cefpython_py37',
|
||||
'cefpython3.cefpython_py38',
|
||||
'cefpython3.cefpython_py39',
|
||||
'cefpython3.cefpython_py310',
|
||||
'cefpython3.cefpython_py311',
|
||||
]
|
||||
|
||||
# --- Application data files to bundle --------------------------------
|
||||
# Resources (icons, intro video, etc.)
|
||||
resources_data = []
|
||||
for item in RESOURCES_DIR.iterdir():
|
||||
if item.is_file():
|
||||
target_dir = 'config/resources'
|
||||
resources_data.append((str(item), target_dir))
|
||||
|
||||
# Config directory.
|
||||
#
|
||||
# app_config.json is deliberately NOT bundled. Including it shipped the
|
||||
# developer's own server_ip / screen_name inside the exe, so a fresh install
|
||||
# silently connected to the wrong server (or to a placeholder) instead of
|
||||
# asking the operator. The player now starts unconfigured, shows a notice after
|
||||
# the splash video and opens Settings to collect the real values, which are
|
||||
# then saved next to the .exe.
|
||||
config_data = []
|
||||
config_file = CONFIG_DIR / 'app_config.json'
|
||||
if config_file.exists():
|
||||
print("[spec] app_config.json is NOT bundled (first-run setup collects it)")
|
||||
|
||||
# --- Bundled web engines ---------------------------------------------
|
||||
# Embedded WebView2 (Edge) SDK: the managed assembly plus the native loader
|
||||
# DLL. The *runtime* itself is a Microsoft-shipped evergreen component and is
|
||||
# deliberately NOT bundled (that is the point of using WebView2 — no ~150 MB
|
||||
# Chromium payload inside our exe).
|
||||
webview2_data = []
|
||||
webview2_sdk = BUILD_DIR / 'webview2_sdk'
|
||||
if webview2_sdk.is_dir():
|
||||
for item in webview2_sdk.iterdir():
|
||||
if item.is_file():
|
||||
webview2_data.append((str(item), 'webview2_sdk'))
|
||||
print(f"[spec] Bundling {len(webview2_data)} WebView2 SDK file(s) from {webview2_sdk}")
|
||||
|
||||
# WebView2 Runtime installer, so a machine that ships WITHOUT the Runtime can
|
||||
# install it on first start (see windows/webview2_runtime.py).
|
||||
#
|
||||
# Only the small bootstrapper (~1.7 MB) is bundled by default; it downloads the
|
||||
# Runtime from Microsoft. Dropping the ~203 MB offline standalone installer
|
||||
# into windows/webview2_runtime/ bundles it too, which is what you want for
|
||||
# machines with no internet — but it triples the exe size, so it is opt-in.
|
||||
webview2_runtime = BUILD_DIR / 'webview2_runtime'
|
||||
_bootstrap = webview2_runtime / 'MicrosoftEdgeWebview2Setup.exe'
|
||||
_standalone = webview2_runtime / 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe'
|
||||
if _bootstrap.is_file():
|
||||
webview2_data.append((str(_bootstrap), 'webview2_runtime'))
|
||||
print(f"[spec] Bundling WebView2 Runtime bootstrapper ({_bootstrap.stat().st_size / 1024 / 1024:.1f} MB)")
|
||||
if _standalone.is_file():
|
||||
webview2_data.append((str(_standalone), 'webview2_runtime'))
|
||||
print(f"[spec] Bundling WebView2 offline standalone installer "
|
||||
f"({_standalone.stat().st_size / 1024 / 1024:.0f} MB) — exe will be much larger")
|
||||
if not _bootstrap.is_file() and not _standalone.is_file():
|
||||
print("=" * 70)
|
||||
print("WARNING: no WebView2 Runtime installer in windows/webview2_runtime/.")
|
||||
print("Machines without the Runtime cannot show web links (they fall back")
|
||||
print("to the Chrome/Edge subprocess engine).")
|
||||
print("=" * 70)
|
||||
else:
|
||||
print("=" * 70)
|
||||
print("WARNING: windows/webview2_sdk/ not found.")
|
||||
print("Web links will fall back to the Chrome/Edge subprocess engine.")
|
||||
print("=" * 70)
|
||||
|
||||
# Source files - .kv file
|
||||
kv_file = SRC_DIR / 'signage_player.kv'
|
||||
kv_data = []
|
||||
if kv_file.exists():
|
||||
kv_data.append((str(kv_file), '.'))
|
||||
|
||||
# Bundle the entire src directory as a tree.
|
||||
#
|
||||
# EXCLUDE player_auth.json: it holds LIVE credentials (auth_code, player_id,
|
||||
# server_url). Bundling it means the frozen app starts up in _internal/ and
|
||||
# loads that snapshot as its auth state — so a freshly built exe boots
|
||||
# "already authenticated" against whatever server the file happened to name,
|
||||
# and plays a stale playlist. Auth must be created at runtime in the data dir
|
||||
# next to the .exe (see run_win.py `_patch_auth_paths`).
|
||||
source_tree = Tree(
|
||||
str(SRC_DIR),
|
||||
prefix='',
|
||||
excludes=['*.pyc', '__pycache__', '*.ini', 'player_auth.json'],
|
||||
)
|
||||
|
||||
# --- Collect binary DLLs from kivy_deps and ffpyplayer ----------------
|
||||
import importlib.util
|
||||
from pathlib import Path as _Path
|
||||
|
||||
|
||||
def _site_packages_dir(package_path):
|
||||
"""Climb up from a package's __init__.py to its site-packages dir."""
|
||||
d = _Path(package_path).parent
|
||||
while d.name != 'site-packages' and d.parent != d:
|
||||
d = d.parent
|
||||
return d
|
||||
|
||||
|
||||
def _find_share_dlls(package_name, share_name=None):
|
||||
"""Find .dll files under venv_root/share/<share_name>/.
|
||||
|
||||
kivy_deps.sdl2/angle/glew and ffpyplayer install their DLLs into
|
||||
<venv>/share/<pkg>/... NOT inside the package dir. The share folder is
|
||||
named after the *short* dep name (e.g. 'sdl2', 'angle', 'glew'), not the
|
||||
dotted package name ('kivy_deps.sdl2'), so pass share_name explicitly.
|
||||
"""
|
||||
if share_name is None:
|
||||
share_name = package_name
|
||||
spec = importlib.util.find_spec(package_name)
|
||||
if spec is None or not spec.origin:
|
||||
return []
|
||||
sp = _site_packages_dir(spec.origin)
|
||||
# Climb from site-packages up until we find a sibling 'share' dir
|
||||
# (site-packages -> Lib -> venv, where venv/share lives).
|
||||
d = sp
|
||||
while d.parent != d:
|
||||
if (d.parent / 'share').is_dir():
|
||||
share = d.parent / 'share' / share_name
|
||||
break
|
||||
d = d.parent
|
||||
else:
|
||||
return []
|
||||
if not share.is_dir():
|
||||
return []
|
||||
results = []
|
||||
for root, dirs, files in os.walk(share):
|
||||
for f in files:
|
||||
if f.endswith('.dll'):
|
||||
results.append((os.path.join(root, f), '.'))
|
||||
return results
|
||||
|
||||
|
||||
def _find_ffpyplayer_bins():
|
||||
"""Return ffpyplayer's own dependency DLL dirs (FFmpeg + bundled SDL).
|
||||
|
||||
ffpyplayer ships a `dep_bins` list that already points at the correct
|
||||
share/ffpyplayer/ffmpeg/bin and share/ffpyplayer/sdl/bin directories.
|
||||
"""
|
||||
try:
|
||||
import ffpyplayer
|
||||
bins = getattr(ffpyplayer, 'dep_bins', None)
|
||||
if not bins:
|
||||
return []
|
||||
results = []
|
||||
for b in bins:
|
||||
bpath = _Path(b)
|
||||
if bpath.is_dir():
|
||||
for f in bpath.glob('*.dll'):
|
||||
results.append((str(f), '.'))
|
||||
return results
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# SDL2 / ANGLE / GLEW DLLs (kivy_deps share dirs)
|
||||
_sdl2_dlls = _find_share_dlls('kivy_deps.sdl2', 'sdl2')
|
||||
_angle_dlls = _find_share_dlls('kivy_deps.angle', 'angle')
|
||||
_glew_dlls = _find_share_dlls('kivy_deps.glew', 'glew')
|
||||
|
||||
# ffpyplayer FFmpeg + bundled SDL DLLs (via dep_bins)
|
||||
_ffpy_dlls = _find_ffpyplayer_bins()
|
||||
|
||||
_all_binaries = _sdl2_dlls + _angle_dlls + _glew_dlls + _ffpy_dlls
|
||||
|
||||
if not _all_binaries:
|
||||
print("=" * 70)
|
||||
print("WARNING: No Kivy/ffpyplayer DLLs found via share/ directories.")
|
||||
print("PyInstaller may still auto-detect them, but if the .exe")
|
||||
print("fails with 'SDL2.dll not found' or similar, you will need")
|
||||
print("to manually add the DLL paths to the spec file.")
|
||||
print("=" * 70)
|
||||
else:
|
||||
print(f"[spec] Bundling {len(_all_binaries)} DLLs:")
|
||||
for _p, _t in sorted(_all_binaries):
|
||||
print(f" {_Path(_p).name} <- {_p}")
|
||||
|
||||
# --- Build the .exe --------------------------------------------------
|
||||
a = Analysis(
|
||||
['run_win.py'], # Entry point (relative to this spec)
|
||||
pathex=[str(BUILD_DIR), str(SRC_DIR)], # Where to find modules
|
||||
binaries=_all_binaries,
|
||||
datas=resources_data + config_data + kv_data + webview2_data,
|
||||
hiddenimports=hidden_imports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[str(BUILD_DIR / 'pyi_runtime_hook.py')],
|
||||
excludes=excluded_imports,
|
||||
noarchive=False,
|
||||
module_collection_mode={
|
||||
'kivy': 'pyz',
|
||||
'kivy.core': 'pyz',
|
||||
'kivy.uix': 'pyz',
|
||||
'kivy.graphics': 'pyz',
|
||||
},
|
||||
)
|
||||
|
||||
# Add the source tree (main.py, etc.)
|
||||
a.datas += source_tree
|
||||
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='KiwySignagePlayer',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True, # Show console for debugging startup errors
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=str(BUILD_DIR / 'app_icon.ico') if (BUILD_DIR / 'app_icon.ico').exists() else None,
|
||||
version=str(BUILD_DIR / 'version_info.txt') if (BUILD_DIR / 'version_info.txt').exists() else None,
|
||||
)
|
||||
|
||||
# --- COLLECT everything into a single folder -------------------------
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='KiwySignagePlayer',
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
libtiff-5.dll <- C:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\venv\share\sdl2\bin\libtiff-5.dll
|
||||
libwavpack-1.dll <- C:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\venv\share\sdl2\bin\libwavpack-1.dll
|
||||
libwebp-7.dll <- C:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\venv\share\sdl2\bin\libwebp-7.dll
|
||||
libwebpdemux-2.dll <- C:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\venv\share\sdl2\bin\libwebpdemux-2.dll
|
||||
libxmp.dll <- C:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\venv\share\sdl2\bin\libxmp.dll
|
||||
@@ -0,0 +1,158 @@
|
||||
@echo off
|
||||
REM =====================================================================
|
||||
REM Kiwy Signage Player - Windows Build Script
|
||||
REM =====================================================================
|
||||
REM This script builds a standalone Windows .exe using PyInstaller.
|
||||
REM
|
||||
REM Prerequisites:
|
||||
REM 1. Python 3.10+ installed (with "Add to PATH" checked)
|
||||
REM 2. Visual C++ Redistributable (for ffpyplayer)
|
||||
REM 3. FFmpeg binaries in PATH (optional, for video codec support)
|
||||
REM
|
||||
REM Steps:
|
||||
REM 1. Run this script from the project root or the windows\ folder
|
||||
REM 2. The .exe will be created in windows\dist\KiwySignagePlayer\
|
||||
REM =====================================================================
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ============================================
|
||||
echo Kiwy Signage Player - Windows Build
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
REM ---- Check Python ----
|
||||
where python >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] Python not found! Please install Python 3.10+ and add it to PATH.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [INFO] Using Python:
|
||||
python --version
|
||||
|
||||
REM ---- Create virtual environment (if not exists) ----
|
||||
if not exist "venv\Scripts\python.exe" (
|
||||
echo.
|
||||
echo [STEP] Creating virtual environment...
|
||||
python -m venv venv
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] Failed to create virtual environment.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo [INFO] Virtual environment already exists.
|
||||
)
|
||||
|
||||
REM ---- Activate virtual environment ----
|
||||
call venv\Scripts\activate.bat
|
||||
|
||||
REM ---- Install/upgrade pip ----
|
||||
echo.
|
||||
echo [STEP] Upgrading pip...
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
REM ---- Install dependencies ----
|
||||
echo.
|
||||
echo [STEP] Installing Windows dependencies...
|
||||
pip install -r requirements_win.txt
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] Failed to install dependencies.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ---- Verify Kivy installation ----
|
||||
echo.
|
||||
echo [STEP] Verifying Kivy installation...
|
||||
python -c "import kivy; print(f'Kivy {kivy.__version__}')" 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [WARNING] Kivy check failed. Build may still work but test carefully.
|
||||
)
|
||||
|
||||
REM ---- Check PyInstaller ----
|
||||
echo.
|
||||
echo [STEP] Verifying PyInstaller...
|
||||
python -c "import PyInstaller; print(f'PyInstaller {PyInstaller.__version__}')" 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [ERROR] PyInstaller not found.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ---- Create app icon (from PNG if possible) ----
|
||||
echo.
|
||||
echo [STEP] Checking for app icon...
|
||||
if not exist "..\config\resources\app_icon.ico" (
|
||||
echo [INFO] No .ico icon found. Will use default PyInstaller icon.
|
||||
echo [INFO] To add a custom icon, place app_icon.ico in config\resources\
|
||||
)
|
||||
|
||||
REM ---- Run PyInstaller ----
|
||||
echo.
|
||||
echo [STEP] Building executable with PyInstaller...
|
||||
echo This may take several minutes. Please wait...
|
||||
echo.
|
||||
|
||||
pyinstaller build.spec --clean --noconfirm
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo.
|
||||
echo [ERROR] PyInstaller build failed!
|
||||
echo Check the output above for error details.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ---- Optional code signing ----------------------------------------
|
||||
REM For production PCs with Smart App Control ON, the exe MUST be signed
|
||||
REM by a cert from a reputable public CA. If you have a .pfx, put its path
|
||||
REM in the env var KIWY_SIGN_PFX (and optionally KIWY_SIGN_PFX_PASSWORD),
|
||||
REM or drop a pfx named "kiwy_signing.pfx" in this folder. The build will
|
||||
REM then auto-sign via sign_exe.ps1.
|
||||
echo.
|
||||
echo [STEP] Checking for code-signing certificate...
|
||||
|
||||
set "SIGN_PFX=%KIWY_SIGN_PFX%"
|
||||
if not defined SIGN_PFX if exist "%~dp0kiwy_signing.pfx" set "SIGN_PFX=%~dp0kiwy_signing.pfx"
|
||||
|
||||
if defined SIGN_PFX (
|
||||
echo [INFO ] Code-signing cert found: %SIGN_PFX%
|
||||
if defined KIWY_SIGN_PFX_PASSWORD (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0sign_exe.ps1" -CertPath "%SIGN_PFX%" -CertPassword "%KIWY_SIGN_PFX_PASSWORD%"
|
||||
) else (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0sign_exe.ps1" -CertPath "%SIGN_PFX%"
|
||||
)
|
||||
if not "!ERRORLEVEL!"=="0" (
|
||||
echo [WARNING] Signing failed or signtool missing - the exe is NOT signed.
|
||||
echo Smart App Control machines will still block it.
|
||||
) else (
|
||||
echo [OK] Executable signed successfully.
|
||||
)
|
||||
) else (
|
||||
echo [INFO ] No signing cert found - skipping signing.
|
||||
echo [INFO ] To sign automatically, set KIWY_SIGN_PFX to your .pfx path
|
||||
echo or place "kiwy_signing.pfx" in this folder.
|
||||
echo [INFO ] NOTE: Unsigned exe will be BLOCKED on PCs with Smart App Control ON.
|
||||
)
|
||||
|
||||
REM ---- Success ----
|
||||
echo.
|
||||
echo ============================================
|
||||
echo BUILD COMPLETE!
|
||||
echo ============================================
|
||||
echo.
|
||||
echo Output: %~dp0dist\KiwySignagePlayer\
|
||||
echo.
|
||||
echo The executable is:
|
||||
echo %~dp0dist\KiwySignagePlayer\KiwySignagePlayer.exe
|
||||
echo.
|
||||
echo To run: Double-click KiwySignagePlayer.exe
|
||||
echo.
|
||||
echo Note: The first run may take a while as Windows Defender
|
||||
echo scans the executable. This is normal.
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
cef_browser.py v2 — Embedded Chromium INSIDE Kivy's SDL2 window
|
||||
|
||||
v1 created a separate Win32 window (same as external Chrome).
|
||||
v2 creates CEF as a **child window** of Kivy's SDL_app window:
|
||||
- No separate taskbar entry
|
||||
- No z-order fighting
|
||||
- No desktop flash
|
||||
- CEF message loop pumped via Kivy Clock (main thread)
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cefpython3 import cefpython as cef
|
||||
CEF_AVAILABLE = True
|
||||
except ImportError:
|
||||
CEF_AVAILABLE = False
|
||||
|
||||
WS_CHILD = 0x40000000
|
||||
WS_VISIBLE = 0x10000000
|
||||
WS_CLIPSIBLINGS = 0x04000000
|
||||
WS_CLIPCHILDREN = 0x02000000
|
||||
SW_HIDE = 0
|
||||
SW_SHOWNORMAL = 1
|
||||
|
||||
|
||||
class CefBrowser:
|
||||
def __init__(self):
|
||||
self._browser = None
|
||||
self._cef_initialized = False
|
||||
self._child_hwnd = None
|
||||
self._kivy_hwnd = None
|
||||
self._clock_event = None
|
||||
self._showing = False
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────
|
||||
|
||||
def show(self, url):
|
||||
if not CEF_AVAILABLE:
|
||||
return False
|
||||
if not self._cef_initialized:
|
||||
self._init_cef()
|
||||
if self._browser is not None:
|
||||
self._browser.Navigate(url)
|
||||
self._show_in_kivy()
|
||||
return True
|
||||
return self._create_embedded(url)
|
||||
|
||||
def hide(self):
|
||||
self._showing = False
|
||||
if self._clock_event is not None:
|
||||
try:
|
||||
from kivy.clock import Clock
|
||||
Clock.unschedule(self._clock_event)
|
||||
except Exception:
|
||||
pass
|
||||
self._clock_event = None
|
||||
if self._child_hwnd:
|
||||
try:
|
||||
ctypes.windll.user32.ShowWindow(self._child_hwnd, SW_HIDE)
|
||||
except Exception:
|
||||
pass
|
||||
if self._browser is not None:
|
||||
try:
|
||||
self._browser.CloseBrowser(True)
|
||||
except Exception:
|
||||
pass
|
||||
self._browser = None
|
||||
if self._child_hwnd:
|
||||
try:
|
||||
ctypes.windll.user32.DestroyWindow(self._child_hwnd)
|
||||
except Exception:
|
||||
pass
|
||||
self._child_hwnd = None
|
||||
|
||||
def shutdown(self):
|
||||
self.hide()
|
||||
if self._cef_initialized:
|
||||
try:
|
||||
cef.Shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
self._cef_initialized = False
|
||||
|
||||
def navigate(self, url):
|
||||
if self._browser is not None:
|
||||
self._browser.Navigate(url)
|
||||
|
||||
def is_showing(self):
|
||||
return self._showing
|
||||
|
||||
def resize(self, width, height):
|
||||
"""Called when Kivy window resizes — repositions CEF child."""
|
||||
if self._child_hwnd:
|
||||
ctypes.windll.user32.SetWindowPos(
|
||||
self._child_hwnd, 0, 0, 0, width, height, 0x0004
|
||||
)
|
||||
if self._browser:
|
||||
self._browser.SetBounds(0, 0, width, height)
|
||||
|
||||
# ── Internal ────────────────────────────────────────────────────
|
||||
|
||||
def _init_cef(self):
|
||||
settings = {
|
||||
"multi_threaded_message_loop": False,
|
||||
"single_process": True,
|
||||
"log_severity": cef.LOGSEVERITY_WARNING,
|
||||
"user_agent": "Mozilla/5.0 KiwySignage/1.0",
|
||||
"cache_path": str(
|
||||
Path(os.environ.get("KIWY_DATA_DIR", ".")) / ".cef_cache"
|
||||
),
|
||||
}
|
||||
cef.Initialize(settings=settings)
|
||||
self._cef_initialized = True
|
||||
|
||||
def _get_kivy_hwnd(self):
|
||||
if self._kivy_hwnd is not None:
|
||||
return self._kivy_hwnd
|
||||
try:
|
||||
import win32gui
|
||||
hwnd = win32gui.FindWindow("SDL_app", None)
|
||||
if hwnd:
|
||||
self._kivy_hwnd = hwnd
|
||||
return hwnd
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _create_embedded(self, url):
|
||||
kivy_hwnd = self._get_kivy_hwnd()
|
||||
if not kivy_hwnd:
|
||||
return False
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
rect = (ctypes.c_long * 4)()
|
||||
user32.GetClientRect(kivy_hwnd, ctypes.byref(rect))
|
||||
w, h = rect[2], rect[3]
|
||||
|
||||
hinstance = ctypes.windll.kernel32.GetModuleHandleW(None)
|
||||
self._child_hwnd = user32.CreateWindowExW(
|
||||
0, b'#32770', b'',
|
||||
WS_CHILD | WS_CLIPSIBLINGS | WS_CLIPCHILDREN,
|
||||
0, 0, w, h, kivy_hwnd, 0, hinstance, 0,
|
||||
)
|
||||
if not self._child_hwnd:
|
||||
return False
|
||||
|
||||
winfo = cef.WindowInfo()
|
||||
winfo.SetAsChild(self._child_hwnd, [0, 0, w, h])
|
||||
self._browser = cef.CreateBrowserSync(
|
||||
window_info=winfo,
|
||||
settings={"background_color": 0x00000000},
|
||||
url=url,
|
||||
)
|
||||
self._showing = True
|
||||
self._show_in_kivy()
|
||||
self._start_clock_pump()
|
||||
return True
|
||||
|
||||
def _show_in_kivy(self):
|
||||
if not self._child_hwnd:
|
||||
return
|
||||
kivy_hwnd = self._get_kivy_hwnd()
|
||||
if kivy_hwnd:
|
||||
user32 = ctypes.windll.user32
|
||||
rect = (ctypes.c_long * 4)()
|
||||
user32.GetClientRect(kivy_hwnd, ctypes.byref(rect))
|
||||
user32.SetWindowPos(
|
||||
self._child_hwnd, 0, 0, 0, rect[2], rect[3], 0x0004
|
||||
)
|
||||
ctypes.windll.user32.ShowWindow(self._child_hwnd, SW_SHOWNORMAL)
|
||||
self._showing = True
|
||||
|
||||
def _start_clock_pump(self):
|
||||
if self._clock_event is not None:
|
||||
return
|
||||
|
||||
def _pump(dt):
|
||||
if self._cef_initialized:
|
||||
try:
|
||||
cef.MessageLoopWork()
|
||||
except Exception:
|
||||
pass
|
||||
if self._showing:
|
||||
from kivy.clock import Clock
|
||||
self._clock_event = Clock.schedule_once(_pump, 0.01)
|
||||
|
||||
from kivy.clock import Clock
|
||||
self._clock_event = Clock.schedule_once(_pump, 0)
|
||||
@@ -0,0 +1,110 @@
|
||||
<#
|
||||
================================================================================
|
||||
Kiwy Signage Player - Self-Signed Certificate Creator
|
||||
================================================================================
|
||||
Creates a self-signed code-signing certificate for DEV/TEST machines.
|
||||
|
||||
IMPORTANT (please read before running):
|
||||
A self-signed certificate, even when trusted locally, does NOT satisfy
|
||||
Smart App Control (SAC). SAC only trusts reputable public CAs. This script
|
||||
is therefore ONLY for development / test PCs where you have admin rights
|
||||
and where SAC is either OFF or the app is run with SAC disabled.
|
||||
|
||||
For production PCs (SAC ON, no admin), you MUST buy a code-signing cert
|
||||
from a public CA (Sectigo/SSL.com/DigiCert/GlobalSign) and use
|
||||
sign_exe.ps1 with that .pfx.
|
||||
|
||||
What this does:
|
||||
1. Creates a self-signed code-signing cert in the Current User store
|
||||
(never expires, exportable so you can move it to the build machine).
|
||||
2. Exports it to a .pfx (password-protected) so build_win.bat can sign.
|
||||
3. Asks if you want to trust it on THIS machine (installs to Root + Trusted
|
||||
Publisher + Trusted People) so the player runs without SmartScreen/
|
||||
Defender prompts on this dev PC.
|
||||
|
||||
Usage (run as Administrator):
|
||||
.\create_self_signed_cert.ps1
|
||||
.\create_self_signed_cert.ps1 -CertName "Kiwy Signage Dev" -PfxPassword "ChangeMe!1" -ExportPath "C:\certs\kiwy_dev.pfx"
|
||||
================================================================================
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$CertName = 'Kiwy Signage Player (Dev)',
|
||||
[string]$Subject = 'CN=Kiwy Signage Player (Dev)',
|
||||
[string]$PfxPassword = 'KiwySignage2026!',
|
||||
[string]$ExportPath = (Join-Path $PSScriptRoot 'kiwy_dev_signing.pfx'),
|
||||
[switch]$SkipTrust
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
# ── 1. Create the self-signed code-signing certificate ──────────────
|
||||
Write-Host "[STEP] Creating self-signed code-signing certificate..." -ForegroundColor Cyan
|
||||
|
||||
$cert = New-SelfSignedCertificate `
|
||||
-Subject $Subject `
|
||||
-FriendlyName $CertName `
|
||||
-Type CodeSigningCert `
|
||||
-CertStoreLocation 'Cert:\CurrentUser\My' `
|
||||
-KeyExportPolicy Exportable `
|
||||
-KeyAlgorithm RSA `
|
||||
-KeyLength 2048 `
|
||||
-NotAfter (Get-Date).AddYears(10)
|
||||
|
||||
if (-not $cert) {
|
||||
Write-Host "[ERROR] Failed to create certificate." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[OK ] Created cert: $($cert.Subject) thumbprint=$($cert.Thumbprint)" -ForegroundColor Green
|
||||
|
||||
# ── 2. Export to PFX ─────────────────────────────────────────────────
|
||||
Write-Host "[STEP] Exporting to PFX: $ExportPath" -ForegroundColor Cyan
|
||||
$securePwd = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText
|
||||
try {
|
||||
Export-PfxCertificate -Cert $cert -FilePath $ExportPath -Password $securePwd -Force | Out-Null
|
||||
Write-Host "[OK ] PFX written: $ExportPath" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[WARN ] Could not export PFX (still usable from cert store): $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# ── 3. Trust it on THIS machine (Root + Trusted Publisher) ───────────
|
||||
if (-not $SkipTrust) {
|
||||
Write-Host "[STEP] Installing to Trusted Root + Trusted Publisher (requires admin)..." -ForegroundColor Cyan
|
||||
try {
|
||||
$rootStore = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||
'Root', 'CurrentUser')
|
||||
$rootStore.Open('ReadWrite')
|
||||
$rootStore.Add($cert)
|
||||
$rootStore.Close()
|
||||
|
||||
$pubStore = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||
'TrustedPublisher', 'CurrentUser')
|
||||
$pubStore.Open('ReadWrite')
|
||||
$pubStore.Add($cert)
|
||||
$pubStore.Close()
|
||||
|
||||
$peopleStore = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||
'TrustedPeople', 'CurrentUser')
|
||||
$peopleStore.Open('ReadWrite')
|
||||
$peopleStore.Add($cert)
|
||||
$peopleStore.Close()
|
||||
|
||||
Write-Host "[OK ] Certificate trusted on this machine." -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[WARN ] Trust install failed (run as Administrator): $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "================ RESULT ================" -ForegroundColor Green
|
||||
Write-Host "Cert : $($cert.Subject)"
|
||||
Write-Host "Thumb : $($cert.Thumbprint)"
|
||||
Write-Host "PFX : $ExportPath (password: $PfxPassword)"
|
||||
Write-Host ""
|
||||
Write-Host "To sign the exe with this cert:"
|
||||
Write-Host " .\sign_exe.ps1 -CertPath `"$ExportPath`" -CertPassword `"$PfxPassword`""
|
||||
Write-Host ""
|
||||
Write-Host "REMINDER: This self-signed cert is for DEV ONLY. Production PCs"
|
||||
Write-Host "with Smart App Control ON need a cert from a public CA."
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
@@ -0,0 +1,317 @@
|
||||
# 🧪 Development Track — Kiwy Signage Player (Windows Edition)
|
||||
|
||||
> This file tracks every change, bug fix, tested solution, build info, and
|
||||
> pending issues for the Windows port. Read this FIRST before starting any
|
||||
> debugging or coding session.
|
||||
|
||||
---
|
||||
|
||||
## 📅 Current Session — 2026-08-07
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Branch** | `Windows-Player` |
|
||||
| **Python** | 3.12.9 — `windows\venv\` (250 MB, all deps installed) |
|
||||
| **Kivy** | 2.3.1 |
|
||||
| **PyInstaller** | 6.21.0 |
|
||||
| **Last .exe build** | 2026-08-07 08:23 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (98.8 MB) |
|
||||
| **Build command** | `.\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` |
|
||||
|
||||
### Overnight soak test findings (2026-08-07 morning)
|
||||
|
||||
- **Symptom:** player froze on the last video widget (never advanced past
|
||||
`video_loaded`); heartbeat stopped; 7 leaked `msedge.exe` processes left
|
||||
running in the background.
|
||||
- **Root cause (two compounding bugs in `run_win.py`):**
|
||||
1. **Leaked browser + instant-exit handoff.** A previously leaked Chrome/Edge
|
||||
process held the `.kiosk-profile` lock. The next weblink launch handed
|
||||
the URL to that leaked instance and **exited in ~2s** (`07:04:57` launch →
|
||||
`07:04:59 next_media_called`). The watchdog advanced instantly, so the
|
||||
weblink never showed AND the leaked browser window was never killed →
|
||||
`msedge.exe` processes accumulated overnight.
|
||||
2. **Main-thread freeze.** The focus keeper ran heavy Win32 work
|
||||
(`EnumWindows` + `AttachThreadInput` + `SetForegroundWindow` + `SendInput`)
|
||||
synchronously on the Kivy thread every second. With leaked Edge windows
|
||||
fighting back, this wedged the event loop → video never advanced, heartbeat
|
||||
stopped (`08-07 07:09`).
|
||||
- **Fix (in `run_win.py`, rebuilt 08:23):**
|
||||
1. New `_windows_kill_browsers_on_profile()` — scans `chrome/msedge/chromium`
|
||||
command lines (WMIC, PowerShell fallback), taskkills any browser holding
|
||||
the `.kiosk-profile` lock. Called **before every weblink launch**.
|
||||
2. Watchdog now has `MIN_ALIVE_BEFORE_EARLY_ADVANCE = 8s` — an instant
|
||||
(~2s) handoff exit no longer advances/skips the weblink.
|
||||
3. `_bring_kivy_to_front(async_ok=True)` runs the heavy Win32 bring-to-front
|
||||
on a **background worker thread** guarded by a lock, so the Kivy main
|
||||
thread is never blocked. Synchronous `async_ok=False` still available for
|
||||
explicit transitions.
|
||||
|
||||
### 📋 Cross-platform audit — Linux commands → Windows handling
|
||||
|
||||
Every Linux-only command in `src/` was cross-referenced against the patches
|
||||
in `windows/run_win.py`. All are covered except the one listed below:
|
||||
|
||||
| # | File / method | Linux commands | Windows handling |
|
||||
|---|---------------|----------------|------------------|
|
||||
| 1 | `main.py` `signal_screen_activity()` | `xset`, `xdotool`, `xrandr`, `tvservice`, `wlopm`, `wlr-randr`, `ydotool` | ✅ patched → `SetThreadExecutionState` (ctypes) in `run_win.py` |
|
||||
| 2 | `main.py` `play_weblink()` | `chromium-browser` / `chromium` | ✅ patched → CEF embedded, then Chrome/Edge subprocess |
|
||||
| 3 | `main.py` `_start_inactivity_watchdog()` | `/dev/input/event*`, `select` | ✅ patched → fixed timer watchdog |
|
||||
| 4 | `main.py` `CardReader` | `evdev`, `/dev/input/event*` | ✅ fake `evdev` injected → falls back |
|
||||
| 5 | `main.py` `SettingsPopup.test_connection` | `/tmp/temp_auth_test.json` | ✅ patched → `tempfile.gettempdir()` |
|
||||
| 6 | `main.py` weblink kill/prewarm wrappers | `proc.terminate()` only | ✅ patched → `taskkill /F /T` + `_Win32Overlay` |
|
||||
| 7 | `network_monitor.py` `_test_server_connection()` | `ping -c 3 -W 3` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||
| 8 | `network_monitor.py` `_restart_wifi()` | `sudo rfkill`, `sudo ifconfig`, `sudo dhclient` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||
| 9 | `get_playlists_v2.py`, `player_auth.py`, `ssl_utils.py`, `edit_popup.py`, `keyboard_widget.py` | none | ✅ no Linux commands |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Tracker
|
||||
|
||||
### [BUG-010] NetworkMonitor uses Linux-only ping + rfkill commands
|
||||
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||
- **Symptom:** `network_monitor.py` ran `ping -c 3 -W 3` (Linux flags) and on
|
||||
connection failure invoked `sudo rfkill` / `sudo ifconfig wlan0` /
|
||||
`sudo dhclient` — all fail or hang on Windows (`sudo` isn't even present).
|
||||
- **Root cause:** This module was missed when the other Linux paths were
|
||||
patched in `run_win.py`.
|
||||
- **Fix:** Made `network_monitor.py` self-contained cross-platform:
|
||||
1. Added `IS_WINDOWS = platform.system() == 'Windows'`
|
||||
2. `_test_server_connection()` uses `ping -n 3 -w 3000` on Windows
|
||||
3. `_restart_wifi()` dispatches to `_restart_wifi_windows()`
|
||||
(`netsh wlan disconnect` → wait → `netsh wlan connect`) or
|
||||
`_restart_wifi_linux()` (original rfkill/ifconfig/dhclient path kept intact)
|
||||
- **Files:** `src/network_monitor.py`
|
||||
- **Test:** Windows `ping -n 3 -w 3000 localhost` returns 0; AST parse OK.
|
||||
|
||||
---
|
||||
|
||||
### [BUG-011] Weblink never displays on Windows (opens behind Kivy / exits instantly)
|
||||
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||
- **Symptom:** Web link items don't show. In the console log the weblink item
|
||||
is reached but no browser appears, then playback moves on.
|
||||
- **Root causes (two compounding):**
|
||||
1. **Chrome re-used an existing instance.** `subprocess.Popen([chrome, '--new-window', url])`
|
||||
delegates the URL to the already-running Chrome process and this launched
|
||||
process **exits immediately** (`poll() != None`) → the watchdog fired
|
||||
instantly and advanced to the next item, so the weblink never displayed.
|
||||
2. **Overlay-hide raised Kivy over Chrome.** `_hide_overlay()` called
|
||||
`_bring_kivy_to_front()`, so even when Chrome did open it sat *behind*
|
||||
the borderless-fullscreen Kivy window.
|
||||
- **Fix (in `windows/run_win.py`):**
|
||||
1. Launch Chrome/Edge with a **dedicated `--user-data-dir`** (`<data>/.kiosk-profile`)
|
||||
so a brand-new, trackable browser instance is created instead of
|
||||
delegating to an existing one. Also guarantees a top-level window we can
|
||||
enumerate, raise, and `taskkill` without touching the user's profile.
|
||||
2. `_hide_overlay()` now calls **`_bring_chrome_to_front(proc)`** (new helper
|
||||
that enumerates `Chrome_WidgetWin_1/0` windows owned by the launched PID)
|
||||
instead of raising Kivy.
|
||||
- **Update (2026-07-31 15:42):** added **`--kiosk`** flag to the weblink launch
|
||||
args so the browser opens in true kiosk mode (no UI/chrome, locks to screen).
|
||||
Safe with the dedicated `--user-data-dir` — does not affect the user's normal
|
||||
browser session.
|
||||
- **Update (2026-07-31 16:04):** replaced the fixed 1.0s overlay-hide timer with
|
||||
**adaptive polling** (`_hide_overlay_when_chrome_ready`). The black overlay now
|
||||
stays up until Chrome's window is actually detected on screen
|
||||
(`_find_chrome_hwnd`), so the host desktop is never exposed during cold
|
||||
starts / slow disk / GPU init. Falls back to Kivy after a 6s timeout.
|
||||
- **Update (2026-07-31 16:19):** added a **persistent `_Win32Backdrop`** — a
|
||||
fullscreen black window created at player startup (`_Win32Backdrop.show()`)
|
||||
placed at `HWND_BOTTOM` (below Kivy & the kiosk browser, above the desktop),
|
||||
destroyed only on clean exit. Any browser load/unload gap now reveals clean
|
||||
black instead of the host desktop.
|
||||
- **Test:** exe rebuilt 2026-07-31 16:19; DLL set intact (28 DLLs incl. FFmpeg).
|
||||
|
||||
### [BUG-012] Next widget never comes to foreground after weblink ends
|
||||
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||
- **Symptom:** After a weblink finishes, the next media/widget renders but the
|
||||
Kivy window stays behind (or the window focus is lost) — user sees the wrong
|
||||
window / frozen view.
|
||||
- **Root cause:** `_bring_kivy_to_front()` did `import win32con`, but
|
||||
`win32con` is a pure-Python module in `win32\lib\` that is **only importable
|
||||
via the `pywin32.pth` file**. `.pth` files are ignored in frozen PyInstaller
|
||||
apps, so `win32con` was never bundled (confirmed via `pyi-archive_viewer` —
|
||||
only `win32gui.pyd` / `win32api.pyd` / `win32process.pyd` present). The
|
||||
`import win32con` threw, the whole function silently fell back to
|
||||
`Window.raise_window()`, and the Kivy window was never reliably raised.
|
||||
- **Fix (in `windows/run_win.py`):**
|
||||
1. Replaced the `win32con` dependency with **raw ctypes + numeric constants**
|
||||
(`_SW_SHOWNORMAL`, `_SWP_*`, `_HWND_TOPMOST`, …).
|
||||
2. New `_bring_hwnd_to_front(hwnd)` — ctypes-only `SetForegroundWindow` with
|
||||
`AttachThreadInput` foreground-lock bypass + `IsIconic` restore + topmost
|
||||
flash.
|
||||
3. `_bring_kivy_to_front()` now uses `_find_kivy_hwnd()` (win32gui.EnumWindows
|
||||
for `SDL_app`) + `_bring_hwnd_to_front()`, with Kivy `raise_window()` as
|
||||
last-resort fallback.
|
||||
- **Test:** exe rebuilt; no `win32con` import remains in `run_win.py`.
|
||||
|
||||
---
|
||||
|
||||
### [BUG-001] RecursionError: play_current_media ↔ restart_playlist
|
||||
- **Status:** ✅ Fixed 2026-07-24
|
||||
- **Symptom:** Pressing "Restart Player" in settings with empty playlist causes
|
||||
infinite recursion: `play_current_media → restart_playlist → play_current_media → ...`
|
||||
- **Fix:** Added empty-playlist guard in both `play_current_media()` and
|
||||
`restart_playlist()` → they return early instead of calling each other.
|
||||
- **Files:** `src/main.py` — lines ~1304 and ~2073
|
||||
- **Test:** Verified no Python syntax errors via `ast.parse`.
|
||||
|
||||
### [BUG-002] Settings fields cut off on small screens
|
||||
- **Status:** ✅ Fixed 2026-07-24
|
||||
- **Symptom:** "Screen Name", "Quickconnect" and other fields at the top of
|
||||
the settings popup are invisible on smaller resolutions because content
|
||||
overflows the popup.
|
||||
- **Fix:** Wrapped settings content in a `ScrollView`. Moved "Save & Close" /
|
||||
"Cancel" buttons outside the scroll (always visible). Reduced row heights.
|
||||
- **Files:** `src/signage_player.kv` — `<SettingsPopup@Popup>` block
|
||||
|
||||
### [BUG-003] Chromium not fullscreen on Windows
|
||||
- **Status:** ✅ Fixed 2026-07-24
|
||||
- **Symptom:** Web links open in a small window instead of fullscreen.
|
||||
- **Fix:** Changed launch args from `--kiosk` to `--start-maximized --app=URL`
|
||||
+ explicit `--window-size=WxH`. `--kiosk` uses Wayland exclusive-fullscreen
|
||||
protocol which doesn't work on Windows.
|
||||
- **Tested rejected solutions:**
|
||||
- ❌ `--kiosk` alone → small window, no fullscreen
|
||||
- ❌ `--start-fullscreen` alone → not reliable
|
||||
- ✅ `--start-maximized --app=URL --window-size=...` → works
|
||||
- **Files:** `windows/run_win.py` — `_windows_play_weblink()`
|
||||
|
||||
### [BUG-004] Desktop flash when switching between Chromium and Kivy
|
||||
- **Status:** ✅ Fixed 2026-07-24
|
||||
- **Symptom:** When Chrome closes, the desktop is briefly visible before Kivy
|
||||
reappears. Also when Chrome opens, there's a flash.
|
||||
- **Fix:** Added `_Win32Overlay` class — a fullscreen black Win32 window that
|
||||
covers the screen during transitions. Shown BEFORE closing Chrome / opening
|
||||
Chrome, hidden AFTER Kivy is ready.
|
||||
- **Tested rejected solutions:**
|
||||
- ❌ `Window.raise_window()` alone → still shows flash
|
||||
- ✅ Win32 black overlay → smooth masking
|
||||
- **Files:** `windows/run_win.py` — `_Win32Overlay` class
|
||||
|
||||
### [BUG-005] Chrome processes linger after closing weblink
|
||||
- **Status:** ✅ Fixed 2026-07-24
|
||||
- **Symptom:** After a weblink item ends, Chrome child processes (GPU,
|
||||
renderer) remain running → blank windows accumulate.
|
||||
- **Fix:** Use `taskkill /F /T /PID <pid>` to kill the entire process tree.
|
||||
- **Tested rejected solutions:**
|
||||
- ❌ `proc.terminate()` → leaves children running
|
||||
- ❌ `proc.kill()` → same problem
|
||||
- ✅ `taskkill /F /T` → kills everything
|
||||
- **Files:** `windows/run_win.py` — `_windows_kill_process_tree()`
|
||||
|
||||
### [BUG-007] Video plays behind Chromium on weblink→media transition
|
||||
- **Status:** ✅ **Fixed — 2026-07-26 (final)**
|
||||
- **Symptom:** When a weblink ends and the next media starts, the media plays
|
||||
*behind* Chromium. Audio is heard but user sees Chrome.
|
||||
- **Root cause (Windows):** Linux renders Kivy widget UNDER Chromium → closes
|
||||
Chrome → widget visible. On Windows Chrome stays ON TOP.
|
||||
`Window.raise_window()` is unreliable. Three compounding issues:
|
||||
1. `KivyWindow.minimize()` made Kivy impossible to bring back reliably
|
||||
2. `_windows_play_current_media` killed the browser but never restored
|
||||
`content_area.opacity = 1`, so next widget rendered invisible
|
||||
3. `_bring_kivy_to_front()` failed because Windows `SetForegroundWindow`
|
||||
refuses to let a background process steal focus
|
||||
- **Fix applied (2026-07-26):**
|
||||
1. **Removed `KivyWindow.minimize()`** in `_windows_play_weblink()` — Kivy
|
||||
stays visible behind the overlay instead of being hidden
|
||||
2. **Restored `content_area.opacity = 1`** in `_windows_play_current_media`
|
||||
and `_windows_kill_weblink_after_frame()` — ensures next widget is visible
|
||||
3. **`_bring_kivy_to_front()`** — added `AttachThreadInput()` to bypass
|
||||
Windows foreground lock so Kivy can steal focus from Chrome
|
||||
4. **Overlay hide** now calls `_bring_kivy_to_front()` instead of
|
||||
`Window.raise_window()`
|
||||
5. **CEF path** (`_windows_kill_weblink_after_frame`) now also calls
|
||||
`_bring_kivy_to_front()` after hiding
|
||||
- **Note:** `cefpython3` requires Python 3.10 — falls back to subprocess
|
||||
Chrome/Edge on 3.12.9. Transition now works reliably with subprocess path.
|
||||
- **Files:** `windows/run_win.py`
|
||||
|
||||
### [BUG-008] Intro video and media files not found at runtime
|
||||
- **Status:** ✅ **Fixed** 2026-07-24
|
||||
- **Symptom:** `[ERROR] [Image] Error loading <...intro1.mp4>` — intro
|
||||
broken. Also `❌ Media file not found` for playlist items.
|
||||
- **Root cause:** Media download only ran when `server_version > local_version`.
|
||||
When versions matched (v16 == v16), `download_media_files` was never called
|
||||
→ media folder stayed empty.
|
||||
- **Fix:** Added download check in the "up to date" branch — now downloads
|
||||
missing media files even when playlist version hasn't changed.
|
||||
|
||||
### [BUG-009] Video never advances to next item (EOS handler empty)
|
||||
- **Status:** ✅ **Fixed** 2026-07-24
|
||||
- **Symptom:** Video plays but never advances to the next playlist item.
|
||||
- **Root cause:** `_on_video_eos()` callback was a stub — just logged
|
||||
"Video finished playing (EOS)" but never called `next_media()`.
|
||||
- **Fix:** Added `Clock.unschedule(self.next_media)` + `Clock.schedule_once`
|
||||
to advance after 0.5s when a video reaches end of stream.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Tested & Rejected Solutions Log
|
||||
|
||||
> Keep a record of approaches that were tried and didn't work, so we don't
|
||||
> waste time re-testing them.
|
||||
|
||||
| Date | What was tested | Result | Reason it failed |
|
||||
|------|----------------|--------|-----------------|
|
||||
| 2026-07-24 | Python 3.14 with Kivy | ❌ | `kivy_deps.sdl2_dev~=0.8.0` has no cp314 wheel |
|
||||
| 2026-07-24 | `--kiosk` Chrome flag on Windows | ❌ | Not fullscreen, Wayland exclusive-fullscreen not available |
|
||||
| 2026-07-24 | `--start-fullscreen` alone | ❌ | Inconsistent, sometimes not full |
|
||||
| 2026-07-24 | `proc.terminate()` for Chrome | ❌ | Leaves child processes running |
|
||||
| 2026-07-24 | `proc.kill()` for Chrome | ❌ | Same as terminate — children survive |
|
||||
| 2026-07-24 | `Window.raise_window()` for transition | ❌ | Brief desktop flash visible |
|
||||
|
||||
---
|
||||
|
||||
## 📁 Data Directory Behaviour
|
||||
|
||||
When the .exe runs:
|
||||
1. Runtime hook (`pyi_runtime_hook.py`) sets `KIWY_DATA_DIR = exe_dir`
|
||||
2. `run_win.py` patches `SignagePlayer.__init__` to use `KIWY_DATA_DIR`
|
||||
3. Local folders created next to the .exe:
|
||||
```
|
||||
KiwySignagePlayer.exe
|
||||
config/
|
||||
app_config.json
|
||||
resources/ (icons, intro video)
|
||||
certs/ (SSL certificates)
|
||||
media/
|
||||
edited_media/
|
||||
playlists/
|
||||
logs/
|
||||
.kivy/ (Kivy home)
|
||||
.player_heartbeat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Build Cheatsheet
|
||||
|
||||
```powershell
|
||||
# Build the .exe (from windows/ directory)
|
||||
Set-Location windows
|
||||
& .\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||
|
||||
# Run in dev mode (no build needed)
|
||||
& .\venv\Scripts\python.exe run_win.py
|
||||
|
||||
# Test imports only
|
||||
& .\venv\Scripts\python.exe test_import_fix.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes for the Next Session
|
||||
|
||||
- [x] ~~Investigate [BUG-006]~~ → merged into [BUG-007], fixed with CEF + win32gui
|
||||
- [x] ~~Test `SetForegroundWindow`~~ → `_bring_kivy_to_front()` uses `win32gui`
|
||||
- [x] Install `cefpython3` — embedded Chromium, no more subprocess
|
||||
- [x] ~~Verify CEF embedded browser actually works at runtime~~ → CEF needs Python 3.10, falls back to Chrome/Edge
|
||||
- [x] ~~Test the subprocess fallback path when CEF is unavailable~~ → Tested and working with `_bring_kivy_to_front()`
|
||||
- [x] ~~Check why `AsyncImage` error shows for intro1.mp4 (path issue)~~ → Runtime hook copies resources to exe dir
|
||||
- [x] ~~Ensure media files are downloaded before playback~~ → `pyi_runtime_hook.py` copies config/resources on first run
|
||||
- [x] ~~Add `cef_browser.py` to PyInstaller hidden imports~~ → Already in `build.spec`
|
||||
- [x] ~~Make `network_monitor.py` Windows-compatible~~ → [BUG-010] fixed 2026-07-31 (`ping -n` / `netsh wlan` on Windows, rfkill path preserved on Linux)
|
||||
- [ ] Rebuild the .exe to pick up the `network_monitor.py` fix
|
||||
- [ ] Clean `cefpython3` from `venv/` (Python 3.12 won't use it anyway)
|
||||
- [ ] Verify the .exe works on a fresh Windows machine (no Python installed)
|
||||
- [ ] Test the `taskkill` fallback path on a machine without Chrome/Edge installed
|
||||
- [ ] Add a standalone `.bat` launcher for development mode
|
||||
@@ -0,0 +1,27 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM Kiwy Signage Player - Windows Launcher
|
||||
REM ============================================================
|
||||
REM This batch file launches the Kiwy Signage Player executable.
|
||||
REM It creates local folders for playlist, media, config, and logs
|
||||
REM next to the executable.
|
||||
REM ============================================================
|
||||
|
||||
cd /d "%~dp0dist\KiwySignagePlayer"
|
||||
|
||||
echo ============================================
|
||||
echo Kiwy Signage Player - Windows Edition
|
||||
echo ============================================
|
||||
echo.
|
||||
echo Launching player...
|
||||
echo.
|
||||
|
||||
start "" "KiwySignagePlayer.exe"
|
||||
|
||||
echo Player started.
|
||||
echo.
|
||||
echo If the player window does not appear, check:
|
||||
echo dist\KiwySignagePlayer\logs\crash.log
|
||||
echo dist\KiwySignagePlayer\logs\fatal_crash.log
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
PyInstaller Runtime Hook for Kiwy Signage Player
|
||||
------------------------------------------------
|
||||
Runs at startup of the packaged .exe to fix paths and environment.
|
||||
Creates all necessary folders LOCAL to the executable's directory.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _set_process_dpi_awareness():
|
||||
"""Declare per-monitor DPI awareness BEFORE SDL/Kivy initialize.
|
||||
|
||||
On a display scaled above 100% (e.g. 1920x1080 @ 125%), Windows
|
||||
virtualizes a non-DPI-aware app to the scaled-down size (1536x864).
|
||||
Kivy then sizes its content area to the virtualized resolution, leaving a
|
||||
black strip on one side and making images/videos render at the wrong size.
|
||||
Must run before any SDL window is created, so this lives in the runtime
|
||||
hook (the first Python code that runs in the frozen app).
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
aware = ctypes.c_int(2) # PROCESS_PER_MONITOR_DPI_AWARE_V2
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(aware)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ctypes.windll.user32.SetProcessDPIAware()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if platform.system() == 'Windows':
|
||||
import ctypes
|
||||
_set_process_dpi_awareness()
|
||||
|
||||
# ── IMPORTANT: Set Windows environment BEFORE any Kivy code runs ──
|
||||
# This must happen before main.py's top-level code executes, because
|
||||
# main.py sets SDL_VIDEODRIVER=wayland,x11,dummy which would crash on Windows.
|
||||
os.environ['SDL_VIDEODRIVER'] = 'windows'
|
||||
os.environ['SDL_AUDIODRIVER'] = 'directsound'
|
||||
os.environ['KIVY_WINDOW'] = 'sdl2'
|
||||
os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2'
|
||||
os.environ['KIVY_VIDEO'] = 'ffpyplayer'
|
||||
os.environ['KIVY_AUDIO'] = 'ffpyplayer'
|
||||
os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8'
|
||||
os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0'
|
||||
os.environ['KIVY_NO_FILELOG'] = '1'
|
||||
os.environ['KIVY_INPUTPROVIDERS'] = '' # Let Kivy auto-detect on Windows
|
||||
# Use native physical pixels (fixes black strip on DPI-scaled displays).
|
||||
os.environ.setdefault('SDL_VIDEO_HIGHDPI', '1')
|
||||
|
||||
# ── Capture ALL early output to a crash log ─────────────────────────
|
||||
# Ensure we catch any exception that happens before Logger is available.
|
||||
_startup_log_path = None
|
||||
try:
|
||||
_exe_dir = Path(sys.executable).parent
|
||||
_startup_log_path = _exe_dir / 'logs' / 'startup_crash.log'
|
||||
(_startup_log_path.parent).mkdir(parents=True, exist_ok=True)
|
||||
with open(_startup_log_path, 'w') as _f:
|
||||
_f.write("pyi_runtime_hook.py started\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _setup_paths():
|
||||
"""Ensure the app can find its bundled files at runtime.
|
||||
|
||||
All data folders (config, media, playlists, logs) are created
|
||||
LOCAL to the executable's directory — NOT in %%APPDATA%%.
|
||||
"""
|
||||
# In PyInstaller, sys.executable is the .exe path.
|
||||
# sys._MEIPASS is the extraction directory (i.e. _internal/ folder).
|
||||
exe_dir = Path(sys.executable).parent
|
||||
internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir))
|
||||
|
||||
# ── Change cwd to _internal so Builder.load_file('signage_player.kv')
|
||||
# and other relative file references from main.py resolve ─────
|
||||
os.chdir(str(internal_dir))
|
||||
|
||||
# Add bundled src directory to Python path
|
||||
src_dir = str(internal_dir / 'src')
|
||||
if os.path.isdir(src_dir) and src_dir not in sys.path:
|
||||
sys.path.insert(0, src_dir)
|
||||
|
||||
# Add internal directory for config/media/playlists access
|
||||
if str(internal_dir) not in sys.path:
|
||||
sys.path.insert(0, str(internal_dir))
|
||||
|
||||
# ── Local folders next to the .exe ──────────────────────────────
|
||||
# All data lives in the SAME folder as the executable so the user
|
||||
# can copy/move the whole directory and everything still works.
|
||||
os.environ['KIWY_DATA_DIR'] = str(exe_dir)
|
||||
|
||||
# Set KIVY_HOME to a local .kivy folder next to the .exe
|
||||
kivy_home = exe_dir / '.kivy'
|
||||
os.environ.setdefault('KIVY_HOME', str(kivy_home))
|
||||
kivy_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create local data folders next to the .exe
|
||||
for sub in ['config', 'config/resources', 'media', 'playlists', 'logs']:
|
||||
(exe_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _copy_bundled_resources():
|
||||
"""Copy bundled resource files to the local folders on first run.
|
||||
|
||||
NOTE: app_config.json is intentionally absent. It is not bundled (see
|
||||
build.spec) because shipping it would plant the build machine's server
|
||||
settings into a fresh install. The player instead starts unconfigured and
|
||||
runs its first-run setup, writing a real config next to the .exe.
|
||||
"""
|
||||
exe_dir = Path(sys.executable).parent
|
||||
internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir))
|
||||
|
||||
# Files to copy (source in bundle -> destination next to .exe)
|
||||
files_to_copy = [
|
||||
('config/resources/access-card.png', 'config/resources/access-card.png'),
|
||||
('config/resources/arrow.png', 'config/resources/arrow.png'),
|
||||
('config/resources/backward.png', 'config/resources/backward.png'),
|
||||
('config/resources/card-checked.png', 'config/resources/card-checked.png'),
|
||||
('config/resources/edit-pen.png', 'config/resources/edit-pen.png'),
|
||||
('config/resources/exit.png', 'config/resources/exit.png'),
|
||||
('config/resources/forward.png', 'config/resources/forward.png'),
|
||||
('config/resources/intro1.mp4', 'config/resources/intro1.mp4'),
|
||||
('config/resources/pause.png', 'config/resources/pause.png'),
|
||||
('config/resources/pencil.png', 'config/resources/pencil.png'),
|
||||
('config/resources/play.png', 'config/resources/play.png'),
|
||||
('config/resources/settings.png', 'config/resources/settings.png'),
|
||||
]
|
||||
|
||||
for src_rel, dest_rel in files_to_copy:
|
||||
src_path = internal_dir / src_rel
|
||||
dest_path = exe_dir / dest_rel
|
||||
if src_path.is_file() and not dest_path.exists():
|
||||
try:
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
import shutil
|
||||
shutil.copy2(str(src_path), str(dest_path))
|
||||
except Exception:
|
||||
pass # Non-critical; app can still run
|
||||
|
||||
|
||||
# ── Wrap everything in try/except to capture early crashes ──────────
|
||||
try:
|
||||
_setup_paths()
|
||||
_copy_bundled_resources()
|
||||
# If we reach here, the hook finished successfully
|
||||
try:
|
||||
with open(_startup_log_path, 'a') as _f:
|
||||
_f.write("pyi_runtime_hook.py completed successfully\n")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _hook_exc:
|
||||
import traceback as _tb
|
||||
try:
|
||||
with open(_startup_log_path, 'a') as _f:
|
||||
_f.write(f"pyi_runtime_hook.py CRASHED: {_hook_exc}\n")
|
||||
_tb.print_exc(file=_f)
|
||||
except Exception:
|
||||
pass
|
||||
raise # Re-raise so the .exe still fails visibly
|
||||
@@ -0,0 +1,51 @@
|
||||
# =====================================================================
|
||||
# Kiwy Signage Player - Windows Dependencies
|
||||
# =====================================================================
|
||||
# Install with: pip install -r requirements_win.txt
|
||||
|
||||
# --- Core GUI Framework ---
|
||||
# Kivy 2.3+ with SDL2 backend (best for Windows)
|
||||
kivy[base]>=2.3.0
|
||||
|
||||
# --- Video Playback ---
|
||||
# ffpyplayer for video decoding
|
||||
ffpyplayer>=4.5
|
||||
|
||||
# --- HTTP / Networking ---
|
||||
requests>=2.32.0,<3.0.0
|
||||
aiohttp>=3.9.0,<4.0.0
|
||||
certifi>=2024.0.0
|
||||
|
||||
# --- Password / Auth ---
|
||||
bcrypt>=4.2.0,<5.0.0
|
||||
|
||||
# --- Packaging ---
|
||||
# PyInstaller for building the .exe
|
||||
pyinstaller>=6.0
|
||||
|
||||
# --- Embedded web engine (web links) ---
|
||||
# pythonnet lets Python drive the WebView2 .NET SDK. WebView2 renders INSIDE
|
||||
# the Kivy window as a child window, which is what removed the old subprocess
|
||||
# browser bugs (window opening behind the player, instant hand-off exit,
|
||||
# z-order/focus fights, leaked chrome.exe/msedge.exe processes).
|
||||
# The WebView2 *runtime* is a free, Microsoft-shipped evergreen component and
|
||||
# is intentionally NOT bundled; the small SDK DLLs live in windows/webview2_sdk/
|
||||
# and are added to the exe by build.spec.
|
||||
# Without pythonnet the player silently falls back to the Chrome/Edge
|
||||
# subprocess engine, so weblinks still work but with the old drawbacks.
|
||||
pythonnet>=3.0.3
|
||||
|
||||
# --- Windows-specific Libraries ---
|
||||
# cefpython3: Embedded Chromium browser (replaces subprocess Chrome/Edge)
|
||||
# Installed separately because it's a large package (69 MB):
|
||||
# pip install cefpython3
|
||||
# cefpython3>=66.1
|
||||
# Note: Uncomment above line to bundle cefpython3 in the .exe.
|
||||
# Without it, weblinks fall back to subprocess Chrome/Edge.
|
||||
|
||||
# pywin32: Windows API bindings (win32gui for SetForegroundWindow etc.)
|
||||
# Already installed as a dependency of kivy[base]
|
||||
|
||||
# --- Optional: DirectShow filters for better video on Windows ---
|
||||
# ffmpeg (install via chocolatey or manual download)
|
||||
# https://ffmpeg.org/download.html
|
||||
+2306
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
<#
|
||||
================================================================================
|
||||
Kiwy Signage Player - Code Signing Script
|
||||
================================================================================
|
||||
Signs the built KiwySignagePlayer.exe with an Authenticode certificate.
|
||||
|
||||
For PRODUCTION PCs that have Smart App Control (SAC) ENABLED:
|
||||
- The cert MUST be issued by a reputable public CA (e.g. Sectigo, SSL.com,
|
||||
DigiCert, GlobalSign). Self-signed certs will NOT satisfy SAC.
|
||||
- You must use this script with -CertPath pointing at your .pfx/.p12.
|
||||
|
||||
For DEV/TEST machines where you have admin rights:
|
||||
- A self-signed cert trusted in the local Root store + Trusted Publisher
|
||||
works (see create_self_signed_cert.ps1), but it does NOT satisfy SAC.
|
||||
|
||||
Usage:
|
||||
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -CertPassword "secret"
|
||||
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" # prompt for pwd
|
||||
.\sign_exe.ps1 -CertThumbprint "A1B2..." # from cert store
|
||||
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -SkipTimestamp $false
|
||||
|
||||
Optional:
|
||||
-TimestampUrl RFC3161 timestamp server (default DigiCert).
|
||||
Timestamping is REQUIRED for the signature to stay valid
|
||||
after the cert expires and to satisfy Smart App Control.
|
||||
-ExePath Path to the exe to sign (default dist\KiwySignagePlayer\KiwySignagePlayer.exe)
|
||||
-Force Re-sign even if already signed
|
||||
================================================================================
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$CertPath,
|
||||
[string]$CertPassword,
|
||||
[string]$CertThumbprint,
|
||||
[string]$ExePath = (Join-Path $PSScriptRoot 'dist\KiwySignagePlayer\KiwySignagePlayer.exe'),
|
||||
[string]$TimestampUrl = 'http://timestamp.digicert.com',
|
||||
[switch]$SkipTimestamp,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
function Find-Signtool {
|
||||
$candidates = @(
|
||||
(Get-Command signtool.exe -ErrorAction SilentlyContinue).Source,
|
||||
"$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe",
|
||||
"$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe",
|
||||
"$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.22000.0\x64\signtool.exe",
|
||||
"$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.19041.0\x64\signtool.exe"
|
||||
)
|
||||
foreach ($c in $candidates) {
|
||||
if ($c -and (Test-Path $c)) { return $c }
|
||||
}
|
||||
# Fallback: newest SDK on disk
|
||||
$sdkBin = "$env:ProgramFiles(x86)\Windows Kits\10\bin"
|
||||
if (Test-Path $sdkBin) {
|
||||
$found = Get-ChildItem $sdkBin -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue |
|
||||
Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName
|
||||
if ($found) { return $found }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$signtool = Find-Signtool
|
||||
if ($signtool) {
|
||||
Write-Host "[INFO ] signtool: $signtool" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[WARN ] signtool.exe not found - will use PowerShell Set-AuthenticodeSignature fallback." -ForegroundColor Yellow
|
||||
Write-Host "[WARN ] NOTE: the fallback cannot apply an RFC3161 timestamp. For production (SAC),"
|
||||
Write-Host "[WARN ] install the Windows SDK signtool: winget install Microsoft.WindowsSDK.10.0.26100"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $ExePath)) {
|
||||
Write-Host "[ERROR] Exe not found: $ExePath" -ForegroundColor Red
|
||||
Write-Host "Run build_win.bat first, or pass -ExePath."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Already signed?
|
||||
$sig = Get-AuthenticodeSignature -FilePath $ExePath
|
||||
if ($sig.Status -eq 'Valid' -and -not $Force) {
|
||||
Write-Host "[INFO ] Exe is already validly signed by: $($sig.SignerCertificate.Subject)" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Load the certificate ────────────────────────────────────────────
|
||||
$cert = $null
|
||||
if ($CertThumbprint) {
|
||||
$cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Thumbprint -eq $CertThumbprint } | Select-Object -First 1
|
||||
if (-not $cert) {
|
||||
Write-Host "[ERROR] No certificate with thumbprint $CertThumbprint in My store." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif ($CertPath) {
|
||||
if (-not (Test-Path $CertPath)) {
|
||||
Write-Host "[ERROR] Cert file not found: $CertPath" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if (-not $CertPassword) {
|
||||
$secure = Read-Host "Certificate password for $CertPath" -AsSecureString
|
||||
$CertPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
||||
[System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
|
||||
}
|
||||
$securePwd = ConvertTo-SecureString -String $CertPassword -Force -AsPlainText
|
||||
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertPath, $securePwd)
|
||||
} else {
|
||||
Write-Host "[ERROR] Provide -CertPath or -CertThumbprint." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Sign (signtool preferred, PowerShell fallback) ──────────────────
|
||||
if ($signtool) {
|
||||
$args = @()
|
||||
if ($CertThumbprint) {
|
||||
$args = @('sign', '/sha1', $CertThumbprint, '/fd', 'SHA256')
|
||||
} else {
|
||||
$args = @('sign', '/f', $CertPath, '/p', $CertPassword, '/fd', 'SHA256')
|
||||
}
|
||||
if (-not $SkipTimestamp) {
|
||||
$args += @('/tr', $TimestampUrl, '/td', 'SHA256')
|
||||
}
|
||||
$args += @('"' + $ExePath + '"')
|
||||
$cmd = "& `"$signtool`" " + ($args -join ' ')
|
||||
Write-Host "[INFO ] Signing with signtool..." -ForegroundColor Cyan
|
||||
Write-Host "[CMD ] $cmd"
|
||||
Invoke-Expression $cmd
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "[ERROR] signtool failed with exit code $LASTEXITCODE" -ForegroundColor Red
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
} else {
|
||||
Write-Host "[INFO ] Signing with PowerShell Set-AuthenticodeSignature (no timestamp)..." -ForegroundColor Cyan
|
||||
if (-not $cert.HasPrivateKey) {
|
||||
Write-Host "[ERROR] Certificate has no private key - cannot sign." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$sig = Set-AuthenticodeSignature -FilePath $ExePath -Certificate $cert -HashAlgorithm SHA256
|
||||
if ($sig.Status -notin @('Valid','UnknownError')) {
|
||||
Write-Host "[ERROR] Signing failed: $($sig.StatusMessage)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Verify
|
||||
$sig = Get-AuthenticodeSignature -FilePath $ExePath
|
||||
Write-Host ""
|
||||
Write-Host "[INFO ] Signature status: $($sig.Status)" -ForegroundColor Green
|
||||
Write-Host "[INFO ] Signer: $($sig.SignerCertificate.Subject)" -ForegroundColor Green
|
||||
if ($sig.Status -eq 'Valid') {
|
||||
Write-Host "[OK ] KiwySignagePlayer.exe is now digitally signed." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[WARN ] Signature status is '$($sig.Status)' - inspect above." -ForegroundColor Yellow
|
||||
Write-Host "[WARN ] If no timestamp was applied, SAC may still block after cert expiry." -ForegroundColor Yellow
|
||||
}
|
||||
@@ -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,39 @@
|
||||
"""Test that setting env vars before importing main.py fixes the crash."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# This is the KEY fix: set Windows env vars BEFORE main.py is imported
|
||||
os.environ['SDL_VIDEODRIVER'] = 'windows'
|
||||
os.environ['SDL_AUDIODRIVER'] = 'directsound'
|
||||
os.environ['KIVY_WINDOW'] = 'sdl2'
|
||||
# Use 'angle_sdl2' on Windows for better DirectX compatibility
|
||||
os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2'
|
||||
# Let Kivy auto-detect input providers on Windows
|
||||
os.environ['KIVY_INPUTPROVIDERS'] = ''
|
||||
os.environ['KIVY_VIDEO'] = 'ffpyplayer'
|
||||
os.environ['KIVY_AUDIO'] = 'ffpyplayer'
|
||||
os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8'
|
||||
os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0'
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, r'C:\Users\Dell-PC\Desktop\Kiwy-Signage\src')
|
||||
|
||||
print("=" * 60)
|
||||
print("Testing main.py import with Windows env vars...")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
import main
|
||||
print("SUCCESS: main.py imported without crashing!")
|
||||
print(f" SDL_VIDEODRIVER = {os.environ.get('SDL_VIDEODRIVER')}")
|
||||
print(f" KIVY_WINDOW = {os.environ.get('KIVY_WINDOW')}")
|
||||
print(f" KIVY_GL_BACKEND = {os.environ.get('KIVY_GL_BACKEND')}")
|
||||
print(f" KIVY_INPUTPROVIDERS = {os.environ.get('KIVY_INPUTPROVIDERS')}")
|
||||
except SystemExit as e:
|
||||
print(f"FAILED: SystemExit({e}) - Kivy window provider still not loading")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"FAILED with exception: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -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,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rigorous verification: is the fixed SendInput code in the compiled run_win?"""
|
||||
import marshal
|
||||
import types
|
||||
import dis
|
||||
from PyInstaller.archive.readers import CArchiveReader
|
||||
|
||||
EXE = r'dist\KiwySignagePlayer\KiwySignagePlayer.exe'
|
||||
|
||||
|
||||
def collect_strings(code, acc):
|
||||
for c in code.co_consts:
|
||||
if isinstance(c, str):
|
||||
acc.append(c)
|
||||
elif isinstance(c, types.CodeType):
|
||||
collect_strings(c, acc)
|
||||
|
||||
|
||||
def collect_codes(code, acc):
|
||||
acc.append(code)
|
||||
for c in code.co_consts:
|
||||
if isinstance(c, types.CodeType):
|
||||
collect_codes(c, acc)
|
||||
|
||||
|
||||
def main():
|
||||
arc = CArchiveReader(EXE)
|
||||
data = arc.extract('run_win')
|
||||
code = marshal.loads(data)
|
||||
|
||||
all_codes = []
|
||||
collect_codes(code, all_codes)
|
||||
strings = []
|
||||
collect_strings(code, strings)
|
||||
|
||||
# 1) c_ulonglong can ONLY come from the fixed code (old used c_ulong + POINTER)
|
||||
has_c_ulonglong = any('c_ulonglong' in c.co_names for c in all_codes)
|
||||
print(f'c_ulonglong in co_names of any code object: {has_c_ulonglong}')
|
||||
|
||||
# 2) INPUTUNION should be STORE_DEREF/STORE_NAME'd inside
|
||||
# _force_foreground_sendinput (classes are defined in a closure,
|
||||
# so they're stored via STORE_DEREF).
|
||||
store_names = set()
|
||||
for c in all_codes:
|
||||
for instr in dis.get_instructions(c):
|
||||
if instr.opname in ('STORE_NAME', 'STORE_FAST', 'STORE_DEREF'):
|
||||
store_names.add(instr.argval)
|
||||
print(f'INPUTUNION stored: {"INPUTUNION" in store_names}')
|
||||
print(f'KEYBDINPUT stored: {"KEYBDINPUT" in store_names}')
|
||||
print(f'MOUSEINPUT stored: {"MOUSEINPUT" in store_names}')
|
||||
print(f'HARDWAREINPUT stored: {"HARDWAREINPUT" in store_names}')
|
||||
|
||||
# 3) The new code uses INPUT() + field assignment (type, u.ki.wVk, u.ki.dwFlags)
|
||||
names = set()
|
||||
for c in all_codes:
|
||||
names.update(c.co_names)
|
||||
print(f'Has "u" attribute usage: {"u" in names}')
|
||||
|
||||
# 4) string markers
|
||||
print(f'string "undersized buffer": {any("undersized buffer" in s for s in strings)}')
|
||||
print(f'string "real Win32 x64": {any("real Win32 x64" in s for s in strings)}')
|
||||
print(f'string "ULONG_PTR": {any("ULONG_PTR" in s for s in strings)}')
|
||||
|
||||
# 5) Console-window fix in _find_kivy_hwnd: cellvars/freevars + tuple consts
|
||||
find_kv = [c for c in all_codes if c.co_name == '_find_kivy_hwnd']
|
||||
cell_vars = set()
|
||||
for c in find_kv:
|
||||
cell_vars.update(c.co_cellvars)
|
||||
cell_vars.update(c.co_freevars)
|
||||
enum_cb = [c for c in all_codes
|
||||
if c.co_name == '_enum_cb' and 'SDL_CLASSES' in c.co_freevars]
|
||||
tuple_strs = set()
|
||||
for c in enum_cb:
|
||||
for x in c.co_consts:
|
||||
if isinstance(x, tuple):
|
||||
tuple_strs.update(str(v) for v in x)
|
||||
print(f'_find_kivy_hwnd cell/free vars (SDL_CLASSES/sdl_windows/our_pid): '
|
||||
f'{"SDL_CLASSES" in cell_vars and "sdl_windows" in cell_vars and "our_pid" in cell_vars}')
|
||||
print(f'console class excluded (ConsoleWindowClass in _enum_cb tuple): '
|
||||
f'{"ConsoleWindowClass" in tuple_strs}')
|
||||
|
||||
verdict = has_c_ulonglong and 'INPUTUNION' in store_names
|
||||
console_fix = ('SDL_CLASSES' in cell_vars and 'ConsoleWindowClass' in tuple_strs)
|
||||
print(f'\nVerdict SendInput: {"FIX PRESENT" if verdict else "FIX ABSENT - rebuild needed"}')
|
||||
print(f'Verdict console-hwnd: {"FIX PRESENT" if console_fix else "FIX ABSENT - rebuild needed"}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
# UTF-8
|
||||
#
|
||||
# Windows version resource for KiwySignagePlayer.exe
|
||||
# This file is used by PyInstaller (version=) to embed publisher/product
|
||||
# metadata into the executable so Windows Smart App Control / SmartScreen
|
||||
# can identify the app instead of flagging it as "Unknown publisher".
|
||||
#
|
||||
# Note: A code-signing certificate is still required for a fully trusted
|
||||
# publisher name; this metadata at least names the product/company and
|
||||
# supplies a version number.
|
||||
#
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(1, 2, 0, 0),
|
||||
prodvers=(1, 2, 0, 0),
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
fileType=0x1,
|
||||
subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo(
|
||||
[
|
||||
StringTable(
|
||||
'040904B0',
|
||||
[
|
||||
StringStruct('CompanyName', 'Kiwy Signage'),
|
||||
StringStruct('FileDescription', 'Kiwy Signage Player - Digital Signage Player'),
|
||||
StringStruct('FileVersion', '1.2.0.0'),
|
||||
StringStruct('InternalName', 'KiwySignagePlayer'),
|
||||
StringStruct('LegalCopyright', 'Copyright (c) 2026 Kiwy Signage'),
|
||||
StringStruct('OriginalFilename', 'KiwySignagePlayer.exe'),
|
||||
StringStruct('ProductName', 'Kiwy Signage Player'),
|
||||
StringStruct('ProductVersion', '1.2.0.0'),
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
VarFileInfo([VarStruct('Translation', [1033, 1200])])
|
||||
]
|
||||
)
|
||||
@@ -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.
@@ -0,0 +1,720 @@
|
||||
"""
|
||||
Windows Card Reader (Raw Input API + LL-hook fallback)
|
||||
=======================================================
|
||||
Drop-in replacement for the Linux `CardReader` class in `src/main.py`.
|
||||
|
||||
The Linux implementation reads keystrokes from `/dev/input/event*` via
|
||||
`evdev`. On Windows there is no `/dev/input`; instead HID devices (such as
|
||||
USB card readers that emulate a keyboard) are accessed through the Win32
|
||||
**Raw Input API** (`WM_INPUT`).
|
||||
|
||||
Design
|
||||
------
|
||||
* A dedicated hidden message-only window + message pump runs on a background
|
||||
thread. It registers for Raw Input of all *keyboard* HID devices with
|
||||
`RIDEV_INPUTSINK`, so it receives `WM_INPUT` even though it never has focus.
|
||||
* Device selection mirrors the Linux priority logic:
|
||||
1. A device whose name contains "card" / "reader" / "rfid".
|
||||
2. A USB HID keyboard that is *not* the PS/2 system keyboard.
|
||||
3. Any keyboard device (excluding obvious touchscreens/mice).
|
||||
A config override `card_reader_device` (substring of the device name, e.g.
|
||||
`VID_08FF`) takes precedence.
|
||||
* While reading, only key events from the *selected* device are accepted, so a
|
||||
physical keyboard used for maintenance cannot pollute the card data.
|
||||
* Card data ends on Enter (`VK_RETURN`), mirroring the Linux behaviour.
|
||||
* If Raw Input registration fails (or config `card_reader_mode = "hook"`), it
|
||||
falls back to a low-level keyboard hook (`WH_KEYBOARD_LL`).
|
||||
|
||||
Config keys (in `config/app_config.json`):
|
||||
"card_reader_device": "VID_08FF" # substring of device name (optional)
|
||||
"card_reader_mode": "auto" # "auto" | "raw" | "hook"
|
||||
"card_reader_timeout": 5 # seconds (optional)
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes as wintypes
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
# ── Win32 constants ──────────────────────────────────────────────────────────
|
||||
WM_INPUT = 0x00FF
|
||||
WM_INPUT_DEVICE_CHANGE = 0x00FE
|
||||
WM_KEYDOWN = 0x0100
|
||||
WM_SYSKEYDOWN = 0x0104
|
||||
|
||||
RIM_TYPEMOUSE = 0
|
||||
RIM_TYPEKEYBOARD = 1
|
||||
RIM_TYPEHID = 2
|
||||
|
||||
RID_INPUT = 0x10000003
|
||||
RIDI_DEVICENAME = 0x20000007
|
||||
|
||||
RIDEV_INPUTSINK = 0x00000100
|
||||
RIDEV_DEVNOTIFY = 0x00002000
|
||||
|
||||
WH_KEYBOARD_LL = 13
|
||||
HC_ACTION = 0
|
||||
|
||||
# Virtual keys we never treat as card data
|
||||
_VK_SHIFT = 0x10
|
||||
_VK_CONTROL = 0x11
|
||||
_VK_MENU = 0x12
|
||||
_VK_CAPITAL = 0x14
|
||||
_VK_ESCAPE = 0x1B
|
||||
_VK_RETURN = 0x0D
|
||||
_VK_TAB = 0x09
|
||||
_VK_LSHIFT = 0xA0
|
||||
_VK_RSHIFT = 0xA1
|
||||
_VK_LCONTROL = 0xA2
|
||||
_VK_RCONTROL = 0xA3
|
||||
_VK_LMENU = 0xA4
|
||||
_VK_RMENU = 0xA5
|
||||
|
||||
# ── ctypes structures ───────────────────────────────────────────────────────
|
||||
# ctypes.wintypes does not export LRESULT; it is a signed pointer-sized value.
|
||||
# Use ctypes.c_long (standard ctypes workaround; fine for WNDPROC/LL-hook).
|
||||
_LRESULT = ctypes.c_long
|
||||
|
||||
_WND_PROC = ctypes.WINFUNCTYPE(
|
||||
_LRESULT, wintypes.HWND, wintypes.UINT,
|
||||
wintypes.WPARAM, wintypes.LPARAM,
|
||||
)
|
||||
_LL_KEYBOARD_PROC = ctypes.WINFUNCTYPE(
|
||||
_LRESULT, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM,
|
||||
)
|
||||
|
||||
|
||||
class _WNDCLASSW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('style', wintypes.UINT),
|
||||
('lpfnWndProc', _WND_PROC),
|
||||
('cbClsExtra', ctypes.c_int),
|
||||
('cbWndExtra', ctypes.c_int),
|
||||
('hInstance', wintypes.HINSTANCE),
|
||||
('hIcon', wintypes.HICON),
|
||||
('hCursor', ctypes.c_void_p), # HCURSOR (not exported by wintypes)
|
||||
('hbrBackground', wintypes.HBRUSH),
|
||||
('lpszMenuName', wintypes.LPCWSTR),
|
||||
('lpszClassName', wintypes.LPCWSTR),
|
||||
]
|
||||
|
||||
|
||||
class _MSG(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('hwnd', wintypes.HWND),
|
||||
('message', wintypes.UINT),
|
||||
('wParam', wintypes.WPARAM),
|
||||
('lParam', wintypes.LPARAM),
|
||||
('time', wintypes.DWORD),
|
||||
('pt', wintypes.POINT),
|
||||
]
|
||||
|
||||
|
||||
class _RAWINPUTDEVICE(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('usUsagePage', wintypes.USHORT),
|
||||
('usUsage', wintypes.USHORT),
|
||||
('dwFlags', wintypes.DWORD),
|
||||
('hwndTarget', wintypes.HWND),
|
||||
]
|
||||
|
||||
|
||||
class _RAWINPUTDEVICELIST(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('hDevice', wintypes.HANDLE),
|
||||
('dwType', wintypes.DWORD),
|
||||
]
|
||||
|
||||
|
||||
class _RAWINPUTHEADER(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('dwType', wintypes.DWORD),
|
||||
('dwSize', wintypes.DWORD),
|
||||
('hDevice', wintypes.HANDLE),
|
||||
('wParam', wintypes.WPARAM),
|
||||
]
|
||||
|
||||
|
||||
class _RAWKEYBOARD(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('MakeCode', wintypes.USHORT),
|
||||
('Flags', wintypes.USHORT),
|
||||
('Reserved', wintypes.USHORT),
|
||||
('VKey', wintypes.USHORT),
|
||||
('Message', wintypes.UINT),
|
||||
('ExtraInformation', wintypes.ULONG),
|
||||
]
|
||||
|
||||
|
||||
class _RAWINPUT_UNION(ctypes.Union):
|
||||
_fields_ = [('keyboard', _RAWKEYBOARD)]
|
||||
|
||||
|
||||
class _RAWINPUT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('header', _RAWINPUTHEADER),
|
||||
('u', _RAWINPUT_UNION),
|
||||
]
|
||||
|
||||
|
||||
class _KBDLLHOOKSTRUCT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('vkCode', wintypes.DWORD),
|
||||
('scanCode', wintypes.DWORD),
|
||||
('flags', wintypes.DWORD),
|
||||
('time', wintypes.DWORD),
|
||||
('dwExtraInfo', wintypes.WPARAM),
|
||||
]
|
||||
|
||||
|
||||
# ── Win32 function bindings with explicit signatures ────────────────────────
|
||||
_user32 = ctypes.windll.user32
|
||||
_kernel32 = ctypes.windll.kernel32
|
||||
|
||||
_user32.RegisterClassW.argtypes = [ctypes.POINTER(_WNDCLASSW)]
|
||||
_user32.RegisterClassW.restype = wintypes.ATOM
|
||||
_user32.CreateWindowExW.argtypes = [
|
||||
wintypes.DWORD, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD,
|
||||
ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
|
||||
wintypes.HWND, wintypes.HMENU, wintypes.HINSTANCE, wintypes.LPVOID,
|
||||
]
|
||||
_user32.CreateWindowExW.restype = wintypes.HWND
|
||||
_user32.DestroyWindow.argtypes = [wintypes.HWND]
|
||||
_user32.UnregisterClassW.argtypes = [wintypes.LPCWSTR, wintypes.HINSTANCE]
|
||||
_user32.GetMessageW.argtypes = [
|
||||
ctypes.POINTER(_MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT,
|
||||
]
|
||||
_user32.GetMessageW.restype = wintypes.BOOL
|
||||
_user32.TranslateMessage.argtypes = [ctypes.POINTER(_MSG)]
|
||||
_user32.DispatchMessageW.argtypes = [ctypes.POINTER(_MSG)]
|
||||
_user32.DefWindowProcW.argtypes = [
|
||||
wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM,
|
||||
]
|
||||
_user32.DefWindowProcW.restype = _LRESULT
|
||||
_user32.PostMessageW.argtypes = [
|
||||
wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM,
|
||||
]
|
||||
_user32.PostMessageW.restype = wintypes.BOOL
|
||||
|
||||
_user32.RegisterRawInputDevices.argtypes = [
|
||||
ctypes.POINTER(_RAWINPUTDEVICE), wintypes.UINT, wintypes.UINT,
|
||||
]
|
||||
_user32.RegisterRawInputDevices.restype = wintypes.BOOL
|
||||
_user32.GetRawInputDeviceList.argtypes = [
|
||||
ctypes.POINTER(_RAWINPUTDEVICELIST), ctypes.POINTER(wintypes.UINT),
|
||||
wintypes.UINT,
|
||||
]
|
||||
_user32.GetRawInputDeviceList.restype = wintypes.UINT
|
||||
_user32.GetRawInputDeviceInfoW.argtypes = [
|
||||
wintypes.HANDLE, wintypes.UINT, wintypes.LPVOID,
|
||||
ctypes.POINTER(wintypes.UINT),
|
||||
]
|
||||
_user32.GetRawInputDeviceInfoW.restype = wintypes.UINT
|
||||
_user32.GetRawInputData.argtypes = [
|
||||
wintypes.HANDLE, wintypes.UINT, wintypes.LPVOID,
|
||||
ctypes.POINTER(wintypes.UINT), wintypes.UINT,
|
||||
]
|
||||
_user32.GetRawInputData.restype = wintypes.UINT
|
||||
_user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT]
|
||||
_user32.MapVirtualKeyW.restype = wintypes.UINT
|
||||
|
||||
_user32.SetWindowsHookExW.argtypes = [
|
||||
ctypes.c_int, _LL_KEYBOARD_PROC, wintypes.HINSTANCE, wintypes.DWORD,
|
||||
]
|
||||
_user32.SetWindowsHookExW.restype = wintypes.HHOOK
|
||||
_user32.UnhookWindowsHookEx.argtypes = [wintypes.HHOOK]
|
||||
_user32.UnhookWindowsHookEx.restype = wintypes.BOOL
|
||||
_user32.CallNextHookEx.argtypes = [
|
||||
wintypes.HHOOK, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM,
|
||||
]
|
||||
_user32.CallNextHookEx.restype = _LRESULT
|
||||
|
||||
_kernel32.GetModuleHandleW.argtypes = [wintypes.LPCWSTR]
|
||||
_kernel32.GetModuleHandleW.restype = wintypes.HINSTANCE
|
||||
|
||||
_WND_CLASS_NAME = 'KiwyWinCardReaderWindow'
|
||||
|
||||
# The single active reader instance (the Raw Input / LL-hook callbacks are
|
||||
# invoked from a background thread; a module-level ref avoids GC of the proc).
|
||||
_ACTIVE_READER = None
|
||||
|
||||
|
||||
# ── Small helpers ───────────────────────────────────────────────────────────
|
||||
def _log(msg):
|
||||
"""Log via Kivy Logger when available, else print."""
|
||||
try:
|
||||
from kivy.logger import Logger
|
||||
Logger.info(f"CardReaderWin: {msg}")
|
||||
except Exception:
|
||||
try:
|
||||
print(f"[CardReaderWin] {msg}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_config():
|
||||
"""Read app_config.json (next to exe, or project config in dev mode)."""
|
||||
try:
|
||||
data_dir = os.environ.get('KIWY_DATA_DIR', '')
|
||||
candidates = []
|
||||
if data_dir:
|
||||
candidates.append(os.path.join(data_dir, 'config', 'app_config.json'))
|
||||
# Dev fallback: <project>/config/app_config.json
|
||||
here = os.path.dirname(os.path.abspath(__file__)) # windows/
|
||||
candidates.append(os.path.join(os.path.dirname(here), 'config', 'app_config.json'))
|
||||
for path in candidates:
|
||||
if path and os.path.exists(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f) or {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _vk_to_char(vk):
|
||||
"""Convert a virtual-key code to its character, or None if not data."""
|
||||
# Numeric keypad 0-9
|
||||
if 0x60 <= vk <= 0x69:
|
||||
return chr(vk - 0x60 + 0x30)
|
||||
# Main-row digits
|
||||
if 0x30 <= vk <= 0x39:
|
||||
return chr(vk)
|
||||
# Letters (uppercase) — card readers typically emit these
|
||||
if 0x41 <= vk <= 0x5A:
|
||||
return chr(vk)
|
||||
if vk == 0x20: # space
|
||||
return ' '
|
||||
# Anything else (symbols) via MapVirtualKey
|
||||
try:
|
||||
res = _user32.MapVirtualKeyW(vk, 2) # MAPVK_VK_TO_CHAR
|
||||
if res & 0x80000000:
|
||||
return None # dead key
|
||||
c = res & 0xFFFF
|
||||
if 0x20 <= c <= 0x7E:
|
||||
return chr(c)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_device_name(hdev):
|
||||
"""Return the Win32 device name (e.g. \\\\?\\HID#VID_08FF&PID_0009#...)."""
|
||||
try:
|
||||
size = wintypes.UINT(0)
|
||||
if _user32.GetRawInputDeviceInfoW(hdev, RIDI_DEVICENAME, None, ctypes.byref(size)) == 0xFFFFFFFF:
|
||||
return ''
|
||||
if not size.value:
|
||||
return ''
|
||||
buf = ctypes.create_unicode_buffer(int(size.value) + 2)
|
||||
if _user32.GetRawInputDeviceInfoW(hdev, RIDI_DEVICENAME, buf, ctypes.byref(size)) == 0xFFFFFFFF:
|
||||
return ''
|
||||
return buf.value or ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _enumerate_keyboards():
|
||||
"""Return [{handle, name}] for all Raw-Input keyboard-type devices."""
|
||||
result = []
|
||||
try:
|
||||
count = wintypes.UINT(0)
|
||||
_user32.GetRawInputDeviceList(None, ctypes.byref(count), ctypes.sizeof(_RAWINPUTDEVICELIST))
|
||||
if not count.value:
|
||||
return result
|
||||
buf = (_RAWINPUTDEVICELIST * count.value)()
|
||||
n = _user32.GetRawInputDeviceList(buf, ctypes.byref(count), ctypes.sizeof(_RAWINPUTDEVICELIST))
|
||||
for i in range(int(n)):
|
||||
dev = buf[i]
|
||||
if dev.dwType != RIM_TYPEKEYBOARD:
|
||||
continue
|
||||
result.append({'handle': dev.hDevice, 'name': _get_device_name(dev.hDevice)})
|
||||
except Exception as e:
|
||||
_log(f"enumerate_keyboards error: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def _choose_card_reader(override=''):
|
||||
"""Pick the most likely card-reader device, mirroring the Linux logic."""
|
||||
keyboards = _enumerate_keyboards()
|
||||
if not keyboards:
|
||||
_log("No keyboard-type Raw Input devices found")
|
||||
return None
|
||||
|
||||
for dev in keyboards:
|
||||
_log(f" candidate: {dev['name']}")
|
||||
|
||||
# Config override (substring of device name, e.g. VID_08FF)
|
||||
if override:
|
||||
for dev in keyboards:
|
||||
if override.lower() in dev['name'].lower():
|
||||
_log(f"Using config override -> {dev['name']}")
|
||||
return dev
|
||||
_log(f"No device matched config override '{override}'; continuing auto-detect")
|
||||
|
||||
exclusion = ('touch', 'mouse', 'trackpad', 'pen', 'stylus', 'monitor', 'video')
|
||||
|
||||
# Priority 1: explicit card / reader / rfid
|
||||
for dev in keyboards:
|
||||
name = dev['name'].lower()
|
||||
if 'card' in name or 'reader' in name or 'rfid' in name:
|
||||
_log(f"Priority 1 (explicit reader) -> {dev['name']}")
|
||||
return dev
|
||||
|
||||
# Priority 2: USB HID keyboard that is NOT the PS/2 system keyboard
|
||||
for dev in keyboards:
|
||||
name = dev['name'].lower()
|
||||
if 'hid' in name and 'vid' in name:
|
||||
if any(p in name for p in ('pnp0303', 'pnp0c0e', 'pnp0320', 'acpi')):
|
||||
continue
|
||||
if any(k in name for k in exclusion):
|
||||
continue
|
||||
_log(f"Priority 2 (USB HID keyboard) -> {dev['name']}")
|
||||
return dev
|
||||
|
||||
# Priority 3: any keyboard that isn't obviously a touchscreen/mouse
|
||||
for dev in keyboards:
|
||||
name = dev['name'].lower()
|
||||
if any(k in name for k in exclusion):
|
||||
continue
|
||||
_log(f"Priority 3 (any keyboard) -> {dev['name']}")
|
||||
return dev
|
||||
|
||||
_log(f"Fallback: using first keyboard device -> {keyboards[0]['name']}")
|
||||
return keyboards[0]
|
||||
|
||||
|
||||
# ── WndProc / LL-hook callbacks (called on the pump thread) ─────────────────
|
||||
def _wnd_proc(hwnd, msg, wparam, lparam):
|
||||
"""Handle WM_INPUT / WM_INPUT_DEVICE_CHANGE for the hidden window."""
|
||||
try:
|
||||
reader = _ACTIVE_READER
|
||||
if reader is not None:
|
||||
if msg == WM_INPUT:
|
||||
reader._on_raw_input(lparam)
|
||||
elif msg == WM_INPUT_DEVICE_CHANGE:
|
||||
reader._on_device_change()
|
||||
return _user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
||||
except Exception:
|
||||
try:
|
||||
return _user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _ll_hook_proc(nCode, wParam, lParam):
|
||||
"""Low-level keyboard hook used as a fallback capture path."""
|
||||
try:
|
||||
if nCode == HC_ACTION:
|
||||
reader = _ACTIVE_READER
|
||||
if reader is not None and wParam in (WM_KEYDOWN, WM_SYSKEYDOWN):
|
||||
kb = _KBDLLHOOKSTRUCT.from_address(lParam)
|
||||
reader._on_key_event(kb.vkCode)
|
||||
return _user32.CallNextHookEx(None, nCode, wParam, lParam)
|
||||
except Exception:
|
||||
try:
|
||||
return _user32.CallNextHookEx(None, nCode, wParam, lParam)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
|
||||
# ── Background message-pump thread ──────────────────────────────────────────
|
||||
class _RawInputThread(threading.Thread):
|
||||
def __init__(self, owner):
|
||||
super().__init__(daemon=True)
|
||||
self._owner = owner
|
||||
self._hwnd = None
|
||||
self._hook = None
|
||||
self.ready = threading.Event()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
hinst = _kernel32.GetModuleHandleW(None)
|
||||
wndclass = _WNDCLASSW()
|
||||
wndclass.lpfnWndProc = _WND_PROC(_wnd_proc)
|
||||
wndclass.hInstance = hinst
|
||||
wndclass.lpszClassName = _WND_CLASS_NAME
|
||||
if not _user32.RegisterClassW(ctypes.byref(wndclass)):
|
||||
raise ctypes.WinError(ctypes.get_last_error(), "RegisterClassW failed")
|
||||
hwnd = _user32.CreateWindowExW(
|
||||
0, _WND_CLASS_NAME, 'KiwyWinCardReader', 0,
|
||||
0, 0, 0, 0, None, None, hinst, None,
|
||||
)
|
||||
if not hwnd:
|
||||
raise ctypes.WinError(ctypes.get_last_error(), "CreateWindowExW failed")
|
||||
self._hwnd = hwnd
|
||||
self._owner._hwnd = hwnd
|
||||
|
||||
# Register Raw Input for ALL keyboards (sink = receive w/o focus)
|
||||
rids = (_RAWINPUTDEVICE * 1)()
|
||||
rids[0].usUsagePage = 0x01
|
||||
rids[0].usUsage = 0x06 # Generic Desktop / Keyboard
|
||||
rids[0].dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY
|
||||
rids[0].hwndTarget = hwnd
|
||||
raw_ok = bool(_user32.RegisterRawInputDevices(
|
||||
rids, 1, ctypes.sizeof(_RAWINPUTDEVICE)))
|
||||
self._owner._raw_ok = raw_ok
|
||||
if raw_ok:
|
||||
_log("Raw Input registered for keyboard devices")
|
||||
|
||||
# LL-hook fallback: use it when config says so, or if raw failed
|
||||
mode = getattr(self._owner, '_mode', 'auto')
|
||||
if mode == 'hook' or not raw_ok:
|
||||
proc = _LL_KEYBOARD_PROC(_ll_hook_proc)
|
||||
hook = _user32.SetWindowsHookExW(WH_KEYBOARD_LL, proc, hinst, 0)
|
||||
if hook:
|
||||
self._hook = hook
|
||||
self._hook_proc = proc # keep reference alive
|
||||
_log("Low-level keyboard hook ACTIVE (fallback capture)")
|
||||
|
||||
self.ready.set()
|
||||
|
||||
msg = _MSG()
|
||||
while _user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
|
||||
_user32.TranslateMessage(ctypes.byref(msg))
|
||||
_user32.DispatchMessageW(ctypes.byref(msg))
|
||||
|
||||
if self._hook:
|
||||
try:
|
||||
_user32.UnhookWindowsHookEx(self._hook)
|
||||
except Exception:
|
||||
pass
|
||||
_user32.DestroyWindow(hwnd)
|
||||
_user32.UnregisterClassW(_WND_CLASS_NAME, hinst)
|
||||
except Exception as e:
|
||||
_log(f"Message pump thread error: {e}")
|
||||
self.ready.set()
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
if self._hwnd:
|
||||
_user32.PostMessageW(self._hwnd, 0x0012, 0, 0) # WM_QUIT
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Public drop-in replacement for the Linux CardReader ─────────────────────
|
||||
class WindowsCardReader:
|
||||
"""Windows-native card reader with the same interface as main.py's CardReader.
|
||||
|
||||
Interface used by SignagePlayer:
|
||||
read_card_async(callback) -> starts listening; callback(card_data) on
|
||||
Enter, or callback(None) on timeout/cancel
|
||||
stop_reading() -> stops listening
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._device = None
|
||||
self._device_name = ''
|
||||
self._reading = False
|
||||
self._finished = False
|
||||
self._callback = None
|
||||
self._card_buffer = []
|
||||
self._last_activity = 0.0
|
||||
self._timeout = 5.0
|
||||
self._mode = 'auto'
|
||||
self._hwnd = None
|
||||
self._raw_ok = False
|
||||
self._thread = None
|
||||
self._lock = threading.Lock()
|
||||
self._last_device_change = 0.0 # debounce WM_INPUT_DEVICE_CHANGE
|
||||
|
||||
# -- config -------------------------------------------------------
|
||||
def _load_settings(self):
|
||||
cfg = _get_config()
|
||||
self._mode = str(cfg.get('card_reader_mode', 'auto')).lower().strip() or 'auto'
|
||||
try:
|
||||
self._timeout = float(cfg.get('card_reader_timeout', 5))
|
||||
except Exception:
|
||||
self._timeout = 5.0
|
||||
if self._timeout <= 0:
|
||||
self._timeout = 5.0
|
||||
return str(cfg.get('card_reader_device', '') or '').strip()
|
||||
|
||||
# -- public API ---------------------------------------------------
|
||||
def read_card_async(self, callback):
|
||||
"""Start reading; callback(card_data) on Enter, callback(None) on timeout."""
|
||||
if self._reading:
|
||||
_log("read_card_async called while already reading — ignoring")
|
||||
return
|
||||
override = self._load_settings()
|
||||
global _ACTIVE_READER
|
||||
_ACTIVE_READER = self
|
||||
|
||||
self._callback = callback
|
||||
self._reading = True
|
||||
self._finished = False
|
||||
self._card_buffer = []
|
||||
self._last_activity = time.time()
|
||||
|
||||
# Choose the target device (raw mode only). In 'hook' mode we capture
|
||||
# from all keyboards via the LL-hook instead.
|
||||
if self._mode != 'hook':
|
||||
self._device = None
|
||||
self._device_name = ''
|
||||
chosen = _choose_card_reader(override=override)
|
||||
if chosen:
|
||||
self._device = chosen['handle']
|
||||
self._device_name = chosen['name']
|
||||
_log(f"Selected card reader: {chosen['name']}")
|
||||
else:
|
||||
_log("No dedicated device found — will accept any keyboard input")
|
||||
else:
|
||||
self._device = None
|
||||
self._device_name = ''
|
||||
|
||||
# Ensure the message pump is running
|
||||
if self._thread is None or not self._thread.is_alive():
|
||||
self._thread = _RawInputThread(self)
|
||||
self._thread.start()
|
||||
self._thread.ready.wait(timeout=3.0)
|
||||
|
||||
# Watchdog for the 5-second timeout
|
||||
threading.Thread(target=self._timeout_watchdog, daemon=True).start()
|
||||
_log(f"Waiting for card swipe (mode={self._mode}, timeout={self._timeout}s)")
|
||||
|
||||
def stop_reading(self):
|
||||
"""Stop listening (also stops the timeout watchdog)."""
|
||||
self._reading = False
|
||||
|
||||
def shutdown(self):
|
||||
"""Stop the background pump thread (call on app exit)."""
|
||||
self.stop_reading()
|
||||
global _ACTIVE_READER
|
||||
if _ACTIVE_READER is self:
|
||||
_ACTIVE_READER = None
|
||||
if self._thread is not None:
|
||||
self._thread.stop()
|
||||
|
||||
# -- internals ----------------------------------------------------
|
||||
def _timeout_watchdog(self):
|
||||
while self._reading and not self._finished:
|
||||
if time.time() - self._last_activity > self._timeout:
|
||||
_log("Read timeout — sending None")
|
||||
self._finish(None)
|
||||
return
|
||||
time.sleep(0.25)
|
||||
|
||||
def _on_raw_input(self, lparam):
|
||||
"""Process a WM_INPUT message (called on the pump thread)."""
|
||||
try:
|
||||
size = wintypes.UINT(0)
|
||||
_user32.GetRawInputData(lparam, RID_INPUT, None, ctypes.byref(size),
|
||||
ctypes.sizeof(_RAWINPUTHEADER))
|
||||
if not size.value:
|
||||
return
|
||||
buf = ctypes.create_string_buffer(int(size.value))
|
||||
got = _user32.GetRawInputData(lparam, RID_INPUT, buf, ctypes.byref(size),
|
||||
ctypes.sizeof(_RAWINPUTHEADER))
|
||||
if got == 0xFFFFFFFF or got == 0:
|
||||
return
|
||||
raw = ctypes.cast(buf, ctypes.POINTER(_RAWINPUT)).contents
|
||||
if raw.header.dwType != RIM_TYPEKEYBOARD:
|
||||
return
|
||||
# Only accept input from the selected card-reader device.
|
||||
if self._device is not None and raw.header.hDevice != self._device:
|
||||
return
|
||||
kb = raw.u.keyboard
|
||||
if kb.Message in (WM_KEYDOWN, WM_SYSKEYDOWN):
|
||||
self._on_key_event(kb.VKey)
|
||||
except Exception as e:
|
||||
_log(f"_on_raw_input error: {e}")
|
||||
|
||||
def _on_device_change(self):
|
||||
"""A HID device was added/removed — re-select only if the actual
|
||||
choice changes, and never more than once per second (the initial
|
||||
registration/enumeration triggers a spurious change event)."""
|
||||
try:
|
||||
if not self._reading or self._mode == 'hook':
|
||||
return
|
||||
now = time.time()
|
||||
if now - self._last_device_change < 1.0:
|
||||
return # debounce
|
||||
self._last_device_change = now
|
||||
override = str(_get_config().get('card_reader_device', '') or '').strip()
|
||||
chosen = _choose_card_reader(override=override)
|
||||
if chosen is None:
|
||||
return
|
||||
# Only re-select if the winning device actually changed.
|
||||
if self._device is not None and chosen['handle'] == self._device:
|
||||
return
|
||||
self._device = chosen['handle']
|
||||
self._device_name = chosen['name']
|
||||
_log(f"Device change — re-selected: {chosen['name']}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_key_event(self, vk):
|
||||
"""Handle a single key event (from Raw Input or LL-hook)."""
|
||||
if not self._reading or self._finished:
|
||||
return
|
||||
if vk == _VK_RETURN:
|
||||
data = ''.join(self._card_buffer).strip()
|
||||
self._card_buffer = []
|
||||
if data:
|
||||
_log(f"Card read complete: '{data}' (len={len(data)})")
|
||||
self._finish(data)
|
||||
else:
|
||||
# Accidental Enter with no data — keep waiting
|
||||
self._last_activity = time.time()
|
||||
return
|
||||
if vk in (_VK_SHIFT, _VK_LSHIFT, _VK_RSHIFT,
|
||||
_VK_CONTROL, _VK_LCONTROL, _VK_RCONTROL,
|
||||
_VK_MENU, _VK_LMENU, _VK_RMENU,
|
||||
_VK_CAPITAL, _VK_TAB, _VK_ESCAPE):
|
||||
return # modifiers / control keys are not card data
|
||||
ch = _vk_to_char(vk)
|
||||
if ch:
|
||||
self._card_buffer.append(ch)
|
||||
self._last_activity = time.time()
|
||||
|
||||
def _finish(self, data):
|
||||
"""Deliver the result exactly once (thread-safe), on the Kivy thread."""
|
||||
with self._lock:
|
||||
if self._finished:
|
||||
return
|
||||
self._finished = True
|
||||
self._reading = False
|
||||
cb = self._callback
|
||||
self._callback = None
|
||||
if cb is None:
|
||||
return
|
||||
# Prefer scheduling on the Kivy thread when a Kivy app is running
|
||||
# (the callback touches Kivy widgets). If Kivy isn't running
|
||||
# (manual test / non-GUI context), invoke the callback directly.
|
||||
kivy_running = False
|
||||
try:
|
||||
from kivy.app import App
|
||||
kivy_running = App.get_running_app() is not None
|
||||
except Exception:
|
||||
kivy_running = False
|
||||
if kivy_running:
|
||||
try:
|
||||
from kivy.clock import Clock
|
||||
Clock.schedule_once(lambda dt, d=data: cb(d), 0)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cb(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Simple manual test (no Kivy): run for a few seconds and print any card.
|
||||
print("Windows Card Reader - manual test (swipe a card within 10s)")
|
||||
|
||||
def _cb(data):
|
||||
print(f"CALLBACK: {data!r}")
|
||||
|
||||
reader = WindowsCardReader()
|
||||
reader.read_card_async(_cb)
|
||||
try:
|
||||
time.sleep(10)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
reader.stop_reading()
|
||||
reader.shutdown()
|
||||
print("Test done.")
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Execute the exact server playlist retrieval flow the player performs:
|
||||
1. Load config/app_config.json (player code: screen_name + quickconnect_key)
|
||||
2. Authenticate with the server (POST /api/auth/player)
|
||||
3. Fetch playlist using the player_id + auth_code (GET /api/playlists/{player_id})
|
||||
4. Print the RAW JSON exactly as received (before any local processing)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import traceback
|
||||
|
||||
# Resolve the real src directory (script lives in working_files/, source is in src/)
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC_DIR = os.path.join(os.path.dirname(HERE), 'src')
|
||||
sys.path.insert(0, SRC_DIR)
|
||||
|
||||
from get_playlists_v2 import get_auth_instance # noqa: E402
|
||||
from player_auth import PlayerAuth # noqa: E402
|
||||
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(HERE), 'config', 'app_config.json')
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print("EXECUTE SERVER PLAYLIST RETRIEVAL (player flow)")
|
||||
print("=" * 80)
|
||||
|
||||
# 1. Load the player config
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
print(f"\n[1] Player config loaded from: {CONFIG_PATH}")
|
||||
print(f" server_ip : {config.get('server_ip')}")
|
||||
print(f" port : {config.get('port')}")
|
||||
print(f" screen_name : {config.get('screen_name')} <- player code")
|
||||
print(f" quickconnect_key: {config.get('quickconnect_key')} <- player quick-connect code")
|
||||
print(f" use_https : {config.get('use_https')}")
|
||||
print(f" verify_ssl : {config.get('verify_ssl')}")
|
||||
|
||||
# 2. Build server URL the same way ensure_authenticated() does
|
||||
import re
|
||||
server_ip = config.get("server_ip", "")
|
||||
port = config.get("port", "")
|
||||
use_https = config.get("use_https", True)
|
||||
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
|
||||
if re.match(ip_pattern, server_ip):
|
||||
if use_https:
|
||||
server_url = f'https://{server_ip}:{port}' if port else f'https://{server_ip}'
|
||||
else:
|
||||
server_url = f'http://{server_ip}:{port}' if port else f'http://{server_ip}'
|
||||
else:
|
||||
server_url = f'https://{server_ip}' if use_https else f'http://{server_ip}'
|
||||
print(f"\n[2] Server URL used: {server_url}")
|
||||
|
||||
# 3. Authenticate with the player code
|
||||
auth = get_auth_instance(
|
||||
config_file=os.path.join(SRC_DIR, 'player_auth.json'),
|
||||
use_https=config.get('use_https', True),
|
||||
verify_ssl=config.get('verify_ssl', True)
|
||||
)
|
||||
|
||||
print(f"\n[3] Authenticating player '{config.get('screen_name')}' with quickconnect code...")
|
||||
success, error = auth.authenticate(
|
||||
server_url=server_url,
|
||||
hostname=config.get('screen_name', ''),
|
||||
quickconnect_code=config.get('quickconnect_key', '')
|
||||
)
|
||||
if not success:
|
||||
print(f" AUTH FAILED: {error}")
|
||||
print(" Cannot retrieve playlist without a valid player code/auth.")
|
||||
return
|
||||
print(f" Authenticated as: {auth.get_player_name()} (player_id={auth.get_player_id()}, "
|
||||
f"playlist_id={auth.auth_data.get('playlist_id')})")
|
||||
|
||||
# 4. Fetch the playlist (GET /api/playlists/{player_id})
|
||||
print(f"\n[4] Fetching playlist from: {server_url}/api/playlists/{auth.get_player_id()}")
|
||||
playlist_data = auth.get_playlist()
|
||||
|
||||
if playlist_data is None:
|
||||
print(" PLAYLIST FETCH FAILED")
|
||||
return
|
||||
|
||||
# 5. Print the RAW JSON exactly as received from the server
|
||||
print(f"\n[5] RAW JSON received from server (status 200):\n")
|
||||
print(json.dumps(playlist_data, indent=2, ensure_ascii=False))
|
||||
|
||||
# 6. Save the raw response for inspection
|
||||
out_path = os.path.join(HERE, 'raw_server_playlist.json')
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(playlist_data, f, indent=2, ensure_ascii=False)
|
||||
print(f"\n[6] Raw server response saved to: {out_path}")
|
||||
|
||||
# 7. Summary
|
||||
print("\n" + "=" * 80)
|
||||
print(f"SUMMARY: playlist_version={playlist_data.get('playlist_version')}, "
|
||||
f"count={playlist_data.get('count')}, items={len(playlist_data.get('playlist', []))}")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"count": 6,
|
||||
"player_id": 2,
|
||||
"player_name": "Windows-Player1",
|
||||
"playlist": [
|
||||
{
|
||||
"audio": "off",
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"edit_on_player": false,
|
||||
"file_name": "anders-jilden-cYrMQA7a3Wc-unsplash.jpg",
|
||||
"id": 9,
|
||||
"muted": true,
|
||||
"position": 1,
|
||||
"type": "image",
|
||||
"url": "http://192.168.0.107:8080/static/uploads/anders-jilden-cYrMQA7a3Wc-unsplash.jpg"
|
||||
},
|
||||
{
|
||||
"audio": "off",
|
||||
"description": null,
|
||||
"duration": 14,
|
||||
"edit_on_player": true,
|
||||
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
||||
"id": 2,
|
||||
"muted": true,
|
||||
"position": 2,
|
||||
"type": "image",
|
||||
"url": "http://192.168.0.107:8080/static/uploads/sean-oulashin-KMn4VEeEPR8-unsplash.jpg"
|
||||
},
|
||||
{
|
||||
"audio": "on",
|
||||
"description": null,
|
||||
"duration": 31,
|
||||
"edit_on_player": false,
|
||||
"file_name": "sample-30s.mp4",
|
||||
"id": 4,
|
||||
"muted": false,
|
||||
"position": 3,
|
||||
"type": "video",
|
||||
"url": "http://192.168.0.107:8080/static/uploads/sample-30s.mp4"
|
||||
},
|
||||
{
|
||||
"audio": "off",
|
||||
"description": null,
|
||||
"duration": 50,
|
||||
"edit_on_player": true,
|
||||
"file_name": "edited_media/5/eye_e_v2.jpg",
|
||||
"id": 5,
|
||||
"muted": true,
|
||||
"position": 4,
|
||||
"type": "image",
|
||||
"url": "http://192.168.0.107:8080/static/uploads/edited_media/5/eye_e_v2.jpg"
|
||||
},
|
||||
{
|
||||
"audio": "off",
|
||||
"description": "https://moto-adv.com/",
|
||||
"duration": 30,
|
||||
"edit_on_player": false,
|
||||
"file_name": "weblink-ecc2705c34e5",
|
||||
"id": 7,
|
||||
"muted": true,
|
||||
"position": 5,
|
||||
"type": "weblink",
|
||||
"url": "https://moto-adv.com/"
|
||||
},
|
||||
{
|
||||
"audio": "off",
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"edit_on_player": false,
|
||||
"file_name": "jack-anstey-XVoyX7l9ocY-unsplash.jpg",
|
||||
"id": 8,
|
||||
"muted": true,
|
||||
"position": 6,
|
||||
"type": "image",
|
||||
"url": "http://192.168.0.107:8080/static/uploads/jack-anstey-XVoyX7l9ocY-unsplash.jpg"
|
||||
}
|
||||
],
|
||||
"playlist_id": 1,
|
||||
"playlist_version": 32
|
||||
}
|
||||
Reference in New Issue
Block a user