18 Commits

Author SHA1 Message Date
ske087 7e880421c9 Update player auth/state and build output
- src/player_auth.json, player_auth.json: refreshed player credentials. The
  untracked root copy is now added to version control.
- playlists/server_playlist.json: latest synced playlist state.
- windows/archive_list.txt, windows/build_last.txt: build output, committed by
  request.

SECURITY: these files contain live player credentials (auth_code, player_id,
server_url). Anyone who can read the repository can use them. Consider rotating
the auth code and removing the credential files from version control.
2026-09-10 16:45:09 +03:00
ske087 5d9aa02c07 Add Windows card reader, code-signing helpers and playlist diagnostics
- windows/win_card_reader.py: Windows-native card reader via the Raw Input API
  with a low-level keyboard-hook fallback.
- windows/sign_exe.ps1: sign the built executable with a .pfx certificate.
- windows/create_self_signed_cert.ps1: generate a self-signed cert for local
  testing (not trusted by Smart App Control).
- windows/verify_sendinput_fix.py: verification helper for the SendInput
  foreground-unlock fix in run_win.py.
- documentation/CODE_SIGNING_SMART_APP_CONTROL.md: signing guidance.
- working_files/execute_playlist_retrieve.py,
  working_files/raw_server_playlist.json: playlist retrieval diagnostics.

Note: windows/archive_list.txt and windows/build_last.txt are build output and
were committed by request rather than by convention.
2026-09-10 16:44:12 +03:00
ske087 a0704efa3c Fix pre-existing player issues: edit upload paths, playlist sync, DPI, console
src/edit_popup.py: pass and reproduce the server-side edited-media layout
('edited_media/<media_id>/') when saving an edit, falling back to the flat
folder when no media id is available, so uploads land where the server expects.

src/get_playlists_v2.py: preserve every server field (audio, muted, description,
id, position, ...) when rewriting playlist items, instead of rebuilding fixed
dicts that silently dropped them. Web-link items keep their original http(s) url
and are never downloaded.

windows/pyi_runtime_hook.py: declare per-monitor DPI awareness before SDL/Kivy
initialise, so on a scaled display the window is not virtualised to a smaller
resolution (which left a black strip and mis-scaled media).

windows/build_win.bat: optional code signing step for PCs with Smart App Control
enabled, driven by KIWY_SIGN_PFX / KIWY_SIGN_PFX_PASSWORD or a local
kiwy_signing.pfx.

windows/build.spec, windows/README_WINDOWS_BUILD.md, windows/development-track.md:
build notes and manifest updates.
2026-09-10 16:44:04 +03:00
ske087 6dc79828bc Refactor player and Windows wrapper onto WeblinkSession
src/main.py:
- Delegate web-link playback to WeblinkSession (get_weblink_session), with
  on_finished / on_failed callbacks driving the transition.
- Remove the in-file Chromium subprocess launching, the /dev/input watchdog and
  the pre-warm implementation; keep thin deprecated shims for platform code.
- Add _item_is_weblink(), which also accepts type aliases and the
  "no file extension + http(s) url" shape so a slightly different server
  payload is not treated as a missing media file.
- Replace the 1.0s wall-clock advance guard with a generation token
  (next_media's _token plus _schedule_advance). The old window silently dropped
  deliberate fast transitions such as weblink -> weblink; the token still
  discards stale/duplicated callbacks.
- toggle_pause is now a no-op while a web link is active: a web link is an
  interactive surface, so pause/play does not apply to it and can no longer cut
  a viewer's session short. Pause still works for images and videos.
- Preload/prewarm the next item through the session.
- _get_browser_target_size uses the real window size instead of hardcoding a
  1920x1080 fallback.

windows/run_win.py:
- Replace the Windows play_weblink override, watchdog, kill_weblink_after_frame,
  play_current_media wrapper and prewarm override with adapter injection via
  weblink_adapter_factory.
- _WinCefAdapter: embedded CEF, preferred (no subprocess, no z-order fights).
  Binds the Kivy resize handler once instead of rebinding a new closure every
  weblink cycle, which grew the callback list without bound.
- _WinChromeAdapter: Chrome/Edge subprocess with a real HWND visibility check,
  so a hand-off or a page that never paints is detected instead of leaving a
  black screen. Teardown keeps the required order (hide overlay, then raise
  Kivy) to avoid handing foreground to Explorer.
- Delete the now-dead _hide_overlay_when_chrome_ready and
  _bring_chrome_to_front. The former leaked a Clock.schedule_interval on every
  weblink cycle.
2026-09-10 16:43:45 +03:00
ske087 a19627885c Add unified WeblinkSession controller and interaction-driven playback
Web links were implemented three times (main.py subprocess, run_win.py
subprocess + Win32 overlay, cef_browser.py embedded CEF), each owning its own
process handle, watchdog and teardown. That ambiguity caused leaked browsers,
skipped items, lost foreground and blank screens when a page failed to load.

Replace all three with a single owner in src/weblink_session.py:

- WeblinkSession: validate -> launch -> verify -> watch -> teardown.
  Generation-tokened so stale callbacks are ignored, idempotent close(),
  atexit-safe, never more than one browser alive.
- WeblinkAdapter: the only platform-specific part (launch / wait_visible /
  is_alive / teardown / prewarm). Platform layers inject engines through
  SignagePlayer.weblink_adapter_factory.
- ChromiumSubprocessAdapter: default engine (Pi chromium, Windows chrome/msedge).
- InteractionWatcher: decides when an item is finished.
- WebInputSources: /dev/input/event* (Linux) plus a GetCursorPos pointer tap
  (needed on Windows for embedded CEF, which has no child process).

Interaction model: web links are an interactive surface, not timed media.
The player advances only when the configured duration has elapsed AND the
viewer has not interacted for 10s, measured from the most recent interaction.
A touch in the final seconds of a slot therefore pushes the advance 10s past
that touch, and each further touch pushes it again, so a page is never pulled
out from under someone using it. An untouched page still advances on schedule.
A drag burst counts as one interaction but the countdown tracks its last event,
so an item cannot be cut off mid-gesture. max_dwell (duration x factor, floored
by min_max_dwell) is an absolute backstop against a wedged browser or a jammed
touchscreen.

Verified start-up: the visibility wait runs on the watcher thread, never on
Kivy's main thread. If the browser window never appears the item is reported
failed and skipped, instead of resetting the error counter and leaving a black
screen up for the whole duration.

Config: new "weblink" block in config/app_config.json (engine,
interaction_postpone, interaction_debounce, interaction_grace,
max_dwell_factor, min_max_dwell, launch_timeout, prewarm) with safe defaults,
so an absent block still works.

Also add weblink_session to the PyInstaller hiddenimports so the frozen exe
bundles the new module.
2026-09-10 16:43:36 +03:00
ske087 31ad592e98 Fix Windows player: kiosk lockdown, robust video transitions, keep-awake
- Production/kiosk mode: exit_on_escape=0, window-close guard, Ctrl+C
  ignore, Alt+F4/Alt+Tab/Win/Ctrl+Esc keyboard hook (Windows)
- Robust video playback: async (non-blocking) ffpyplayer teardown,
  video progress watchdog (advance at true clip end), EOS re-entrancy
  guard, stale-advance guard, focus keeper for foreground retention
- Resume playback timer after Settings/exit popups close
- Windows keep-awake: SetThreadExecutionState + disable screensaver/
  lock screen (restored on exit)
- Always-on playback_trace.log for diagnosing transitions
- exe metadata: app_icon.ico + version_info.txt (publisher identity)
2026-08-04 15:57:33 +03:00
ske087 5c2b3f545f working windows module 2026-07-31 15:40:59 +03:00
ske087 d0ea94447a Exclude cefpython3 other-Python-version .pyd and GStreamer from build
- Added kivy.lib.gstplayer to excluded_imports (we use ffpyplayer)
- Added all cefpython3_py{27,34-311}.pyd to excluded_imports
- Removes ~300 lines of library-not-found warnings from build output
- Reduces .exe size from 143 MB to 96 MB
2026-07-24 16:45:31 +03:00
ske087 12f2880201 Rewrite CEF browser as child of Kivy window (v2)
v1 created a separate Win32 window — same problem as external Chrome.
v2 creates CEF as a CHILD WINDOW of Kivy's SDL_app window:
  - No separate taskbar entry
  - No z-order fighting (CEF is INSIDE Kivy)
  - No desktop flash
  - CEF message loop pumped via Kivy Clock (main thread)
  - Resize handler attached so CEF follows Kivy window changes
  - build.spec includes cef_browser in hidden imports
2026-07-24 16:22:33 +03:00
ske087 5a030671a2 Fix 3 bugs: delete_unused_media path mismatch, media not downloading on same-version, video EOS not advancing
- delete_unused_media: normalize Windows backslashes to forward slashes
  when comparing with playlist file_name (fixes file deleted after download)
- update_playlist_if_needed: download media even when playlist version matches
- _on_video_eos: now schedules next_media() — was an empty stub
2026-07-24 16:10:23 +03:00
ske087 a2add88f04 Fix video not advancing + media not downloading on same-version startup
BUG-009: _on_video_eos was empty (stub). Added Clock.schedule_once
for next_media() when video reaches end of stream.

BUG-008: download_media_files only ran when server_version >
local_version. Added download check in up-to-date branch so media
files are synced even when playlist version hasn't changed.
2026-07-24 15:43:56 +03:00
ske087 c4e8381898 Remove venv_build from git tracking (should not commit venv) 2026-07-24 15:10:20 +03:00
ske087 ced6e10919 Rebuild .exe with CEF + win32gui fixes
- Updated build.spec with win32gui/win32con hidden imports
- Updated requirements_win.txt with cefpython3 docs
- Rebuilt .exe at 2026-07-24 14:41 (143 MB with CEF)
- All 12 resources bundled (icons, intro1.mp4)
- development-track.md updated with BUG-007, BUG-008
2026-07-24 15:09:48 +03:00
ske087 844e5eeebb Add CEF embedded browser + win32gui for weblink handling
Windows-specific fixes:
- New cef_browser.py: embedded Chromium via cefpython3, no subprocess
- _bring_kivy_to_front(): uses win32gui.SetForegroundWindow (reliable)
- _windows_kill_weblink_after_frame: kills Chrome IMMEDIATELY
- prewarm_weblink disabled on Windows (desktop launch is fast)
- _windows_play_weblink tries CEF first, falls back to subprocess
- Updated requirements_win.txt (cefpython3, pywin32 confirmed)
- Added development-track.md for change tracking
2026-07-24 14:39:12 +03:00
ske087 7efc023327 Add development-track.md — session log, bug tracker, rejected solutions, build info 2026-07-24 13:52:28 +03:00
ske087 6abde5a767 Fix Windows weblink handling: fullscreen Chrome, black overlay masking, proper process tree kill
Windows-specific fixes:
- _windows_play_weblink: uses --start-maximized + --app=URL for true fullscreen
- Shows black Win32 overlay before opening/closing Chrome to mask desktop
- _windows_kill_process_tree: uses taskkill /F /T to kill all Chrome child processes
- _Win32Overlay class: fullscreen borderless always-on-top black window
- Updated README to note Python 3.12 requirement and local data dir behavior
2026-07-24 13:48:49 +03:00
ske087 362f5096a0 Fix infinite recursion when restarting player with empty playlist
- Added empty playlist guard in play_current_media() to return
  early instead of calling restart_playlist()
- Added empty playlist guard in restart_playlist() to return
  early instead of calling play_current_media()
- Wrapped SettingsPopup content in ScrollView so fields
  are not cut off on smaller screens
2026-07-24 09:53:29 +03:00
ske087 3845830a86 Add Windows Player support and related files
- New windows/ directory with build scripts, specs, and configuration
- Windows-specific requirements (requirements_win.txt)
- Launch and runtime scripts for Windows (run_win.py, launch_player.bat)
- PyInstaller build configuration (build.spec)
- Updated .gitignore to exclude windows/venv312/
- Updated config and source files for Windows compatibility
- Moved working_files to proper directory
2026-07-24 08:34:00 +03:00
39 changed files with 8069 additions and 847 deletions
+3
View File
@@ -25,6 +25,7 @@ wheels/
venv/ venv/
ENV/ ENV/
env/ env/
windows/venv312/
# Kivy # Kivy
*.pyc *.pyc
@@ -59,3 +60,5 @@ playlists/server_playlist_*.json
Thumbs.db Thumbs.db
.player_heartbear .player_heartbear
windows/venv_build/
+105 -9
View File
@@ -1,21 +1,22 @@
# 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 CEF with a
> Use this guide to implement the player side later. > Chrome/Edge subprocess fallback). Sections 14 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 +244,98 @@ 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. |
| `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`, Windows `chrome.exe`/`msedge.exe`). |
| `InteractionWatcher` | Decides when the item is finished (see the interaction model below). |
| `WebInputSources` | Reads `/dev/input/event*` (Linux) and does a pointer-position tap (Windows, needed for embedded CEF). |
Platform wrappers inject their engines through
`SignagePlayer.weblink_adapter_factory`:
* **Raspberry Pi / Linux** — built-in Chromium subprocess adapter.
* **Windows** (`windows/run_win.py`) — embedded CEF first (`cef_browser.py`,
renders inside the Kivy window: no z-order fights, no subprocess), then the
Chrome/Edge subprocess adapter as fallback.
### 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`.
+16 -3
View File
@@ -1,12 +1,25 @@
{ {
"server_ip": "192.168.0.109", "server_ip": "192.168.0.108",
"port": "8080", "port": "8080",
"screen_name": "Birou_IT", "screen_name": "WINDOWS-PC",
"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 100300/yr. The CA will issue a `.pfx`/`.p12` (or `.cer`+key).
2. **Sign the exe** after each build. Place your pfx at
`windows\kiwy_signing.pfx` (or set `KIWY_SIGN_PFX` env var) — `build_win.bat`
will then auto-sign via `sign_exe.ps1`:
```powershell
# One-off, from the windows\ folder:
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -CertPassword "yourpwd"
```
The script:
- locates `signtool.exe` (Windows SDK) — install with
`winget install Microsoft.WindowsSDK.10.0.26100` if missing,
- signs with **SHA256** + **RFC3161 timestamp** (required for SAC and to
keep the signature valid after the cert expires),
- verifies the result with `Get-AuthenticodeSignature`.
3. **Test** — confirm on one production PC:
```powershell
Get-AuthenticodeSignature "dist\KiwySignagePlayer\KiwySignagePlayer.exe"
# Status must be: Valid
```
---
## 3. Dev / test machines (where you have admin rights)
If a test PC has SAC **off**, you can make the app trusted locally without
buying a cert:
```powershell
# Run as Administrator
.\create_self_signed_cert.ps1
```
This creates a self-signed code-signing cert, exports `kiwy_dev_signing.pfx`,
and installs it into **Trusted Root + Trusted Publisher + Trusted People** for
the current user, so the player runs without SmartScreen/Defender prompts on
that dev PC.
⚠️ **This does NOT satisfy SAC.** It is only for machines where SAC is off or
where you have admin rights.
---
## 4. Build → sign → verify workflow
```bat
:: 1. Build (produces dist\KiwySignagePlayer\KiwySignagePlayer.exe)
cd windows
venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
:: 2. Sign (auto if kiwy_signing.pfx present, else manual)
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -CertPassword "..."
:: 3. Verify
Get-AuthenticodeSignature "dist\KiwySignagePlayer\KiwySignagePlayer.exe"
```
`build_win.bat` now does step 1 + step 2 automatically when a pfx is present.
---
## 5. Important caveats
- **Timestamping is mandatory.** The sign script timestamps by default
(`http://timestamp.digicert.com`). Without a timestamp, the signature becomes
invalid once the certificate expires and SAC will block the app.
- **SAC reputation takes time.** Even a validly signed exe from a brand-new
certificate may be blocked until the CA's reputation builds. EV certificates
and well-known CAs (DigiCert, Sectigo, SSL.com) pass immediately.
- **Re-sign after every build.** PyInstaller creates a new exe each time; the
old signature is lost. The auto-sign step in `build_win.bat` handles this.
- **Do not use UPX** on the signed exe — it invalidates the signature and can
trigger false positives. (`upx=True` in the spec currently does nothing
because UPX is not installed; if you ever install UPX, set it to False.)
+10
View File
@@ -0,0 +1,10 @@
{
"hostname": "WINDOWS-PC",
"auth_code": "GAh_D0qrgRGS0ERK-AQN0Fx17HvEmV9aA7EHXzszYO4",
"player_id": 2,
"player_name": "Windows-Player1",
"playlist_id": 1,
"orientation": "Landscape",
"authenticated": true,
"server_url": "http://192.168.0.107:8080"
}
+71 -27
View File
@@ -1,37 +1,81 @@
{ {
"count": 4, "count": 6,
"player_id": 1, "player_id": 2,
"player_name": "Test_player1", "player_name": "Windows-Player1",
"playlist": [ "playlist": [
{ {
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg", "audio": "off",
"type": "image", "description": null,
"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, "duration": 30,
"edit_on_player": false "edit_on_player": false,
}, "file_name": "anders-jilden-cYrMQA7a3Wc-unsplash.jpg",
{ "id": 9,
"file_name": "sample-30s.mp4", "muted": true,
"type": "video", "position": 1,
"url": "media/sample-30s.mp4",
"duration": 31,
"edit_on_player": false
},
{
"file_name": "edited_media/5/eye_e_v2.jpg",
"type": "image", "type": "image",
"url": "media/edited_media/5/eye_e_v2.jpg", "url": "media\\anders-jilden-cYrMQA7a3Wc-unsplash.jpg"
},
{
"audio": "off",
"description": null,
"duration": 14,
"edit_on_player": true,
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
"id": 2,
"muted": true,
"position": 2,
"type": "image",
"url": "media\\sean-oulashin-KMn4VEeEPR8-unsplash.jpg"
},
{
"audio": "on",
"description": null,
"duration": 31,
"edit_on_player": false,
"file_name": "sample-30s.mp4",
"id": 4,
"muted": false,
"position": 3,
"type": "video",
"url": "media\\sample-30s.mp4"
},
{
"audio": "off",
"description": null,
"duration": 50, "duration": 50,
"edit_on_player": true "edit_on_player": true,
"file_name": "edited_media/5/eye_e_v2.jpg",
"id": 5,
"muted": true,
"position": 4,
"type": "image",
"url": "media\\edited_media\\5\\eye_e_v2.jpg"
},
{
"audio": "off",
"description": "https://moto-adv.com/",
"duration": 30,
"edit_on_player": false,
"file_name": "weblink-ecc2705c34e5",
"id": 7,
"muted": true,
"position": 5,
"type": "weblink",
"url": "https://moto-adv.com/"
},
{
"audio": "off",
"description": null,
"duration": 30,
"edit_on_player": false,
"file_name": "jack-anstey-XVoyX7l9ocY-unsplash.jpg",
"id": 8,
"muted": true,
"position": 6,
"type": "image",
"url": "media\\jack-anstey-XVoyX7l9ocY-unsplash.jpg"
} }
], ],
"playlist_id": 1, "playlist_id": 1,
"playlist_version": 14 "playlist_version": 32
} }
+1
View File
@@ -0,0 +1 @@
Python 3.12.9
+75 -13
View File
@@ -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.
os.sync() # NOTE: os.sync() is Linux-only and raises AttributeError on
# Windows — that used to abort the whole pipeline before the
# metadata/upload steps. Use a cross-platform fsync that is
# best-effort and can never break the save/upload flow.
try:
if hasattr(os, 'sync'):
os.sync()
else:
with open(output_path, 'rb') as _f:
try:
os.fsync(_f.fileno())
except Exception:
pass
except Exception as _sync_err:
Logger.warning(f"EditPopup: File sync skipped ({_sync_err})")
# Verify the overwrite # 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
json_filename = self._save_metadata(edited_dir, new_name, base_name, # block so that a failure here cannot silently stop the
new_version if version_match else 1, output_filename) # upload — the two steps are intentionally decoupled.
json_filename = None
try:
json_filename = self._save_metadata(edited_dir, new_name, base_name,
new_version if version_match else 1, output_filename)
except Exception as meta_err:
Logger.error(f"EditPopup: Metadata save failed: {meta_err}")
# Upload to server in background (continues after popup closes) # Upload 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
with open(metadata_path, 'r') as meta_file: # file was not written — the upload must still go through).
metadata = json.load(meta_file) metadata = None
if metadata_path and os.path.exists(metadata_path):
try:
with open(metadata_path, 'r') as meta_file:
metadata = json.load(meta_file)
except Exception as e:
Logger.warning(f"EditPopup: Could not read metadata file: {e}")
if not metadata:
metadata = {
'time_of_modification': datetime.now().isoformat(),
'original_name': os.path.basename(image_path),
'new_name': os.path.basename(image_path),
'version': 1,
'user_card_data': self.user_card_data,
}
if self.original_filename:
metadata['original_filename'] = self.original_filename
if self.media_id is not None:
metadata['media_id'] = self.media_id
# Prepare upload URL - send to the original file endpoint # 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
View File
@@ -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:
+917 -389
View File
File diff suppressed because it is too large Load Diff
+163 -94
View File
@@ -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,97 +146,10 @@ class NetworkMonitor:
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE") Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
Logger.info("NetworkMonitor: ====================================") Logger.info("NetworkMonitor: ====================================")
# Turn off WiFi using rfkill (more reliable on Raspberry Pi) if IS_WINDOWS:
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...") self._restart_wifi_windows()
result = subprocess.run(
['sudo', 'rfkill', 'block', 'wifi'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
Logger.info("NetworkMonitor: WiFi is now DISABLED and will remain OFF")
else: else:
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...") self._restart_wifi_linux()
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
# Fallback to ifconfig
result2 = subprocess.run(
['sudo', 'ifconfig', 'wlan0', 'down'],
capture_output=True,
text=True,
timeout=10
)
if result2.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
else:
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
return
# Wait for the specified duration with WiFi OFF
wait_minutes = self.wifi_restart_duration / 60
Logger.info(f"NetworkMonitor: ====================================")
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
Logger.info(f"NetworkMonitor: ====================================")
# Sleep while WiFi is OFF
time.sleep(self.wifi_restart_duration)
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
# Turn WiFi back on after the wait period
Logger.info("NetworkMonitor: ====================================")
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
Logger.info("NetworkMonitor: ====================================")
# Unblock WiFi using rfkill
result = subprocess.run(
['sudo', 'rfkill', 'unblock', 'wifi'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
else:
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
# Also bring interface up
result2 = subprocess.run(
['sudo', 'ifconfig', 'wlan0', 'up'],
capture_output=True,
text=True,
timeout=10
)
if result2.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
# Wait a bit for connection to establish
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
time.sleep(10)
# Try to restart DHCP
Logger.info("NetworkMonitor: Requesting IP address...")
subprocess.run(
['sudo', 'dhclient', 'wlan0'],
capture_output=True,
text=True,
timeout=15
)
Logger.info("NetworkMonitor: ====================================")
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
Logger.info("NetworkMonitor: ====================================")
else:
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
Logger.error("NetworkMonitor: WiFi restart command timeout") Logger.error("NetworkMonitor: WiFi restart command timeout")
@@ -233,3 +160,145 @@ class NetworkMonitor:
import threading import threading
thread = threading.Thread(target=wifi_restart_thread, daemon=True) thread = threading.Thread(target=wifi_restart_thread, daemon=True)
thread.start() thread.start()
def _restart_wifi_windows(self):
"""Windows WiFi restart using netsh. Turn off for the wait period,
then turn back on so Windows reconnects to the preferred network."""
wait_minutes = self.wifi_restart_duration / 60
Logger.info(
f"NetworkMonitor: Windows WiFi restart — off for {wait_minutes:.0f} min"
)
# Turn WiFi OFF
off = subprocess.run(
['netsh', 'wlan', 'disconnect'],
capture_output=True, text=True, timeout=10
)
if off.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi turned OFF (netsh wlan disconnect)")
else:
Logger.warning(
f"NetworkMonitor: netsh wlan disconnect failed: {off.stderr.strip()}"
)
# Wait with WiFi OFF
Logger.info(
f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes "
f"(started {datetime.now().strftime('%H:%M:%S')})"
)
time.sleep(self.wifi_restart_duration)
Logger.info(
f"NetworkMonitor: Wait period completed at {datetime.now().strftime('%H:%M:%S')}"
)
# Turn WiFi back ON — Windows reconnects to the preferred network
on = subprocess.run(
['netsh', 'wlan', 'connect'],
capture_output=True, text=True, timeout=10
)
if on.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi re-enabled (netsh wlan connect)")
else:
# 'netsh wlan connect' without a profile may return non-zero even
# though the radio comes back on; log it but don't fail hard.
Logger.warning(
f"NetworkMonitor: netsh wlan connect returned {on.returncode}: "
f"{on.stderr.strip()} (may reconnect automatically)"
)
Logger.info("NetworkMonitor: ====================================")
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
Logger.info("NetworkMonitor: ====================================")
def _restart_wifi_linux(self):
"""Linux (Raspberry Pi) WiFi restart using rfkill/ifconfig/dhclient."""
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
result = subprocess.run(
['sudo', 'rfkill', 'block', 'wifi'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
else:
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
# Fallback to ifconfig
result2 = subprocess.run(
['sudo', 'ifconfig', 'wlan0', 'down'],
capture_output=True,
text=True,
timeout=10
)
if result2.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
else:
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
return
# Wait for the specified duration with WiFi OFF
wait_minutes = self.wifi_restart_duration / 60
Logger.info(f"NetworkMonitor: ====================================")
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
Logger.info(f"NetworkMonitor: ====================================")
# Sleep while WiFi is OFF
time.sleep(self.wifi_restart_duration)
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
# Turn WiFi back on after the wait period
Logger.info("NetworkMonitor: ====================================")
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
Logger.info("NetworkMonitor: ====================================")
# Unblock WiFi using rfkill
result = subprocess.run(
['sudo', 'rfkill', 'unblock', 'wifi'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
else:
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
# Also bring interface up
result2 = subprocess.run(
['sudo', 'ifconfig', 'wlan0', 'up'],
capture_output=True,
text=True,
timeout=10
)
if result2.returncode == 0:
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
# Wait a bit for connection to establish
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
time.sleep(10)
# Try to restart DHCP
Logger.info("NetworkMonitor: Requesting IP address...")
subprocess.run(
['sudo', 'dhclient', 'wlan0'],
capture_output=True,
text=True,
timeout=15
)
Logger.info("NetworkMonitor: ====================================")
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
Logger.info("NetworkMonitor: ====================================")
else:
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
+64
View File
@@ -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
+5 -5
View File
@@ -1,10 +1,10 @@
{ {
"hostname": "Birou_IT", "hostname": "WINDOWS-PC",
"auth_code": "CZncd_2dlTZGieBEdqAbUTjf3qNyEUPDXr8jLVx7NLs", "auth_code": "GAh_D0qrgRGS0ERK-AQN0Fx17HvEmV9aA7EHXzszYO4",
"player_id": 1, "player_id": 2,
"player_name": "Test_player1", "player_name": "Windows-Player1",
"playlist_id": 1, "playlist_id": 1,
"orientation": "Landscape", "orientation": "Landscape",
"authenticated": true, "authenticated": true,
"server_url": "http://192.168.0.109:8080" "server_url": "http://192.168.0.107:8080"
} }
+313 -289
View File
@@ -352,303 +352,327 @@
# 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)
# Server configuration ScrollView:
BoxLayout:
orientation: 'vertical'
spacing: dp(8)
size_hint_y: None
height: self.minimum_height
# Server configuration
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(36)
spacing: dp(8)
Label:
text: 'Server IP:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: server_input
size_hint_x: 0.7
multiline: False
font_size: sp(13)
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Server port
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(36)
spacing: dp(8)
Label:
text: 'Port:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: port_input
size_hint_x: 0.7
multiline: False
font_size: sp(13)
hint_text: '80 or 8080 (leave empty for default)'
input_filter: 'int'
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Screen name
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(36)
spacing: dp(8)
Label:
text: 'Screen Name:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: screen_input
size_hint_x: 0.7
multiline: False
font_size: sp(13)
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Quickconnect key
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(36)
spacing: dp(8)
Label:
text: 'Quickconnect:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: quickconnect_input
size_hint_x: 0.7
multiline: False
font_size: sp(13)
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Orientation
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(36)
spacing: dp(8)
Label:
text: 'Orientation:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: orientation_input
size_hint_x: 0.7
multiline: False
font_size: sp(13)
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Touch
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(36)
spacing: dp(8)
Label:
text: 'Touch:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: touch_input
size_hint_x: 0.7
multiline: False
font_size: sp(13)
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Resolution
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(36)
spacing: dp(8)
Label:
text: 'Max Resolution:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: resolution_input
size_hint_x: 0.7
multiline: False
font_size: sp(13)
hint_text: '1920x1080 or auto'
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Edit Feature Enable/Disable
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(36)
spacing: dp(8)
Label:
text: 'Enable Edit:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
CheckBox:
id: edit_enabled_checkbox
size_hint_x: None
width: dp(36)
active: True
on_active: root.on_edit_feature_toggle(self.active)
Label:
text: '(Allow editing images)'
size_hint_x: 0.4
font_size: sp(11)
text_size: self.size
halign: 'left'
valign: 'middle'
color: 0.7, 0.7, 0.7, 1
# Separator
Widget:
size_hint_y: None
height: dp(5)
# Reset Buttons Section
Label:
text: 'Reset Options:'
size_hint_y: None
height: dp(26)
text_size: self.size
halign: 'left'
valign: 'middle'
bold: True
font_size: sp(14)
# Reset Buttons Row
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(44)
spacing: dp(8)
Button:
id: reset_auth_btn
text: 'Reset Player Auth'
background_color: 0.8, 0.4, 0.2, 1
font_size: sp(12)
on_press: root.reset_player_auth()
Button:
id: reset_playlist_btn
text: 'Reset Playlist to v0'
background_color: 0.8, 0.4, 0.2, 1
font_size: sp(12)
on_press: root.reset_playlist_version()
Button:
id: restart_player_btn
text: 'Restart Player'
background_color: 0.2, 0.6, 0.8, 1
font_size: sp(12)
on_press: root.restart_player()
# Test Connection + Production Mode Buttons
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(44)
spacing: dp(8)
Button:
id: test_connection_btn
text: 'Test Server Connection'
background_color: 0.2, 0.4, 0.8, 1
font_size: sp(13)
on_press: root.test_connection()
Button:
id: production_mode_btn
text: 'Enable Production'
background_color: 0.4, 0.4, 0.4, 1 # grey = disabled
font_size: sp(13)
on_press: root.toggle_production_mode()
# Connection Status Label
Label:
id: connection_status
text: 'Click button to test connection'
size_hint_y: None
height: dp(32)
text_size: self.size
halign: 'center'
valign: 'middle'
font_size: sp(11)
color: 0.7, 0.7, 0.7, 1
# Separator
Widget:
size_hint_y: None
height: dp(5)
# Status information row
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(26)
spacing: dp(8)
Label:
id: playlist_info
text: 'Playlist: N/A'
text_size: self.size
halign: 'center'
valign: 'middle'
font_size: sp(11)
Label:
id: media_count_info
text: 'Media: 0'
text_size: self.size
halign: 'center'
valign: 'middle'
font_size: sp(11)
Label:
id: status_info
text: 'Status: Idle'
text_size: self.size
halign: 'center'
valign: 'middle'
font_size: sp(11)
# Action buttons (always visible, outside scroll)
BoxLayout: BoxLayout:
orientation: 'horizontal' orientation: 'horizontal'
size_hint_y: None size_hint_y: None
height: dp(40) height: dp(44)
spacing: dp(10) spacing: dp(15)
Label:
text: 'Server IP:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: server_input
size_hint_x: 0.7
multiline: False
font_size: sp(14)
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Server port
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Port:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: port_input
size_hint_x: 0.7
multiline: False
font_size: sp(14)
hint_text: '80 or 8080 (leave empty for default)'
input_filter: 'int'
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Screen name
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Screen Name:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: screen_input
size_hint_x: 0.7
multiline: False
font_size: sp(14)
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Quickconnect key
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Quickconnect:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: quickconnect_input
size_hint_x: 0.7
multiline: False
font_size: sp(14)
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Orientation
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Orientation:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: orientation_input
size_hint_x: 0.7
multiline: False
font_size: sp(14)
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Touch
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Touch:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: touch_input
size_hint_x: 0.7
multiline: False
font_size: sp(14)
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Resolution
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Max Resolution:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
TextInput:
id: resolution_input
size_hint_x: 0.7
multiline: False
font_size: sp(14)
hint_text: '1920x1080 or auto'
write_tab: False
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
# Edit Feature Enable/Disable
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(10)
Label:
text: 'Enable Edit Feature:'
size_hint_x: 0.3
text_size: self.size
halign: 'left'
valign: 'middle'
CheckBox:
id: edit_enabled_checkbox
size_hint_x: None
width: dp(40)
active: True
on_active: root.on_edit_feature_toggle(self.active)
Label:
text: '(Allow editing images on this player)'
size_hint_x: 0.4
font_size: sp(12)
text_size: self.size
halign: 'left'
valign: 'middle'
color: 0.7, 0.7, 0.7, 1
Widget:
size_hint_y: 0.05
# Reset Buttons Section
Label:
text: 'Reset Options:'
size_hint_y: None
height: dp(30)
text_size: self.size
halign: 'left'
valign: 'middle'
bold: True
font_size: sp(16)
# Reset Buttons Row
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(50)
spacing: dp(10)
Button:
id: reset_auth_btn
text: 'Reset Player Auth'
background_color: 0.8, 0.4, 0.2, 1
on_press: root.reset_player_auth()
Button:
id: reset_playlist_btn
text: 'Reset Playlist to v0'
background_color: 0.8, 0.4, 0.2, 1
on_press: root.reset_playlist_version()
Button:
id: restart_player_btn
text: 'Restart Player'
background_color: 0.2, 0.6, 0.8, 1
on_press: root.restart_player()
# Test Connection Button
Button:
id: test_connection_btn
text: 'Test Server Connection'
size_hint_y: None
height: dp(50)
background_color: 0.2, 0.4, 0.8, 1
on_press: root.test_connection()
# Connection Status Label
Label:
id: connection_status
text: 'Click button to test connection'
size_hint_y: None
height: dp(40)
text_size: self.size
halign: 'center'
valign: 'middle'
color: 0.7, 0.7, 0.7, 1
Widget:
size_hint_y: 0.05
# Status information row
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(30)
spacing: dp(10)
Label:
id: playlist_info
text: 'Playlist: N/A'
text_size: self.size
halign: 'center'
valign: 'middle'
font_size: sp(12)
Label:
id: media_count_info
text: 'Media: 0'
text_size: self.size
halign: 'center'
valign: 'middle'
font_size: sp(12)
Label:
id: status_info
text: 'Status: Idle'
text_size: self.size
halign: 'center'
valign: 'middle'
font_size: sp(12)
Widget:
size_hint_y: 0.05
# Action buttons
BoxLayout:
orientation: 'horizontal'
size_hint_y: None
height: dp(50)
spacing: dp(20)
Button: Button:
text: 'Save & Close' text: 'Save & Close'
File diff suppressed because it is too large Load Diff
+34
View File
@@ -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)
+204
View File
@@ -0,0 +1,204 @@
# 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.
## ⚙️ Configuration
1. On first run, config files are created in the **same folder as the executable** (not in `%APPDATA%`)
- The .exe creates: `config/`, `media/`, `playlists/`, `logs/` directories locally
- This allows you to copy the entire `dist\KiwySignagePlayer\` folder anywhere and it works
2. Edit `config\app_config.json` (next to the .exe) to set your server:
```json
{
"server_ip": "192.168.0.109",
"port": "8080",
"screen_name": "Birou_IT",
"quickconnect_key": "8887779",
"orientation": "Landscape",
"touch": "True",
"max_resolution": "1920x1080",
"edit_feature_enabled": true,
"use_https": false,
"verify_ssl": false
}
```
## 💳 Card Reader (Windows Edition)
The card reader now works on Windows via the **Raw Input API** (with a
low-level keyboard-hook fallback). It replaces the Linux-only `evdev`
implementation automatically when `run_win.py` starts.
- Detection mirrors the Linux logic:
1. A device named with `card` / `reader` / `rfid`
2. A USB HID keyboard (non-PS/2) — most card readers enumerate this way
3. Any remaining keyboard (excluding touchscreens/mice)
- Only keystrokes from the **selected device** are captured, so the
operator's real keyboard cannot pollute card data.
- Card data ends on **Enter** (same as Linux).
### Card reader config (optional)
Add any of these to `config\app_config.json` next to the .exe:
```json
{
"card_reader_mode": "auto", // "auto" | "raw" | "hook"
"card_reader_device": "", // e.g. "VID_08FF" to force a specific device
"card_reader_timeout": 5 // seconds
}
```
- `card_reader_mode`: `auto` (default, tries Raw Input then falls back),
`raw` (force Raw Input), or `hook` (force the low-level keyboard hook).
- `card_reader_device`: optional substring of the device name to pin the
reader (e.g. `VID_08FF`, `HID#VID_08FF`). Run the manual test below to see
the exact device names on your host.
- `card_reader_timeout`: how long the swipe popup waits (default 5 s).
### Manual card reader test (no GUI)
```batch
cd windows
venv\Scripts\activate
python win_card_reader.py
```
Swipe a card within 10 seconds — the tool prints the captured data, then
exits. The detected devices are listed in the console/log.
## 🧪 Testing
```batch
cd windows
venv\Scripts\activate
python run_win.py
```
## 🔧 Troubleshooting
| Problem | Solution |
|---------|----------|
| **"ffpyplayer not found"** | Install: `pip install ffpyplayer` |
| **"No video" / black screen** | Install FFmpeg and add to PATH. Try `KIVY_GL_BACKEND=angle_sdl2` or `KIVY_GL_BACKEND=gl` |
| **Kivy window doesn't open** | Run from command prompt to see error messages. Ensure GPU drivers are up to date. |
| **Weblinks not opening** | Install Google Chrome or Microsoft Edge |
| **Can't connect to server** | Check firewall. Try `use_https: false` and `verify_ssl: false` for testing |
| **Antivirus flags .exe** | Add the output folder to antivirus exclusions. This is a false positive common with PyInstaller. |
## 📁 Project Structure (Build)
```
Kiwy-Signage/
├── windows/
│ ├── run_win.py # Windows entry point (patches platform differences)
│ ├── build.spec # PyInstaller configuration
│ ├── build_win.bat # One-click build script
│ ├── pyi_runtime_hook.py # PyInstaller runtime hook
│ ├── requirements_win.txt # Windows Python dependencies
│ └── README_WINDOWS_BUILD.md # This file
├── src/
│ ├── main.py # Main application (original)
│ ├── get_playlists_v2.py # Playlist sync
│ ├── player_auth.py # Authentication
│ ├── ssl_utils.py # SSL/HTTPS
│ ├── keyboard_widget.py # On-screen keyboard
│ ├── network_monitor.py # Network monitoring
│ ├── edit_popup.py # Image editing
│ └── signage_player.kv # Kivy UI layout
├── config/
│ ├── app_config.json # Player configuration
│ └── resources/ # Icons, images, intro video
├── media/ # Downloaded media (created at runtime)
├── playlists/ # Playlist files (created at runtime)
└── logs/ # Log files (created at runtime)
```
+18
View File
@@ -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

+317
View File
@@ -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'
+307
View File
@@ -0,0 +1,307 @@
# -*- 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',
'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)
config_data = []
config_file = CONFIG_DIR / 'app_config.json'
if config_file.exists():
config_data.append((str(config_file), 'config'))
# 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
source_tree = Tree(str(SRC_DIR), prefix='', excludes=['*.pyc', '__pycache__', '*.ini'])
# --- 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,
hiddenimports=hidden_imports,
hookspath=[],
hooksconfig={},
runtime_hooks=[str(BUILD_DIR / 'pyi_runtime_hook.py')],
excludes=excluded_imports,
noarchive=False,
module_collection_mode={
'kivy': 'pyz',
'kivy.core': 'pyz',
'kivy.uix': 'pyz',
'kivy.graphics': 'pyz',
},
)
# Add the source tree (main.py, etc.)
a.datas += source_tree
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='KiwySignagePlayer',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True, # Show console for debugging startup errors
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=str(BUILD_DIR / 'app_icon.ico') if (BUILD_DIR / 'app_icon.ico').exists() else None,
version=str(BUILD_DIR / 'version_info.txt') if (BUILD_DIR / 'version_info.txt').exists() else None,
)
# --- COLLECT everything into a single folder -------------------------
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='KiwySignagePlayer',
)
+5
View File
@@ -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
+158
View File
@@ -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
+192
View File
@@ -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)
+110
View File
@@ -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
+317
View File
@@ -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
+27
View File
@@ -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
+164
View File
@@ -0,0 +1,164 @@
"""
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/config files to the local folders on first run."""
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/app_config.json', 'config/app_config.json'),
('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
+39
View File
@@ -0,0 +1,39 @@
# =====================================================================
# 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
# --- 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
+1931
View File
File diff suppressed because it is too large Load Diff
+156
View File
@@ -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
}
+39
View File
@@ -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)
+89
View File
@@ -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()
+43
View File
@@ -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])])
]
)
+720
View File
@@ -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.")
View File
+107
View File
@@ -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)
View File
+81
View File
@@ -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
}