Compare commits
25 Commits
main
..
f437aba1fc
| 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 |
@@ -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/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
env/
|
env/
|
||||||
|
windows/venv312/
|
||||||
|
|
||||||
# Kivy
|
# Kivy
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -59,3 +60,42 @@ playlists/server_playlist_*.json
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
.player_heartbear
|
.player_heartbear
|
||||||
|
|
||||||
|
# NOTE: the line above is a long-standing typo for `.player_heartbeat` and is
|
||||||
|
# kept only so nothing changes for existing clones. The correct patterns are
|
||||||
|
# below — without them the player's runtime state files get committed (they
|
||||||
|
# change every 10 seconds, which would create endless noise and merge conflicts).
|
||||||
|
.player_heartbeat
|
||||||
|
.player_stop_requested
|
||||||
|
logs/startup_marker.txt
|
||||||
|
logs/startup_crash.log
|
||||||
|
logs/console_out.txt
|
||||||
|
logs/console_err.txt
|
||||||
|
logs/watchdog_test_*.txt
|
||||||
|
logs/.webview2_install_attempt
|
||||||
|
|
||||||
|
windows/venv_build/
|
||||||
|
|
||||||
|
# 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,21 +1,23 @@
|
|||||||
# Web Link Playlist Items — Player Integration Guide
|
# Web Link Playlist Items — Player Integration Guide
|
||||||
|
|
||||||
This document describes the changes required on the **Kiwy-Signage player**
|
This document describes how the **Kiwy-Signage player**
|
||||||
(<https://gitea.moto-adv.com/ske087/Kiwy-Signage.git>) to support a new
|
(<https://gitea.moto-adv.com/ske087/Kiwy-Signage.git>) supports the **`weblink`**
|
||||||
playlist item type: **`weblink`** (display a live web page / URL instead of an
|
playlist item type (display a live web page / URL instead of an uploaded media
|
||||||
uploaded media file).
|
file).
|
||||||
|
|
||||||
> The DigiServer (this repo, `digiserver-v2`) side will be updated to emit
|
> **Status: implemented.** The player supports `weblink` items on both
|
||||||
> `weblink` items in the playlist API. The player does **not** yet support them.
|
> Raspberry Pi (`chromium` subprocess) and Windows (embedded **WebView2**, with
|
||||||
> Use this guide to implement the player side later.
|
> 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
|
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:
|
Each playlist item the server returns currently looks like:
|
||||||
@@ -243,3 +245,136 @@ Recommended options, in order of robustness:
|
|||||||
depth).
|
depth).
|
||||||
- Consider running Chromium with `--incognito` (no persistent cookies/cache) as
|
- Consider running Chromium with `--incognito` (no persistent cookies/cache) as
|
||||||
shown above.
|
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.109",
|
"server_ip": "192.168.0.110",
|
||||||
"port": "8080",
|
"port": "80",
|
||||||
"screen_name": "Birou_IT",
|
"screen_name": "DESKTOP-NJLBQKH",
|
||||||
"quickconnect_key": "8887779",
|
"quickconnect_key": "8887779",
|
||||||
"orientation": "Landscape",
|
"orientation": "Landscape",
|
||||||
"touch": "True",
|
"touch": "True",
|
||||||
"max_resolution": "1920x1080",
|
"max_resolution": "1920x1080",
|
||||||
"edit_feature_enabled": true,
|
"edit_feature_enabled": true,
|
||||||
"use_https": false,
|
"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.)
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"count": 4,
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "Test_player1",
|
|
||||||
"playlist": [
|
|
||||||
{
|
|
||||||
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
|
||||||
"type": "image",
|
|
||||||
"url": "media/sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
|
||||||
"duration": 14,
|
|
||||||
"edit_on_player": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "weblink-1e4a4d6d885a",
|
|
||||||
"type": "weblink",
|
|
||||||
"url": "https://moto-adv.com/",
|
|
||||||
"duration": 30,
|
|
||||||
"edit_on_player": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "sample-30s.mp4",
|
|
||||||
"type": "video",
|
|
||||||
"url": "media/sample-30s.mp4",
|
|
||||||
"duration": 31,
|
|
||||||
"edit_on_player": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "edited_media/5/eye_e_v2.jpg",
|
|
||||||
"type": "image",
|
|
||||||
"url": "media/edited_media/5/eye_e_v2.jpg",
|
|
||||||
"duration": 50,
|
|
||||||
"edit_on_player": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"playlist_id": 1,
|
|
||||||
"playlist_version": 14
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Python 3.12.9
|
||||||
+70
-8
@@ -82,11 +82,17 @@ class DrawingLayer(Widget):
|
|||||||
|
|
||||||
class EditPopup(Popup):
|
class EditPopup(Popup):
|
||||||
"""Popup for editing/annotating images"""
|
"""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)
|
super(EditPopup, self).__init__(**kwargs)
|
||||||
self.player = player_instance
|
self.player = player_instance
|
||||||
self.image_path = image_path
|
self.image_path = image_path
|
||||||
self.user_card_data = user_card_data # Store card data to send to server on save
|
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)
|
# Auto-close timer (5 minutes)
|
||||||
self.auto_close_timeout = 300 # 5 minutes in seconds
|
self.auto_close_timeout = 300 # 5 minutes in seconds
|
||||||
@@ -259,8 +265,15 @@ class EditPopup(Popup):
|
|||||||
def save_image(self, instance):
|
def save_image(self, instance):
|
||||||
"""Save the edited image"""
|
"""Save the edited image"""
|
||||||
try:
|
try:
|
||||||
# Create edited_media directory if it doesn't exist
|
# Edited media is stored on the server under
|
||||||
edited_dir = os.path.join(self.player.base_dir, 'media', 'edited_media')
|
# '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)
|
os.makedirs(edited_dir, exist_ok=True)
|
||||||
|
|
||||||
# Get original filename
|
# Get original filename
|
||||||
@@ -310,8 +323,22 @@ class EditPopup(Popup):
|
|||||||
# Overwrite the file
|
# Overwrite the file
|
||||||
shutil.copy2(output_path, self.image_path)
|
shutil.copy2(output_path, self.image_path)
|
||||||
|
|
||||||
# Force file system sync to ensure data is written to disk
|
# Force file system sync to ensure data is written to disk.
|
||||||
|
# NOTE: os.sync() is Linux-only and raises AttributeError on
|
||||||
|
# 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()
|
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
|
# Verify the overwrite
|
||||||
new_size = os.path.getsize(self.image_path)
|
new_size = os.path.getsize(self.image_path)
|
||||||
@@ -326,9 +353,15 @@ class EditPopup(Popup):
|
|||||||
self.ids.top_toolbar.opacity = 1
|
self.ids.top_toolbar.opacity = 1
|
||||||
self.ids.right_sidebar.opacity = 1
|
self.ids.right_sidebar.opacity = 1
|
||||||
|
|
||||||
# Create and save metadata
|
# 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,
|
json_filename = self._save_metadata(edited_dir, new_name, base_name,
|
||||||
new_version if version_match else 1, output_filename)
|
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 to server in background (continues after popup closes)
|
||||||
upload_thread = threading.Thread(
|
upload_thread = threading.Thread(
|
||||||
@@ -414,6 +447,14 @@ class EditPopup(Popup):
|
|||||||
'version': version,
|
'version': version,
|
||||||
'user_card_data': self.user_card_data # Card data from reader (or None)
|
'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
|
# Save metadata JSON
|
||||||
json_filename = f"{new_name}_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)")
|
Logger.warning("EditPopup: Missing server URL or auth code (upload skipped)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Load metadata from 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:
|
with open(metadata_path, 'r') as meta_file:
|
||||||
metadata = json.load(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
|
# Prepare upload URL - send to the original file endpoint
|
||||||
upload_url = f"{server_url}/api/player-edit-media"
|
upload_url = f"{server_url}/api/player-edit-media"
|
||||||
headers = {'Authorization': f'Bearer {auth_code}'}
|
headers = {'Authorization': f'Bearer {auth_code}'}
|
||||||
|
|
||||||
# Add the original filename to metadata so server knows which file was edited
|
# Ensure the original filename (server-side name) is present so the
|
||||||
metadata['original_filename'] = os.path.basename(metadata['original_path'])
|
# 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)
|
# Disable SSL verification for self-signed certificates (like main code does)
|
||||||
# Note: This is NOT recommended for production with untrusted servers
|
# 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.
|
# Web-link items have no file to download — pass the link through unchanged.
|
||||||
if item_type == 'weblink':
|
if item_type == 'weblink':
|
||||||
logger.info(f"🔗 Web link item (no download): {file_url}")
|
logger.info(f"🔗 Web link item (no download): {file_url}")
|
||||||
updated_playlist.append({
|
# Preserve every server field (audio/muted/description/id/position/...)
|
||||||
'file_name': file_name,
|
# instead of rebuilding a fixed dict, so nothing is silently dropped.
|
||||||
'type': 'weblink',
|
weblink_item = dict(media)
|
||||||
'url': file_url, # keep the original web address (not a local path)
|
weblink_item['type'] = 'weblink'
|
||||||
'duration': duration,
|
updated_playlist.append(weblink_item)
|
||||||
'edit_on_player': False,
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
local_path = os.path.join(media_dir, file_name)
|
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
|
# Don't skip - may still add to playlist
|
||||||
|
|
||||||
# Always add the media item to the playlist, even if download failed
|
# Always add the media item to the playlist, even if download failed
|
||||||
# (it might already exist or be available later)
|
# (it might already exist or be available later).
|
||||||
updated_media = {
|
# Preserve EVERY server field (audio/muted/description/id/position/...)
|
||||||
'file_name': file_name,
|
# by copying the original dict and only overriding the URL with the
|
||||||
'type': item_type, # Preserve media type (image/video/...)
|
# local path — previously the fixed dict below dropped `audio`, `muted`,
|
||||||
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
# `description`, `id` and `position` from the saved playlist.
|
||||||
'duration': duration,
|
updated_media = dict(media)
|
||||||
'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag
|
updated_media['url'] = os.path.relpath(local_path, os.path.dirname(media_dir))
|
||||||
}
|
|
||||||
updated_playlist.append(updated_media)
|
updated_playlist.append(updated_media)
|
||||||
|
|
||||||
return updated_playlist
|
return updated_playlist
|
||||||
@@ -354,7 +351,9 @@ def delete_unused_media(playlist_data, media_dir):
|
|||||||
rel_path = os.path.relpath(full_path, media_dir)
|
rel_path = os.path.relpath(full_path, media_dir)
|
||||||
|
|
||||||
# Skip if file is in current playlist
|
# 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
|
continue
|
||||||
|
|
||||||
# Delete unreferenced file
|
# Delete unreferenced file
|
||||||
@@ -454,6 +453,18 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
|
|||||||
return playlist_file
|
return playlist_file
|
||||||
else:
|
else:
|
||||||
logger.info("✓ Playlist is up to date")
|
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
|
return playlist_file
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+1252
-422
File diff suppressed because it is too large
Load Diff
+84
-15
@@ -6,11 +6,16 @@ Checks server connectivity and manages WiFi restart on connection failure
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
|
import platform
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from kivy.logger import Logger
|
from kivy.logger import Logger
|
||||||
from kivy.clock import Clock
|
from kivy.clock import Clock
|
||||||
|
|
||||||
|
# Detect platform once so the ping / WiFi-restart commands below can
|
||||||
|
# pick the correct syntax (Linux vs Windows).
|
||||||
|
IS_WINDOWS = platform.system() == 'Windows'
|
||||||
|
|
||||||
|
|
||||||
class NetworkMonitor:
|
class NetworkMonitor:
|
||||||
"""Monitor network connectivity and manage WiFi restart"""
|
"""Monitor network connectivity and manage WiFi restart"""
|
||||||
@@ -99,9 +104,15 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
||||||
|
|
||||||
# Ping the server hostname with 3 attempts
|
# Ping the server hostname with 3 attempts.
|
||||||
|
# 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(
|
result = subprocess.run(
|
||||||
['ping', '-c', '3', '-W', '3', hostname],
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10
|
||||||
@@ -123,8 +134,11 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
def _restart_wifi(self):
|
def _restart_wifi(self):
|
||||||
"""
|
"""
|
||||||
Restart WiFi by turning it off for a specified duration then back on
|
Restart WiFi by turning it off for a specified duration then back on.
|
||||||
This runs in a separate thread to not block the main application
|
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():
|
def wifi_restart_thread():
|
||||||
try:
|
try:
|
||||||
@@ -132,6 +146,72 @@ class NetworkMonitor:
|
|||||||
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
if IS_WINDOWS:
|
||||||
|
self._restart_wifi_windows()
|
||||||
|
else:
|
||||||
|
self._restart_wifi_linux()
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}")
|
||||||
|
|
||||||
|
# Run in separate thread to not block the application
|
||||||
|
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)
|
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
||||||
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -143,7 +223,6 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
||||||
Logger.info("NetworkMonitor: WiFi is now DISABLED and will remain OFF")
|
|
||||||
else:
|
else:
|
||||||
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
||||||
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||||
@@ -223,13 +302,3 @@ class NetworkMonitor:
|
|||||||
Logger.info("NetworkMonitor: ====================================")
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
else:
|
else:
|
||||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
|
||||||
except Exception as e:
|
|
||||||
Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}")
|
|
||||||
|
|
||||||
# Run in separate thread to not block the application
|
|
||||||
import threading
|
|
||||||
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
|
|||||||
@@ -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": "Birou_IT",
|
|
||||||
"auth_code": "CZncd_2dlTZGieBEdqAbUTjf3qNyEUPDXr8jLVx7NLs",
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "Test_player1",
|
|
||||||
"playlist_id": 1,
|
|
||||||
"orientation": "Landscape",
|
|
||||||
"authenticated": true,
|
|
||||||
"server_url": "http://192.168.0.109:8080"
|
|
||||||
}
|
|
||||||
+78
-51
@@ -352,20 +352,27 @@
|
|||||||
# Settings popup content
|
# Settings popup content
|
||||||
<SettingsPopup@Popup>:
|
<SettingsPopup@Popup>:
|
||||||
title: 'Player Settings'
|
title: 'Player Settings'
|
||||||
size_hint: 0.8, 0.8
|
size_hint: 0.9, 0.85
|
||||||
auto_dismiss: True
|
auto_dismiss: True
|
||||||
|
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'vertical'
|
orientation: 'vertical'
|
||||||
padding: dp(20)
|
padding: [dp(15), dp(10)]
|
||||||
spacing: dp(15)
|
spacing: dp(8)
|
||||||
|
|
||||||
|
ScrollView:
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'vertical'
|
||||||
|
spacing: dp(8)
|
||||||
|
size_hint_y: None
|
||||||
|
height: self.minimum_height
|
||||||
|
|
||||||
# Server configuration
|
# Server configuration
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Server IP:'
|
text: 'Server IP:'
|
||||||
@@ -378,7 +385,8 @@
|
|||||||
id: server_input
|
id: server_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
|
hint_text: 'e.g. 192.168.0.110'
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
@@ -386,8 +394,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Port:'
|
text: 'Port:'
|
||||||
@@ -400,7 +408,7 @@
|
|||||||
id: port_input
|
id: port_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
hint_text: '80 or 8080 (leave empty for default)'
|
hint_text: '80 or 8080 (leave empty for default)'
|
||||||
input_filter: 'int'
|
input_filter: 'int'
|
||||||
write_tab: False
|
write_tab: False
|
||||||
@@ -410,8 +418,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Screen Name:'
|
text: 'Screen Name:'
|
||||||
@@ -424,7 +432,8 @@
|
|||||||
id: screen_input
|
id: screen_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
|
hint_text: 'player name registered on the server'
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
@@ -432,8 +441,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Quickconnect:'
|
text: 'Quickconnect:'
|
||||||
@@ -446,7 +455,8 @@
|
|||||||
id: quickconnect_input
|
id: quickconnect_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
|
hint_text: 'e.g. 8887779'
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
@@ -454,8 +464,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Orientation:'
|
text: 'Orientation:'
|
||||||
@@ -468,7 +478,7 @@
|
|||||||
id: orientation_input
|
id: orientation_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
@@ -476,8 +486,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Touch:'
|
text: 'Touch:'
|
||||||
@@ -490,7 +500,7 @@
|
|||||||
id: touch_input
|
id: touch_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
@@ -498,8 +508,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Max Resolution:'
|
text: 'Max Resolution:'
|
||||||
@@ -512,7 +522,7 @@
|
|||||||
id: resolution_input
|
id: resolution_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
hint_text: '1920x1080 or auto'
|
hint_text: '1920x1080 or auto'
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
@@ -521,11 +531,11 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Enable Edit Feature:'
|
text: 'Enable Edit:'
|
||||||
size_hint_x: 0.3
|
size_hint_x: 0.3
|
||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'left'
|
halign: 'left'
|
||||||
@@ -534,87 +544,107 @@
|
|||||||
CheckBox:
|
CheckBox:
|
||||||
id: edit_enabled_checkbox
|
id: edit_enabled_checkbox
|
||||||
size_hint_x: None
|
size_hint_x: None
|
||||||
width: dp(40)
|
width: dp(36)
|
||||||
active: True
|
active: True
|
||||||
on_active: root.on_edit_feature_toggle(self.active)
|
on_active: root.on_edit_feature_toggle(self.active)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: '(Allow editing images on this player)'
|
text: '(Allow editing images)'
|
||||||
size_hint_x: 0.4
|
size_hint_x: 0.4
|
||||||
font_size: sp(12)
|
font_size: sp(11)
|
||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'left'
|
halign: 'left'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
color: 0.7, 0.7, 0.7, 1
|
color: 0.7, 0.7, 0.7, 1
|
||||||
|
|
||||||
|
# Separator
|
||||||
Widget:
|
Widget:
|
||||||
size_hint_y: 0.05
|
size_hint_y: None
|
||||||
|
height: dp(5)
|
||||||
|
|
||||||
# Reset Buttons Section
|
# Reset Buttons Section
|
||||||
Label:
|
Label:
|
||||||
text: 'Reset Options:'
|
text: 'Reset Options:'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(30)
|
height: dp(26)
|
||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'left'
|
halign: 'left'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
bold: True
|
bold: True
|
||||||
font_size: sp(16)
|
font_size: sp(14)
|
||||||
|
|
||||||
# Reset Buttons Row
|
# Reset Buttons Row
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(50)
|
height: dp(44)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
id: reset_auth_btn
|
id: reset_auth_btn
|
||||||
text: 'Reset Player Auth'
|
text: 'Reset Player Auth'
|
||||||
background_color: 0.8, 0.4, 0.2, 1
|
background_color: 0.8, 0.4, 0.2, 1
|
||||||
|
font_size: sp(12)
|
||||||
on_press: root.reset_player_auth()
|
on_press: root.reset_player_auth()
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
id: reset_playlist_btn
|
id: reset_playlist_btn
|
||||||
text: 'Reset Playlist to v0'
|
text: 'Reset Playlist to v0'
|
||||||
background_color: 0.8, 0.4, 0.2, 1
|
background_color: 0.8, 0.4, 0.2, 1
|
||||||
|
font_size: sp(12)
|
||||||
on_press: root.reset_playlist_version()
|
on_press: root.reset_playlist_version()
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
id: restart_player_btn
|
id: restart_player_btn
|
||||||
text: 'Restart Player'
|
text: 'Restart Player'
|
||||||
background_color: 0.2, 0.6, 0.8, 1
|
background_color: 0.2, 0.6, 0.8, 1
|
||||||
|
font_size: sp(12)
|
||||||
on_press: root.restart_player()
|
on_press: root.restart_player()
|
||||||
|
|
||||||
# Test Connection Button
|
# Test Connection + Production Mode Buttons
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(44)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
id: test_connection_btn
|
id: test_connection_btn
|
||||||
text: 'Test Server Connection'
|
text: 'Test Server Connection'
|
||||||
size_hint_y: None
|
|
||||||
height: dp(50)
|
|
||||||
background_color: 0.2, 0.4, 0.8, 1
|
background_color: 0.2, 0.4, 0.8, 1
|
||||||
|
font_size: sp(13)
|
||||||
on_press: root.test_connection()
|
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
|
# Connection Status Label
|
||||||
Label:
|
Label:
|
||||||
id: connection_status
|
id: connection_status
|
||||||
text: 'Click button to test connection'
|
text: 'Click button to test connection'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(32)
|
||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'center'
|
halign: 'center'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
|
font_size: sp(11)
|
||||||
color: 0.7, 0.7, 0.7, 1
|
color: 0.7, 0.7, 0.7, 1
|
||||||
|
|
||||||
|
# Separator
|
||||||
Widget:
|
Widget:
|
||||||
size_hint_y: 0.05
|
size_hint_y: None
|
||||||
|
height: dp(5)
|
||||||
|
|
||||||
# Status information row
|
# Status information row
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(30)
|
height: dp(26)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
id: playlist_info
|
id: playlist_info
|
||||||
@@ -622,7 +652,7 @@
|
|||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'center'
|
halign: 'center'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
font_size: sp(12)
|
font_size: sp(11)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
id: media_count_info
|
id: media_count_info
|
||||||
@@ -630,7 +660,7 @@
|
|||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'center'
|
halign: 'center'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
font_size: sp(12)
|
font_size: sp(11)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
id: status_info
|
id: status_info
|
||||||
@@ -638,17 +668,14 @@
|
|||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'center'
|
halign: 'center'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
font_size: sp(12)
|
font_size: sp(11)
|
||||||
|
|
||||||
Widget:
|
# Action buttons (always visible, outside scroll)
|
||||||
size_hint_y: 0.05
|
|
||||||
|
|
||||||
# Action buttons
|
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(50)
|
height: dp(44)
|
||||||
spacing: dp(20)
|
spacing: dp(15)
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
text: 'Save & Close'
|
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
@@ -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)
|
||||||
Executable → Regular
@@ -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