Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e880421c9 | |||
| 5d9aa02c07 | |||
| a0704efa3c | |||
| 6dc79828bc | |||
| a19627885c |
@@ -1,21 +1,22 @@
|
||||
# Web Link Playlist Items — Player Integration Guide
|
||||
|
||||
This document describes the changes required on the **Kiwy-Signage player**
|
||||
(<https://gitea.moto-adv.com/ske087/Kiwy-Signage.git>) to support a new
|
||||
playlist item type: **`weblink`** (display a live web page / URL instead of an
|
||||
uploaded media file).
|
||||
This document describes how the **Kiwy-Signage player**
|
||||
(<https://gitea.moto-adv.com/ske087/Kiwy-Signage.git>) supports the **`weblink`**
|
||||
playlist item type (display a live web page / URL instead of an uploaded media
|
||||
file).
|
||||
|
||||
> The DigiServer (this repo, `digiserver-v2`) side will be updated to emit
|
||||
> `weblink` items in the playlist API. The player does **not** yet support them.
|
||||
> Use this guide to implement the player side later.
|
||||
> **Status: implemented.** The player supports `weblink` items on both
|
||||
> Raspberry Pi (`chromium` subprocess) and Windows (embedded CEF with a
|
||||
> Chrome/Edge subprocess fallback). Sections 1–4 describe the original design
|
||||
> plan; section 6 documents the shipped architecture and the interaction model.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background — how items flow today
|
||||
## 1. Background — how items flow
|
||||
|
||||
```
|
||||
DigiServer API ──JSON──▶ player sync (get_playlists_v2.py) ──▶ playlist.json ──▶ main.py renders
|
||||
/api/playlists downloads files to media/ by file extension
|
||||
/api/playlists downloads files to media/ by item type
|
||||
```
|
||||
|
||||
Each playlist item the server returns currently looks like:
|
||||
@@ -243,3 +244,98 @@ Recommended options, in order of robustness:
|
||||
depth).
|
||||
- Consider running Chromium with `--incognito` (no persistent cookies/cache) as
|
||||
shown above.
|
||||
|
||||
---
|
||||
|
||||
## 6. Shipped architecture (`src/weblink_session.py`)
|
||||
|
||||
The player-side implementation lives in **one** module, so launch, verification,
|
||||
timing and teardown have a single owner instead of being duplicated per
|
||||
platform:
|
||||
|
||||
| Piece | Responsibility |
|
||||
|--------------------------|----------------|
|
||||
| `WeblinkSession` | Owns one weblink item: validate → launch → verify → watch → teardown. Generation-tokened so stale callbacks are ignored, and `atexit`-safe. |
|
||||
| `WeblinkAdapter` | The only platform-specific part: launch / wait for the window / is it alive / tear it down / pre-warm. |
|
||||
| `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`.
|
||||
|
||||
+15
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"server_ip": "192.168.0.107",
|
||||
"server_ip": "192.168.0.108",
|
||||
"port": "8080",
|
||||
"screen_name": "WINDOWS-PC",
|
||||
"quickconnect_key": "8887779",
|
||||
@@ -8,5 +8,18 @@
|
||||
"max_resolution": "1920x1080",
|
||||
"edit_feature_enabled": true,
|
||||
"use_https": false,
|
||||
"verify_ssl": false
|
||||
"verify_ssl": false,
|
||||
"card_reader_mode": "auto",
|
||||
"card_reader_device": "",
|
||||
"card_reader_timeout": 5,
|
||||
"weblink": {
|
||||
"engine": "auto",
|
||||
"interaction_postpone": 10,
|
||||
"interaction_debounce": 0.5,
|
||||
"interaction_grace": 5.0,
|
||||
"max_dwell_factor": 6.0,
|
||||
"min_max_dwell": 300,
|
||||
"launch_timeout": 15,
|
||||
"prewarm": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
# Kiwy Signage Player — Code Signing & Smart App Control (Production)
|
||||
|
||||
> **TL;DR:** If production PCs have **Smart App Control (SAC) ON** and you
|
||||
> cannot disable it, the player `.exe` **must be signed by a certificate from a
|
||||
> reputable public CA**. There is no other way — SAC blocks unsigned binaries at
|
||||
> the kernel level (no "Run anyway" button). Self-signed certs and Defender
|
||||
> exclusions do **not** satisfy SAC.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why Smart App Control blocks the app
|
||||
|
||||
- SAC (Windows 11 22H2+, "Smart App Control" in **Windows Security → App &
|
||||
browser control**) only runs apps that are **signed by a reputable publisher**.
|
||||
- Your locally-built `KiwySignagePlayer.exe` is **unsigned**
|
||||
(`Get-AuthenticodeSignature` → `NotSigned`), so SAC refuses to launch it and
|
||||
shows "An Application Control policy has blocked this file."
|
||||
- Unlike classic SmartScreen, SAC has **no "Run anyway" button** and cannot be
|
||||
bypassed per-file. Disabling SAC is **permanent** and only possible with admin
|
||||
rights — so it is not viable for locked-down production PCs.
|
||||
|
||||
---
|
||||
|
||||
## 2. The solution for production: a real code-signing certificate
|
||||
|
||||
1. **Buy an OV code-signing certificate** from a reputable CA, e.g.:
|
||||
- Sectigo Code Signing
|
||||
- SSL.com Code Signing
|
||||
- DigiCert Code Signing
|
||||
- GlobalSign Code Signing
|
||||
OV is sufficient for SAC; EV gives the highest trust level. Cost is roughly
|
||||
USD 100–300/yr. The CA will issue a `.pfx`/`.p12` (or `.cer`+key).
|
||||
|
||||
2. **Sign the exe** after each build. Place your pfx at
|
||||
`windows\kiwy_signing.pfx` (or set `KIWY_SIGN_PFX` env var) — `build_win.bat`
|
||||
will then auto-sign via `sign_exe.ps1`:
|
||||
|
||||
```powershell
|
||||
# One-off, from the windows\ folder:
|
||||
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -CertPassword "yourpwd"
|
||||
```
|
||||
|
||||
The script:
|
||||
- locates `signtool.exe` (Windows SDK) — install with
|
||||
`winget install Microsoft.WindowsSDK.10.0.26100` if missing,
|
||||
- signs with **SHA256** + **RFC3161 timestamp** (required for SAC and to
|
||||
keep the signature valid after the cert expires),
|
||||
- verifies the result with `Get-AuthenticodeSignature`.
|
||||
|
||||
3. **Test** — confirm on one production PC:
|
||||
```powershell
|
||||
Get-AuthenticodeSignature "dist\KiwySignagePlayer\KiwySignagePlayer.exe"
|
||||
# Status must be: Valid
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Dev / test machines (where you have admin rights)
|
||||
|
||||
If a test PC has SAC **off**, you can make the app trusted locally without
|
||||
buying a cert:
|
||||
|
||||
```powershell
|
||||
# Run as Administrator
|
||||
.\create_self_signed_cert.ps1
|
||||
```
|
||||
|
||||
This creates a self-signed code-signing cert, exports `kiwy_dev_signing.pfx`,
|
||||
and installs it into **Trusted Root + Trusted Publisher + Trusted People** for
|
||||
the current user, so the player runs without SmartScreen/Defender prompts on
|
||||
that dev PC.
|
||||
|
||||
⚠️ **This does NOT satisfy SAC.** It is only for machines where SAC is off or
|
||||
where you have admin rights.
|
||||
|
||||
---
|
||||
|
||||
## 4. Build → sign → verify workflow
|
||||
|
||||
```bat
|
||||
:: 1. Build (produces dist\KiwySignagePlayer\KiwySignagePlayer.exe)
|
||||
cd windows
|
||||
venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||
|
||||
:: 2. Sign (auto if kiwy_signing.pfx present, else manual)
|
||||
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -CertPassword "..."
|
||||
|
||||
:: 3. Verify
|
||||
Get-AuthenticodeSignature "dist\KiwySignagePlayer\KiwySignagePlayer.exe"
|
||||
```
|
||||
|
||||
`build_win.bat` now does step 1 + step 2 automatically when a pfx is present.
|
||||
|
||||
---
|
||||
|
||||
## 5. Important caveats
|
||||
|
||||
- **Timestamping is mandatory.** The sign script timestamps by default
|
||||
(`http://timestamp.digicert.com`). Without a timestamp, the signature becomes
|
||||
invalid once the certificate expires and SAC will block the app.
|
||||
- **SAC reputation takes time.** Even a validly signed exe from a brand-new
|
||||
certificate may be blocked until the CA's reputation builds. EV certificates
|
||||
and well-known CAs (DigiCert, Sectigo, SSL.com) pass immediately.
|
||||
- **Re-sign after every build.** PyInstaller creates a new exe each time; the
|
||||
old signature is lost. The auto-sign step in `build_win.bat` handles this.
|
||||
- **Do not use UPX** on the signed exe — it invalidates the signature and can
|
||||
trigger false positives. (`upx=True` in the spec currently does nothing
|
||||
because UPX is not installed; if you ever install UPX, set it to False.)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,37 +1,81 @@
|
||||
{
|
||||
"count": 4,
|
||||
"player_id": 1,
|
||||
"player_name": "Test_player1",
|
||||
"count": 6,
|
||||
"player_id": 2,
|
||||
"player_name": "Windows-Player1",
|
||||
"playlist": [
|
||||
{
|
||||
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
||||
"type": "image",
|
||||
"url": "media/sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
||||
"duration": 14,
|
||||
"edit_on_player": true
|
||||
},
|
||||
{
|
||||
"file_name": "weblink-1e4a4d6d885a",
|
||||
"type": "weblink",
|
||||
"url": "https://moto-adv.com/",
|
||||
"audio": "off",
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"edit_on_player": false
|
||||
},
|
||||
{
|
||||
"file_name": "sample-30s.mp4",
|
||||
"type": "video",
|
||||
"url": "media/sample-30s.mp4",
|
||||
"duration": 31,
|
||||
"edit_on_player": false
|
||||
},
|
||||
{
|
||||
"file_name": "edited_media/5/eye_e_v2.jpg",
|
||||
"edit_on_player": false,
|
||||
"file_name": "anders-jilden-cYrMQA7a3Wc-unsplash.jpg",
|
||||
"id": 9,
|
||||
"muted": true,
|
||||
"position": 1,
|
||||
"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,
|
||||
"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_version": 14
|
||||
"playlist_version": 32
|
||||
}
|
||||
+75
-13
@@ -82,11 +82,17 @@ class DrawingLayer(Widget):
|
||||
|
||||
class EditPopup(Popup):
|
||||
"""Popup for editing/annotating images"""
|
||||
def __init__(self, player_instance, image_path, user_card_data=None, **kwargs):
|
||||
def __init__(self, player_instance, image_path, user_card_data=None,
|
||||
media_id=None, original_filename=None, **kwargs):
|
||||
super(EditPopup, self).__init__(**kwargs)
|
||||
self.player = player_instance
|
||||
self.image_path = image_path
|
||||
self.user_card_data = user_card_data # Store card data to send to server on save
|
||||
# Server naming context: which media item (id) is being edited and what
|
||||
# its original file name is on the server. The server stores edited
|
||||
# media under 'edited_media/<media_id>/', so we must reproduce that.
|
||||
self.media_id = media_id
|
||||
self.original_filename = original_filename # server-side file_name
|
||||
|
||||
# Auto-close timer (5 minutes)
|
||||
self.auto_close_timeout = 300 # 5 minutes in seconds
|
||||
@@ -259,8 +265,15 @@ class EditPopup(Popup):
|
||||
def save_image(self, instance):
|
||||
"""Save the edited image"""
|
||||
try:
|
||||
# Create edited_media directory if it doesn't exist
|
||||
edited_dir = os.path.join(self.player.base_dir, 'media', 'edited_media')
|
||||
# Edited media is stored on the server under
|
||||
# 'edited_media/<media_id>/'. Reproduce that subfolder locally so
|
||||
# the upload naming matches what the server expects. Fall back to
|
||||
# the flat 'edited_media/' folder when no media_id is available.
|
||||
edited_base = os.path.join(self.player.base_dir, 'media', 'edited_media')
|
||||
if self.media_id is not None:
|
||||
edited_dir = os.path.join(edited_base, str(self.media_id))
|
||||
else:
|
||||
edited_dir = edited_base
|
||||
os.makedirs(edited_dir, exist_ok=True)
|
||||
|
||||
# Get original filename
|
||||
@@ -310,8 +323,22 @@ class EditPopup(Popup):
|
||||
# Overwrite the file
|
||||
shutil.copy2(output_path, self.image_path)
|
||||
|
||||
# Force file system sync to ensure data is written to disk
|
||||
os.sync()
|
||||
# Force file system sync to ensure data is written to disk.
|
||||
# NOTE: os.sync() is Linux-only and raises AttributeError on
|
||||
# Windows — that used to abort the whole pipeline before the
|
||||
# metadata/upload steps. Use a cross-platform fsync that is
|
||||
# best-effort and can never break the save/upload flow.
|
||||
try:
|
||||
if hasattr(os, 'sync'):
|
||||
os.sync()
|
||||
else:
|
||||
with open(output_path, 'rb') as _f:
|
||||
try:
|
||||
os.fsync(_f.fileno())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _sync_err:
|
||||
Logger.warning(f"EditPopup: File sync skipped ({_sync_err})")
|
||||
|
||||
# Verify the overwrite
|
||||
new_size = os.path.getsize(self.image_path)
|
||||
@@ -326,9 +353,15 @@ class EditPopup(Popup):
|
||||
self.ids.top_toolbar.opacity = 1
|
||||
self.ids.right_sidebar.opacity = 1
|
||||
|
||||
# Create and save metadata
|
||||
json_filename = self._save_metadata(edited_dir, new_name, base_name,
|
||||
new_version if version_match else 1, output_filename)
|
||||
# Create and save metadata. This runs in its own guarded
|
||||
# block so that a failure here cannot silently stop the
|
||||
# upload — the two steps are intentionally decoupled.
|
||||
json_filename = None
|
||||
try:
|
||||
json_filename = self._save_metadata(edited_dir, new_name, base_name,
|
||||
new_version if version_match else 1, output_filename)
|
||||
except Exception as meta_err:
|
||||
Logger.error(f"EditPopup: Metadata save failed: {meta_err}")
|
||||
|
||||
# Upload to server in background (continues after popup closes)
|
||||
upload_thread = threading.Thread(
|
||||
@@ -414,6 +447,14 @@ class EditPopup(Popup):
|
||||
'version': version,
|
||||
'user_card_data': self.user_card_data # Card data from reader (or None)
|
||||
}
|
||||
# Include the server-side file name and media id so the server can
|
||||
# attach the edit to the correct media item.
|
||||
if self.original_filename:
|
||||
metadata['original_filename'] = self.original_filename
|
||||
else:
|
||||
metadata['original_filename'] = os.path.basename(self.image_path)
|
||||
if self.media_id is not None:
|
||||
metadata['media_id'] = self.media_id
|
||||
|
||||
# Save metadata JSON
|
||||
json_filename = f"{new_name}_metadata.json"
|
||||
@@ -444,16 +485,37 @@ class EditPopup(Popup):
|
||||
Logger.warning("EditPopup: Missing server URL or auth code (upload skipped)")
|
||||
return False
|
||||
|
||||
# Load metadata from file
|
||||
with open(metadata_path, 'r') as meta_file:
|
||||
metadata = json.load(meta_file)
|
||||
# Load metadata from file (or build it in memory if the metadata
|
||||
# file was not written — the upload must still go through).
|
||||
metadata = None
|
||||
if metadata_path and os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, 'r') as meta_file:
|
||||
metadata = json.load(meta_file)
|
||||
except Exception as e:
|
||||
Logger.warning(f"EditPopup: Could not read metadata file: {e}")
|
||||
if not metadata:
|
||||
metadata = {
|
||||
'time_of_modification': datetime.now().isoformat(),
|
||||
'original_name': os.path.basename(image_path),
|
||||
'new_name': os.path.basename(image_path),
|
||||
'version': 1,
|
||||
'user_card_data': self.user_card_data,
|
||||
}
|
||||
if self.original_filename:
|
||||
metadata['original_filename'] = self.original_filename
|
||||
if self.media_id is not None:
|
||||
metadata['media_id'] = self.media_id
|
||||
|
||||
# Prepare upload URL - send to the original file endpoint
|
||||
upload_url = f"{server_url}/api/player-edit-media"
|
||||
headers = {'Authorization': f'Bearer {auth_code}'}
|
||||
|
||||
# Add the original filename to metadata so server knows which file was edited
|
||||
metadata['original_filename'] = os.path.basename(metadata['original_path'])
|
||||
# Ensure the original filename (server-side name) is present so the
|
||||
# server knows which file was edited. Prefer the media context we
|
||||
# captured when the edit popup opened.
|
||||
if not metadata.get('original_filename'):
|
||||
metadata['original_filename'] = os.path.basename(metadata.get('original_path', image_path))
|
||||
|
||||
# Disable SSL verification for self-signed certificates (like main code does)
|
||||
# Note: This is NOT recommended for production with untrusted servers
|
||||
|
||||
+12
-15
@@ -248,13 +248,11 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None)
|
||||
# Web-link items have no file to download — pass the link through unchanged.
|
||||
if item_type == 'weblink':
|
||||
logger.info(f"🔗 Web link item (no download): {file_url}")
|
||||
updated_playlist.append({
|
||||
'file_name': file_name,
|
||||
'type': 'weblink',
|
||||
'url': file_url, # keep the original web address (not a local path)
|
||||
'duration': duration,
|
||||
'edit_on_player': False,
|
||||
})
|
||||
# Preserve every server field (audio/muted/description/id/position/...)
|
||||
# instead of rebuilding a fixed dict, so nothing is silently dropped.
|
||||
weblink_item = dict(media)
|
||||
weblink_item['type'] = 'weblink'
|
||||
updated_playlist.append(weblink_item)
|
||||
continue
|
||||
|
||||
local_path = os.path.join(media_dir, file_name)
|
||||
@@ -318,14 +316,13 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None)
|
||||
# Don't skip - may still add to playlist
|
||||
|
||||
# Always add the media item to the playlist, even if download failed
|
||||
# (it might already exist or be available later)
|
||||
updated_media = {
|
||||
'file_name': file_name,
|
||||
'type': item_type, # Preserve media type (image/video/...)
|
||||
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
||||
'duration': duration,
|
||||
'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag
|
||||
}
|
||||
# (it might already exist or be available later).
|
||||
# Preserve EVERY server field (audio/muted/description/id/position/...)
|
||||
# by copying the original dict and only overriding the URL with the
|
||||
# local path — previously the fixed dict below dropped `audio`, `muted`,
|
||||
# `description`, `id` and `position` from the saved playlist.
|
||||
updated_media = dict(media)
|
||||
updated_media['url'] = os.path.relpath(local_path, os.path.dirname(media_dir))
|
||||
updated_playlist.append(updated_media)
|
||||
|
||||
return updated_playlist
|
||||
|
||||
+485
-362
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"hostname": "WINDOWS-PC",
|
||||
"auth_code": "",
|
||||
"auth_code": "GAh_D0qrgRGS0ERK-AQN0Fx17HvEmV9aA7EHXzszYO4",
|
||||
"player_id": 2,
|
||||
"player_name": "Windows-Player1",
|
||||
"playlist_id": 1,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ The original app was built for **Raspberry Pi (Linux)**, using these technologie
|
||||
|-----------|---------------------|-------------------|
|
||||
| **GUI** | Kivy 2.3+ | Kivy 2.3+ (works cross-platform) |
|
||||
| **Video** | ffpyplayer | ffpyplayer (needs FFmpeg DLLs) |
|
||||
| **Card Reader** | evdev (Linux input) | **Not available** — gracefully disabled |
|
||||
| **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 |
|
||||
@@ -27,9 +27,9 @@ The original app was built for **Raspberry Pi (Linux)**, using these technologie
|
||||
- ✅ 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
|
||||
- ❌ Card reader (evdev is Linux-only; `EVDEV_AVAILABLE = False`)
|
||||
- ❌ HDMI power management (tvservice is RPi-specific)
|
||||
- ❌ WiFi restart (uses Linux `nmcli`)
|
||||
|
||||
@@ -112,6 +112,50 @@ For a **single-file .exe**, edit `build.spec` — uncomment the `exe_onefile` se
|
||||
}
|
||||
```
|
||||
|
||||
## 💳 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
|
||||
|
||||
@@ -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'
|
||||
@@ -105,6 +105,11 @@ hidden_imports = [
|
||||
'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
|
||||
|
||||
@@ -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
|
||||
@@ -107,6 +107,38 @@ if %ERRORLEVEL% neq 0 (
|
||||
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 ============================================
|
||||
|
||||
@@ -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
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 📅 Current Session — 2026-07-31
|
||||
## 📅 Current Session — 2026-08-07
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
@@ -14,9 +14,37 @@
|
||||
| **Python** | 3.12.9 — `windows\venv\` (250 MB, all deps installed) |
|
||||
| **Kivy** | 2.3.1 |
|
||||
| **PyInstaller** | 6.21.0 |
|
||||
| **Last .exe build** | 2026-07-26 16:53 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) |
|
||||
| **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
|
||||
|
||||
@@ -10,6 +10,37 @@ 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.
|
||||
@@ -23,6 +54,8 @@ 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.
|
||||
|
||||
+825
-363
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
<#
|
||||
================================================================================
|
||||
Kiwy Signage Player - Code Signing Script
|
||||
================================================================================
|
||||
Signs the built KiwySignagePlayer.exe with an Authenticode certificate.
|
||||
|
||||
For PRODUCTION PCs that have Smart App Control (SAC) ENABLED:
|
||||
- The cert MUST be issued by a reputable public CA (e.g. Sectigo, SSL.com,
|
||||
DigiCert, GlobalSign). Self-signed certs will NOT satisfy SAC.
|
||||
- You must use this script with -CertPath pointing at your .pfx/.p12.
|
||||
|
||||
For DEV/TEST machines where you have admin rights:
|
||||
- A self-signed cert trusted in the local Root store + Trusted Publisher
|
||||
works (see create_self_signed_cert.ps1), but it does NOT satisfy SAC.
|
||||
|
||||
Usage:
|
||||
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -CertPassword "secret"
|
||||
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" # prompt for pwd
|
||||
.\sign_exe.ps1 -CertThumbprint "A1B2..." # from cert store
|
||||
.\sign_exe.ps1 -CertPath "C:\certs\mycodesign.pfx" -SkipTimestamp $false
|
||||
|
||||
Optional:
|
||||
-TimestampUrl RFC3161 timestamp server (default DigiCert).
|
||||
Timestamping is REQUIRED for the signature to stay valid
|
||||
after the cert expires and to satisfy Smart App Control.
|
||||
-ExePath Path to the exe to sign (default dist\KiwySignagePlayer\KiwySignagePlayer.exe)
|
||||
-Force Re-sign even if already signed
|
||||
================================================================================
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$CertPath,
|
||||
[string]$CertPassword,
|
||||
[string]$CertThumbprint,
|
||||
[string]$ExePath = (Join-Path $PSScriptRoot 'dist\KiwySignagePlayer\KiwySignagePlayer.exe'),
|
||||
[string]$TimestampUrl = 'http://timestamp.digicert.com',
|
||||
[switch]$SkipTimestamp,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
function Find-Signtool {
|
||||
$candidates = @(
|
||||
(Get-Command signtool.exe -ErrorAction SilentlyContinue).Source,
|
||||
"$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe",
|
||||
"$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe",
|
||||
"$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.22000.0\x64\signtool.exe",
|
||||
"$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.19041.0\x64\signtool.exe"
|
||||
)
|
||||
foreach ($c in $candidates) {
|
||||
if ($c -and (Test-Path $c)) { return $c }
|
||||
}
|
||||
# Fallback: newest SDK on disk
|
||||
$sdkBin = "$env:ProgramFiles(x86)\Windows Kits\10\bin"
|
||||
if (Test-Path $sdkBin) {
|
||||
$found = Get-ChildItem $sdkBin -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue |
|
||||
Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName
|
||||
if ($found) { return $found }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$signtool = Find-Signtool
|
||||
if ($signtool) {
|
||||
Write-Host "[INFO ] signtool: $signtool" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[WARN ] signtool.exe not found - will use PowerShell Set-AuthenticodeSignature fallback." -ForegroundColor Yellow
|
||||
Write-Host "[WARN ] NOTE: the fallback cannot apply an RFC3161 timestamp. For production (SAC),"
|
||||
Write-Host "[WARN ] install the Windows SDK signtool: winget install Microsoft.WindowsSDK.10.0.26100"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $ExePath)) {
|
||||
Write-Host "[ERROR] Exe not found: $ExePath" -ForegroundColor Red
|
||||
Write-Host "Run build_win.bat first, or pass -ExePath."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Already signed?
|
||||
$sig = Get-AuthenticodeSignature -FilePath $ExePath
|
||||
if ($sig.Status -eq 'Valid' -and -not $Force) {
|
||||
Write-Host "[INFO ] Exe is already validly signed by: $($sig.SignerCertificate.Subject)" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Load the certificate ────────────────────────────────────────────
|
||||
$cert = $null
|
||||
if ($CertThumbprint) {
|
||||
$cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Thumbprint -eq $CertThumbprint } | Select-Object -First 1
|
||||
if (-not $cert) {
|
||||
Write-Host "[ERROR] No certificate with thumbprint $CertThumbprint in My store." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif ($CertPath) {
|
||||
if (-not (Test-Path $CertPath)) {
|
||||
Write-Host "[ERROR] Cert file not found: $CertPath" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if (-not $CertPassword) {
|
||||
$secure = Read-Host "Certificate password for $CertPath" -AsSecureString
|
||||
$CertPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
||||
[System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
|
||||
}
|
||||
$securePwd = ConvertTo-SecureString -String $CertPassword -Force -AsPlainText
|
||||
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertPath, $securePwd)
|
||||
} else {
|
||||
Write-Host "[ERROR] Provide -CertPath or -CertThumbprint." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Sign (signtool preferred, PowerShell fallback) ──────────────────
|
||||
if ($signtool) {
|
||||
$args = @()
|
||||
if ($CertThumbprint) {
|
||||
$args = @('sign', '/sha1', $CertThumbprint, '/fd', 'SHA256')
|
||||
} else {
|
||||
$args = @('sign', '/f', $CertPath, '/p', $CertPassword, '/fd', 'SHA256')
|
||||
}
|
||||
if (-not $SkipTimestamp) {
|
||||
$args += @('/tr', $TimestampUrl, '/td', 'SHA256')
|
||||
}
|
||||
$args += @('"' + $ExePath + '"')
|
||||
$cmd = "& `"$signtool`" " + ($args -join ' ')
|
||||
Write-Host "[INFO ] Signing with signtool..." -ForegroundColor Cyan
|
||||
Write-Host "[CMD ] $cmd"
|
||||
Invoke-Expression $cmd
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "[ERROR] signtool failed with exit code $LASTEXITCODE" -ForegroundColor Red
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
} else {
|
||||
Write-Host "[INFO ] Signing with PowerShell Set-AuthenticodeSignature (no timestamp)..." -ForegroundColor Cyan
|
||||
if (-not $cert.HasPrivateKey) {
|
||||
Write-Host "[ERROR] Certificate has no private key - cannot sign." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$sig = Set-AuthenticodeSignature -FilePath $ExePath -Certificate $cert -HashAlgorithm SHA256
|
||||
if ($sig.Status -notin @('Valid','UnknownError')) {
|
||||
Write-Host "[ERROR] Signing failed: $($sig.StatusMessage)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Verify
|
||||
$sig = Get-AuthenticodeSignature -FilePath $ExePath
|
||||
Write-Host ""
|
||||
Write-Host "[INFO ] Signature status: $($sig.Status)" -ForegroundColor Green
|
||||
Write-Host "[INFO ] Signer: $($sig.SignerCertificate.Subject)" -ForegroundColor Green
|
||||
if ($sig.Status -eq 'Valid') {
|
||||
Write-Host "[OK ] KiwySignagePlayer.exe is now digitally signed." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[WARN ] Signature status is '$($sig.Status)' - inspect above." -ForegroundColor Yellow
|
||||
Write-Host "[WARN ] If no timestamp was applied, SAC may still block after cert expiry." -ForegroundColor Yellow
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rigorous verification: is the fixed SendInput code in the compiled run_win?"""
|
||||
import marshal
|
||||
import types
|
||||
import dis
|
||||
from PyInstaller.archive.readers import CArchiveReader
|
||||
|
||||
EXE = r'dist\KiwySignagePlayer\KiwySignagePlayer.exe'
|
||||
|
||||
|
||||
def collect_strings(code, acc):
|
||||
for c in code.co_consts:
|
||||
if isinstance(c, str):
|
||||
acc.append(c)
|
||||
elif isinstance(c, types.CodeType):
|
||||
collect_strings(c, acc)
|
||||
|
||||
|
||||
def collect_codes(code, acc):
|
||||
acc.append(code)
|
||||
for c in code.co_consts:
|
||||
if isinstance(c, types.CodeType):
|
||||
collect_codes(c, acc)
|
||||
|
||||
|
||||
def main():
|
||||
arc = CArchiveReader(EXE)
|
||||
data = arc.extract('run_win')
|
||||
code = marshal.loads(data)
|
||||
|
||||
all_codes = []
|
||||
collect_codes(code, all_codes)
|
||||
strings = []
|
||||
collect_strings(code, strings)
|
||||
|
||||
# 1) c_ulonglong can ONLY come from the fixed code (old used c_ulong + POINTER)
|
||||
has_c_ulonglong = any('c_ulonglong' in c.co_names for c in all_codes)
|
||||
print(f'c_ulonglong in co_names of any code object: {has_c_ulonglong}')
|
||||
|
||||
# 2) INPUTUNION should be STORE_DEREF/STORE_NAME'd inside
|
||||
# _force_foreground_sendinput (classes are defined in a closure,
|
||||
# so they're stored via STORE_DEREF).
|
||||
store_names = set()
|
||||
for c in all_codes:
|
||||
for instr in dis.get_instructions(c):
|
||||
if instr.opname in ('STORE_NAME', 'STORE_FAST', 'STORE_DEREF'):
|
||||
store_names.add(instr.argval)
|
||||
print(f'INPUTUNION stored: {"INPUTUNION" in store_names}')
|
||||
print(f'KEYBDINPUT stored: {"KEYBDINPUT" in store_names}')
|
||||
print(f'MOUSEINPUT stored: {"MOUSEINPUT" in store_names}')
|
||||
print(f'HARDWAREINPUT stored: {"HARDWAREINPUT" in store_names}')
|
||||
|
||||
# 3) The new code uses INPUT() + field assignment (type, u.ki.wVk, u.ki.dwFlags)
|
||||
names = set()
|
||||
for c in all_codes:
|
||||
names.update(c.co_names)
|
||||
print(f'Has "u" attribute usage: {"u" in names}')
|
||||
|
||||
# 4) string markers
|
||||
print(f'string "undersized buffer": {any("undersized buffer" in s for s in strings)}')
|
||||
print(f'string "real Win32 x64": {any("real Win32 x64" in s for s in strings)}')
|
||||
print(f'string "ULONG_PTR": {any("ULONG_PTR" in s for s in strings)}')
|
||||
|
||||
# 5) Console-window fix in _find_kivy_hwnd: cellvars/freevars + tuple consts
|
||||
find_kv = [c for c in all_codes if c.co_name == '_find_kivy_hwnd']
|
||||
cell_vars = set()
|
||||
for c in find_kv:
|
||||
cell_vars.update(c.co_cellvars)
|
||||
cell_vars.update(c.co_freevars)
|
||||
enum_cb = [c for c in all_codes
|
||||
if c.co_name == '_enum_cb' and 'SDL_CLASSES' in c.co_freevars]
|
||||
tuple_strs = set()
|
||||
for c in enum_cb:
|
||||
for x in c.co_consts:
|
||||
if isinstance(x, tuple):
|
||||
tuple_strs.update(str(v) for v in x)
|
||||
print(f'_find_kivy_hwnd cell/free vars (SDL_CLASSES/sdl_windows/our_pid): '
|
||||
f'{"SDL_CLASSES" in cell_vars and "sdl_windows" in cell_vars and "our_pid" in cell_vars}')
|
||||
print(f'console class excluded (ConsoleWindowClass in _enum_cb tuple): '
|
||||
f'{"ConsoleWindowClass" in tuple_strs}')
|
||||
|
||||
verdict = has_c_ulonglong and 'INPUTUNION' in store_names
|
||||
console_fix = ('SDL_CLASSES' in cell_vars and 'ConsoleWindowClass' in tuple_strs)
|
||||
print(f'\nVerdict SendInput: {"FIX PRESENT" if verdict else "FIX ABSENT - rebuild needed"}')
|
||||
print(f'Verdict console-hwnd: {"FIX PRESENT" if console_fix else "FIX ABSENT - rebuild needed"}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,720 @@
|
||||
"""
|
||||
Windows Card Reader (Raw Input API + LL-hook fallback)
|
||||
=======================================================
|
||||
Drop-in replacement for the Linux `CardReader` class in `src/main.py`.
|
||||
|
||||
The Linux implementation reads keystrokes from `/dev/input/event*` via
|
||||
`evdev`. On Windows there is no `/dev/input`; instead HID devices (such as
|
||||
USB card readers that emulate a keyboard) are accessed through the Win32
|
||||
**Raw Input API** (`WM_INPUT`).
|
||||
|
||||
Design
|
||||
------
|
||||
* A dedicated hidden message-only window + message pump runs on a background
|
||||
thread. It registers for Raw Input of all *keyboard* HID devices with
|
||||
`RIDEV_INPUTSINK`, so it receives `WM_INPUT` even though it never has focus.
|
||||
* Device selection mirrors the Linux priority logic:
|
||||
1. A device whose name contains "card" / "reader" / "rfid".
|
||||
2. A USB HID keyboard that is *not* the PS/2 system keyboard.
|
||||
3. Any keyboard device (excluding obvious touchscreens/mice).
|
||||
A config override `card_reader_device` (substring of the device name, e.g.
|
||||
`VID_08FF`) takes precedence.
|
||||
* While reading, only key events from the *selected* device are accepted, so a
|
||||
physical keyboard used for maintenance cannot pollute the card data.
|
||||
* Card data ends on Enter (`VK_RETURN`), mirroring the Linux behaviour.
|
||||
* If Raw Input registration fails (or config `card_reader_mode = "hook"`), it
|
||||
falls back to a low-level keyboard hook (`WH_KEYBOARD_LL`).
|
||||
|
||||
Config keys (in `config/app_config.json`):
|
||||
"card_reader_device": "VID_08FF" # substring of device name (optional)
|
||||
"card_reader_mode": "auto" # "auto" | "raw" | "hook"
|
||||
"card_reader_timeout": 5 # seconds (optional)
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes as wintypes
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
# ── Win32 constants ──────────────────────────────────────────────────────────
|
||||
WM_INPUT = 0x00FF
|
||||
WM_INPUT_DEVICE_CHANGE = 0x00FE
|
||||
WM_KEYDOWN = 0x0100
|
||||
WM_SYSKEYDOWN = 0x0104
|
||||
|
||||
RIM_TYPEMOUSE = 0
|
||||
RIM_TYPEKEYBOARD = 1
|
||||
RIM_TYPEHID = 2
|
||||
|
||||
RID_INPUT = 0x10000003
|
||||
RIDI_DEVICENAME = 0x20000007
|
||||
|
||||
RIDEV_INPUTSINK = 0x00000100
|
||||
RIDEV_DEVNOTIFY = 0x00002000
|
||||
|
||||
WH_KEYBOARD_LL = 13
|
||||
HC_ACTION = 0
|
||||
|
||||
# Virtual keys we never treat as card data
|
||||
_VK_SHIFT = 0x10
|
||||
_VK_CONTROL = 0x11
|
||||
_VK_MENU = 0x12
|
||||
_VK_CAPITAL = 0x14
|
||||
_VK_ESCAPE = 0x1B
|
||||
_VK_RETURN = 0x0D
|
||||
_VK_TAB = 0x09
|
||||
_VK_LSHIFT = 0xA0
|
||||
_VK_RSHIFT = 0xA1
|
||||
_VK_LCONTROL = 0xA2
|
||||
_VK_RCONTROL = 0xA3
|
||||
_VK_LMENU = 0xA4
|
||||
_VK_RMENU = 0xA5
|
||||
|
||||
# ── ctypes structures ───────────────────────────────────────────────────────
|
||||
# ctypes.wintypes does not export LRESULT; it is a signed pointer-sized value.
|
||||
# Use ctypes.c_long (standard ctypes workaround; fine for WNDPROC/LL-hook).
|
||||
_LRESULT = ctypes.c_long
|
||||
|
||||
_WND_PROC = ctypes.WINFUNCTYPE(
|
||||
_LRESULT, wintypes.HWND, wintypes.UINT,
|
||||
wintypes.WPARAM, wintypes.LPARAM,
|
||||
)
|
||||
_LL_KEYBOARD_PROC = ctypes.WINFUNCTYPE(
|
||||
_LRESULT, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM,
|
||||
)
|
||||
|
||||
|
||||
class _WNDCLASSW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('style', wintypes.UINT),
|
||||
('lpfnWndProc', _WND_PROC),
|
||||
('cbClsExtra', ctypes.c_int),
|
||||
('cbWndExtra', ctypes.c_int),
|
||||
('hInstance', wintypes.HINSTANCE),
|
||||
('hIcon', wintypes.HICON),
|
||||
('hCursor', ctypes.c_void_p), # HCURSOR (not exported by wintypes)
|
||||
('hbrBackground', wintypes.HBRUSH),
|
||||
('lpszMenuName', wintypes.LPCWSTR),
|
||||
('lpszClassName', wintypes.LPCWSTR),
|
||||
]
|
||||
|
||||
|
||||
class _MSG(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('hwnd', wintypes.HWND),
|
||||
('message', wintypes.UINT),
|
||||
('wParam', wintypes.WPARAM),
|
||||
('lParam', wintypes.LPARAM),
|
||||
('time', wintypes.DWORD),
|
||||
('pt', wintypes.POINT),
|
||||
]
|
||||
|
||||
|
||||
class _RAWINPUTDEVICE(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('usUsagePage', wintypes.USHORT),
|
||||
('usUsage', wintypes.USHORT),
|
||||
('dwFlags', wintypes.DWORD),
|
||||
('hwndTarget', wintypes.HWND),
|
||||
]
|
||||
|
||||
|
||||
class _RAWINPUTDEVICELIST(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('hDevice', wintypes.HANDLE),
|
||||
('dwType', wintypes.DWORD),
|
||||
]
|
||||
|
||||
|
||||
class _RAWINPUTHEADER(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('dwType', wintypes.DWORD),
|
||||
('dwSize', wintypes.DWORD),
|
||||
('hDevice', wintypes.HANDLE),
|
||||
('wParam', wintypes.WPARAM),
|
||||
]
|
||||
|
||||
|
||||
class _RAWKEYBOARD(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('MakeCode', wintypes.USHORT),
|
||||
('Flags', wintypes.USHORT),
|
||||
('Reserved', wintypes.USHORT),
|
||||
('VKey', wintypes.USHORT),
|
||||
('Message', wintypes.UINT),
|
||||
('ExtraInformation', wintypes.ULONG),
|
||||
]
|
||||
|
||||
|
||||
class _RAWINPUT_UNION(ctypes.Union):
|
||||
_fields_ = [('keyboard', _RAWKEYBOARD)]
|
||||
|
||||
|
||||
class _RAWINPUT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('header', _RAWINPUTHEADER),
|
||||
('u', _RAWINPUT_UNION),
|
||||
]
|
||||
|
||||
|
||||
class _KBDLLHOOKSTRUCT(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('vkCode', wintypes.DWORD),
|
||||
('scanCode', wintypes.DWORD),
|
||||
('flags', wintypes.DWORD),
|
||||
('time', wintypes.DWORD),
|
||||
('dwExtraInfo', wintypes.WPARAM),
|
||||
]
|
||||
|
||||
|
||||
# ── Win32 function bindings with explicit signatures ────────────────────────
|
||||
_user32 = ctypes.windll.user32
|
||||
_kernel32 = ctypes.windll.kernel32
|
||||
|
||||
_user32.RegisterClassW.argtypes = [ctypes.POINTER(_WNDCLASSW)]
|
||||
_user32.RegisterClassW.restype = wintypes.ATOM
|
||||
_user32.CreateWindowExW.argtypes = [
|
||||
wintypes.DWORD, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD,
|
||||
ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
|
||||
wintypes.HWND, wintypes.HMENU, wintypes.HINSTANCE, wintypes.LPVOID,
|
||||
]
|
||||
_user32.CreateWindowExW.restype = wintypes.HWND
|
||||
_user32.DestroyWindow.argtypes = [wintypes.HWND]
|
||||
_user32.UnregisterClassW.argtypes = [wintypes.LPCWSTR, wintypes.HINSTANCE]
|
||||
_user32.GetMessageW.argtypes = [
|
||||
ctypes.POINTER(_MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT,
|
||||
]
|
||||
_user32.GetMessageW.restype = wintypes.BOOL
|
||||
_user32.TranslateMessage.argtypes = [ctypes.POINTER(_MSG)]
|
||||
_user32.DispatchMessageW.argtypes = [ctypes.POINTER(_MSG)]
|
||||
_user32.DefWindowProcW.argtypes = [
|
||||
wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM,
|
||||
]
|
||||
_user32.DefWindowProcW.restype = _LRESULT
|
||||
_user32.PostMessageW.argtypes = [
|
||||
wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM,
|
||||
]
|
||||
_user32.PostMessageW.restype = wintypes.BOOL
|
||||
|
||||
_user32.RegisterRawInputDevices.argtypes = [
|
||||
ctypes.POINTER(_RAWINPUTDEVICE), wintypes.UINT, wintypes.UINT,
|
||||
]
|
||||
_user32.RegisterRawInputDevices.restype = wintypes.BOOL
|
||||
_user32.GetRawInputDeviceList.argtypes = [
|
||||
ctypes.POINTER(_RAWINPUTDEVICELIST), ctypes.POINTER(wintypes.UINT),
|
||||
wintypes.UINT,
|
||||
]
|
||||
_user32.GetRawInputDeviceList.restype = wintypes.UINT
|
||||
_user32.GetRawInputDeviceInfoW.argtypes = [
|
||||
wintypes.HANDLE, wintypes.UINT, wintypes.LPVOID,
|
||||
ctypes.POINTER(wintypes.UINT),
|
||||
]
|
||||
_user32.GetRawInputDeviceInfoW.restype = wintypes.UINT
|
||||
_user32.GetRawInputData.argtypes = [
|
||||
wintypes.HANDLE, wintypes.UINT, wintypes.LPVOID,
|
||||
ctypes.POINTER(wintypes.UINT), wintypes.UINT,
|
||||
]
|
||||
_user32.GetRawInputData.restype = wintypes.UINT
|
||||
_user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT]
|
||||
_user32.MapVirtualKeyW.restype = wintypes.UINT
|
||||
|
||||
_user32.SetWindowsHookExW.argtypes = [
|
||||
ctypes.c_int, _LL_KEYBOARD_PROC, wintypes.HINSTANCE, wintypes.DWORD,
|
||||
]
|
||||
_user32.SetWindowsHookExW.restype = wintypes.HHOOK
|
||||
_user32.UnhookWindowsHookEx.argtypes = [wintypes.HHOOK]
|
||||
_user32.UnhookWindowsHookEx.restype = wintypes.BOOL
|
||||
_user32.CallNextHookEx.argtypes = [
|
||||
wintypes.HHOOK, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM,
|
||||
]
|
||||
_user32.CallNextHookEx.restype = _LRESULT
|
||||
|
||||
_kernel32.GetModuleHandleW.argtypes = [wintypes.LPCWSTR]
|
||||
_kernel32.GetModuleHandleW.restype = wintypes.HINSTANCE
|
||||
|
||||
_WND_CLASS_NAME = 'KiwyWinCardReaderWindow'
|
||||
|
||||
# The single active reader instance (the Raw Input / LL-hook callbacks are
|
||||
# invoked from a background thread; a module-level ref avoids GC of the proc).
|
||||
_ACTIVE_READER = None
|
||||
|
||||
|
||||
# ── Small helpers ───────────────────────────────────────────────────────────
|
||||
def _log(msg):
|
||||
"""Log via Kivy Logger when available, else print."""
|
||||
try:
|
||||
from kivy.logger import Logger
|
||||
Logger.info(f"CardReaderWin: {msg}")
|
||||
except Exception:
|
||||
try:
|
||||
print(f"[CardReaderWin] {msg}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_config():
|
||||
"""Read app_config.json (next to exe, or project config in dev mode)."""
|
||||
try:
|
||||
data_dir = os.environ.get('KIWY_DATA_DIR', '')
|
||||
candidates = []
|
||||
if data_dir:
|
||||
candidates.append(os.path.join(data_dir, 'config', 'app_config.json'))
|
||||
# Dev fallback: <project>/config/app_config.json
|
||||
here = os.path.dirname(os.path.abspath(__file__)) # windows/
|
||||
candidates.append(os.path.join(os.path.dirname(here), 'config', 'app_config.json'))
|
||||
for path in candidates:
|
||||
if path and os.path.exists(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f) or {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _vk_to_char(vk):
|
||||
"""Convert a virtual-key code to its character, or None if not data."""
|
||||
# Numeric keypad 0-9
|
||||
if 0x60 <= vk <= 0x69:
|
||||
return chr(vk - 0x60 + 0x30)
|
||||
# Main-row digits
|
||||
if 0x30 <= vk <= 0x39:
|
||||
return chr(vk)
|
||||
# Letters (uppercase) — card readers typically emit these
|
||||
if 0x41 <= vk <= 0x5A:
|
||||
return chr(vk)
|
||||
if vk == 0x20: # space
|
||||
return ' '
|
||||
# Anything else (symbols) via MapVirtualKey
|
||||
try:
|
||||
res = _user32.MapVirtualKeyW(vk, 2) # MAPVK_VK_TO_CHAR
|
||||
if res & 0x80000000:
|
||||
return None # dead key
|
||||
c = res & 0xFFFF
|
||||
if 0x20 <= c <= 0x7E:
|
||||
return chr(c)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_device_name(hdev):
|
||||
"""Return the Win32 device name (e.g. \\\\?\\HID#VID_08FF&PID_0009#...)."""
|
||||
try:
|
||||
size = wintypes.UINT(0)
|
||||
if _user32.GetRawInputDeviceInfoW(hdev, RIDI_DEVICENAME, None, ctypes.byref(size)) == 0xFFFFFFFF:
|
||||
return ''
|
||||
if not size.value:
|
||||
return ''
|
||||
buf = ctypes.create_unicode_buffer(int(size.value) + 2)
|
||||
if _user32.GetRawInputDeviceInfoW(hdev, RIDI_DEVICENAME, buf, ctypes.byref(size)) == 0xFFFFFFFF:
|
||||
return ''
|
||||
return buf.value or ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _enumerate_keyboards():
|
||||
"""Return [{handle, name}] for all Raw-Input keyboard-type devices."""
|
||||
result = []
|
||||
try:
|
||||
count = wintypes.UINT(0)
|
||||
_user32.GetRawInputDeviceList(None, ctypes.byref(count), ctypes.sizeof(_RAWINPUTDEVICELIST))
|
||||
if not count.value:
|
||||
return result
|
||||
buf = (_RAWINPUTDEVICELIST * count.value)()
|
||||
n = _user32.GetRawInputDeviceList(buf, ctypes.byref(count), ctypes.sizeof(_RAWINPUTDEVICELIST))
|
||||
for i in range(int(n)):
|
||||
dev = buf[i]
|
||||
if dev.dwType != RIM_TYPEKEYBOARD:
|
||||
continue
|
||||
result.append({'handle': dev.hDevice, 'name': _get_device_name(dev.hDevice)})
|
||||
except Exception as e:
|
||||
_log(f"enumerate_keyboards error: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def _choose_card_reader(override=''):
|
||||
"""Pick the most likely card-reader device, mirroring the Linux logic."""
|
||||
keyboards = _enumerate_keyboards()
|
||||
if not keyboards:
|
||||
_log("No keyboard-type Raw Input devices found")
|
||||
return None
|
||||
|
||||
for dev in keyboards:
|
||||
_log(f" candidate: {dev['name']}")
|
||||
|
||||
# Config override (substring of device name, e.g. VID_08FF)
|
||||
if override:
|
||||
for dev in keyboards:
|
||||
if override.lower() in dev['name'].lower():
|
||||
_log(f"Using config override -> {dev['name']}")
|
||||
return dev
|
||||
_log(f"No device matched config override '{override}'; continuing auto-detect")
|
||||
|
||||
exclusion = ('touch', 'mouse', 'trackpad', 'pen', 'stylus', 'monitor', 'video')
|
||||
|
||||
# Priority 1: explicit card / reader / rfid
|
||||
for dev in keyboards:
|
||||
name = dev['name'].lower()
|
||||
if 'card' in name or 'reader' in name or 'rfid' in name:
|
||||
_log(f"Priority 1 (explicit reader) -> {dev['name']}")
|
||||
return dev
|
||||
|
||||
# Priority 2: USB HID keyboard that is NOT the PS/2 system keyboard
|
||||
for dev in keyboards:
|
||||
name = dev['name'].lower()
|
||||
if 'hid' in name and 'vid' in name:
|
||||
if any(p in name for p in ('pnp0303', 'pnp0c0e', 'pnp0320', 'acpi')):
|
||||
continue
|
||||
if any(k in name for k in exclusion):
|
||||
continue
|
||||
_log(f"Priority 2 (USB HID keyboard) -> {dev['name']}")
|
||||
return dev
|
||||
|
||||
# Priority 3: any keyboard that isn't obviously a touchscreen/mouse
|
||||
for dev in keyboards:
|
||||
name = dev['name'].lower()
|
||||
if any(k in name for k in exclusion):
|
||||
continue
|
||||
_log(f"Priority 3 (any keyboard) -> {dev['name']}")
|
||||
return dev
|
||||
|
||||
_log(f"Fallback: using first keyboard device -> {keyboards[0]['name']}")
|
||||
return keyboards[0]
|
||||
|
||||
|
||||
# ── WndProc / LL-hook callbacks (called on the pump thread) ─────────────────
|
||||
def _wnd_proc(hwnd, msg, wparam, lparam):
|
||||
"""Handle WM_INPUT / WM_INPUT_DEVICE_CHANGE for the hidden window."""
|
||||
try:
|
||||
reader = _ACTIVE_READER
|
||||
if reader is not None:
|
||||
if msg == WM_INPUT:
|
||||
reader._on_raw_input(lparam)
|
||||
elif msg == WM_INPUT_DEVICE_CHANGE:
|
||||
reader._on_device_change()
|
||||
return _user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
||||
except Exception:
|
||||
try:
|
||||
return _user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _ll_hook_proc(nCode, wParam, lParam):
|
||||
"""Low-level keyboard hook used as a fallback capture path."""
|
||||
try:
|
||||
if nCode == HC_ACTION:
|
||||
reader = _ACTIVE_READER
|
||||
if reader is not None and wParam in (WM_KEYDOWN, WM_SYSKEYDOWN):
|
||||
kb = _KBDLLHOOKSTRUCT.from_address(lParam)
|
||||
reader._on_key_event(kb.vkCode)
|
||||
return _user32.CallNextHookEx(None, nCode, wParam, lParam)
|
||||
except Exception:
|
||||
try:
|
||||
return _user32.CallNextHookEx(None, nCode, wParam, lParam)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
|
||||
# ── Background message-pump thread ──────────────────────────────────────────
|
||||
class _RawInputThread(threading.Thread):
|
||||
def __init__(self, owner):
|
||||
super().__init__(daemon=True)
|
||||
self._owner = owner
|
||||
self._hwnd = None
|
||||
self._hook = None
|
||||
self.ready = threading.Event()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
hinst = _kernel32.GetModuleHandleW(None)
|
||||
wndclass = _WNDCLASSW()
|
||||
wndclass.lpfnWndProc = _WND_PROC(_wnd_proc)
|
||||
wndclass.hInstance = hinst
|
||||
wndclass.lpszClassName = _WND_CLASS_NAME
|
||||
if not _user32.RegisterClassW(ctypes.byref(wndclass)):
|
||||
raise ctypes.WinError(ctypes.get_last_error(), "RegisterClassW failed")
|
||||
hwnd = _user32.CreateWindowExW(
|
||||
0, _WND_CLASS_NAME, 'KiwyWinCardReader', 0,
|
||||
0, 0, 0, 0, None, None, hinst, None,
|
||||
)
|
||||
if not hwnd:
|
||||
raise ctypes.WinError(ctypes.get_last_error(), "CreateWindowExW failed")
|
||||
self._hwnd = hwnd
|
||||
self._owner._hwnd = hwnd
|
||||
|
||||
# Register Raw Input for ALL keyboards (sink = receive w/o focus)
|
||||
rids = (_RAWINPUTDEVICE * 1)()
|
||||
rids[0].usUsagePage = 0x01
|
||||
rids[0].usUsage = 0x06 # Generic Desktop / Keyboard
|
||||
rids[0].dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY
|
||||
rids[0].hwndTarget = hwnd
|
||||
raw_ok = bool(_user32.RegisterRawInputDevices(
|
||||
rids, 1, ctypes.sizeof(_RAWINPUTDEVICE)))
|
||||
self._owner._raw_ok = raw_ok
|
||||
if raw_ok:
|
||||
_log("Raw Input registered for keyboard devices")
|
||||
|
||||
# LL-hook fallback: use it when config says so, or if raw failed
|
||||
mode = getattr(self._owner, '_mode', 'auto')
|
||||
if mode == 'hook' or not raw_ok:
|
||||
proc = _LL_KEYBOARD_PROC(_ll_hook_proc)
|
||||
hook = _user32.SetWindowsHookExW(WH_KEYBOARD_LL, proc, hinst, 0)
|
||||
if hook:
|
||||
self._hook = hook
|
||||
self._hook_proc = proc # keep reference alive
|
||||
_log("Low-level keyboard hook ACTIVE (fallback capture)")
|
||||
|
||||
self.ready.set()
|
||||
|
||||
msg = _MSG()
|
||||
while _user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
|
||||
_user32.TranslateMessage(ctypes.byref(msg))
|
||||
_user32.DispatchMessageW(ctypes.byref(msg))
|
||||
|
||||
if self._hook:
|
||||
try:
|
||||
_user32.UnhookWindowsHookEx(self._hook)
|
||||
except Exception:
|
||||
pass
|
||||
_user32.DestroyWindow(hwnd)
|
||||
_user32.UnregisterClassW(_WND_CLASS_NAME, hinst)
|
||||
except Exception as e:
|
||||
_log(f"Message pump thread error: {e}")
|
||||
self.ready.set()
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
if self._hwnd:
|
||||
_user32.PostMessageW(self._hwnd, 0x0012, 0, 0) # WM_QUIT
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Public drop-in replacement for the Linux CardReader ─────────────────────
|
||||
class WindowsCardReader:
|
||||
"""Windows-native card reader with the same interface as main.py's CardReader.
|
||||
|
||||
Interface used by SignagePlayer:
|
||||
read_card_async(callback) -> starts listening; callback(card_data) on
|
||||
Enter, or callback(None) on timeout/cancel
|
||||
stop_reading() -> stops listening
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._device = None
|
||||
self._device_name = ''
|
||||
self._reading = False
|
||||
self._finished = False
|
||||
self._callback = None
|
||||
self._card_buffer = []
|
||||
self._last_activity = 0.0
|
||||
self._timeout = 5.0
|
||||
self._mode = 'auto'
|
||||
self._hwnd = None
|
||||
self._raw_ok = False
|
||||
self._thread = None
|
||||
self._lock = threading.Lock()
|
||||
self._last_device_change = 0.0 # debounce WM_INPUT_DEVICE_CHANGE
|
||||
|
||||
# -- config -------------------------------------------------------
|
||||
def _load_settings(self):
|
||||
cfg = _get_config()
|
||||
self._mode = str(cfg.get('card_reader_mode', 'auto')).lower().strip() or 'auto'
|
||||
try:
|
||||
self._timeout = float(cfg.get('card_reader_timeout', 5))
|
||||
except Exception:
|
||||
self._timeout = 5.0
|
||||
if self._timeout <= 0:
|
||||
self._timeout = 5.0
|
||||
return str(cfg.get('card_reader_device', '') or '').strip()
|
||||
|
||||
# -- public API ---------------------------------------------------
|
||||
def read_card_async(self, callback):
|
||||
"""Start reading; callback(card_data) on Enter, callback(None) on timeout."""
|
||||
if self._reading:
|
||||
_log("read_card_async called while already reading — ignoring")
|
||||
return
|
||||
override = self._load_settings()
|
||||
global _ACTIVE_READER
|
||||
_ACTIVE_READER = self
|
||||
|
||||
self._callback = callback
|
||||
self._reading = True
|
||||
self._finished = False
|
||||
self._card_buffer = []
|
||||
self._last_activity = time.time()
|
||||
|
||||
# Choose the target device (raw mode only). In 'hook' mode we capture
|
||||
# from all keyboards via the LL-hook instead.
|
||||
if self._mode != 'hook':
|
||||
self._device = None
|
||||
self._device_name = ''
|
||||
chosen = _choose_card_reader(override=override)
|
||||
if chosen:
|
||||
self._device = chosen['handle']
|
||||
self._device_name = chosen['name']
|
||||
_log(f"Selected card reader: {chosen['name']}")
|
||||
else:
|
||||
_log("No dedicated device found — will accept any keyboard input")
|
||||
else:
|
||||
self._device = None
|
||||
self._device_name = ''
|
||||
|
||||
# Ensure the message pump is running
|
||||
if self._thread is None or not self._thread.is_alive():
|
||||
self._thread = _RawInputThread(self)
|
||||
self._thread.start()
|
||||
self._thread.ready.wait(timeout=3.0)
|
||||
|
||||
# Watchdog for the 5-second timeout
|
||||
threading.Thread(target=self._timeout_watchdog, daemon=True).start()
|
||||
_log(f"Waiting for card swipe (mode={self._mode}, timeout={self._timeout}s)")
|
||||
|
||||
def stop_reading(self):
|
||||
"""Stop listening (also stops the timeout watchdog)."""
|
||||
self._reading = False
|
||||
|
||||
def shutdown(self):
|
||||
"""Stop the background pump thread (call on app exit)."""
|
||||
self.stop_reading()
|
||||
global _ACTIVE_READER
|
||||
if _ACTIVE_READER is self:
|
||||
_ACTIVE_READER = None
|
||||
if self._thread is not None:
|
||||
self._thread.stop()
|
||||
|
||||
# -- internals ----------------------------------------------------
|
||||
def _timeout_watchdog(self):
|
||||
while self._reading and not self._finished:
|
||||
if time.time() - self._last_activity > self._timeout:
|
||||
_log("Read timeout — sending None")
|
||||
self._finish(None)
|
||||
return
|
||||
time.sleep(0.25)
|
||||
|
||||
def _on_raw_input(self, lparam):
|
||||
"""Process a WM_INPUT message (called on the pump thread)."""
|
||||
try:
|
||||
size = wintypes.UINT(0)
|
||||
_user32.GetRawInputData(lparam, RID_INPUT, None, ctypes.byref(size),
|
||||
ctypes.sizeof(_RAWINPUTHEADER))
|
||||
if not size.value:
|
||||
return
|
||||
buf = ctypes.create_string_buffer(int(size.value))
|
||||
got = _user32.GetRawInputData(lparam, RID_INPUT, buf, ctypes.byref(size),
|
||||
ctypes.sizeof(_RAWINPUTHEADER))
|
||||
if got == 0xFFFFFFFF or got == 0:
|
||||
return
|
||||
raw = ctypes.cast(buf, ctypes.POINTER(_RAWINPUT)).contents
|
||||
if raw.header.dwType != RIM_TYPEKEYBOARD:
|
||||
return
|
||||
# Only accept input from the selected card-reader device.
|
||||
if self._device is not None and raw.header.hDevice != self._device:
|
||||
return
|
||||
kb = raw.u.keyboard
|
||||
if kb.Message in (WM_KEYDOWN, WM_SYSKEYDOWN):
|
||||
self._on_key_event(kb.VKey)
|
||||
except Exception as e:
|
||||
_log(f"_on_raw_input error: {e}")
|
||||
|
||||
def _on_device_change(self):
|
||||
"""A HID device was added/removed — re-select only if the actual
|
||||
choice changes, and never more than once per second (the initial
|
||||
registration/enumeration triggers a spurious change event)."""
|
||||
try:
|
||||
if not self._reading or self._mode == 'hook':
|
||||
return
|
||||
now = time.time()
|
||||
if now - self._last_device_change < 1.0:
|
||||
return # debounce
|
||||
self._last_device_change = now
|
||||
override = str(_get_config().get('card_reader_device', '') or '').strip()
|
||||
chosen = _choose_card_reader(override=override)
|
||||
if chosen is None:
|
||||
return
|
||||
# Only re-select if the winning device actually changed.
|
||||
if self._device is not None and chosen['handle'] == self._device:
|
||||
return
|
||||
self._device = chosen['handle']
|
||||
self._device_name = chosen['name']
|
||||
_log(f"Device change — re-selected: {chosen['name']}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_key_event(self, vk):
|
||||
"""Handle a single key event (from Raw Input or LL-hook)."""
|
||||
if not self._reading or self._finished:
|
||||
return
|
||||
if vk == _VK_RETURN:
|
||||
data = ''.join(self._card_buffer).strip()
|
||||
self._card_buffer = []
|
||||
if data:
|
||||
_log(f"Card read complete: '{data}' (len={len(data)})")
|
||||
self._finish(data)
|
||||
else:
|
||||
# Accidental Enter with no data — keep waiting
|
||||
self._last_activity = time.time()
|
||||
return
|
||||
if vk in (_VK_SHIFT, _VK_LSHIFT, _VK_RSHIFT,
|
||||
_VK_CONTROL, _VK_LCONTROL, _VK_RCONTROL,
|
||||
_VK_MENU, _VK_LMENU, _VK_RMENU,
|
||||
_VK_CAPITAL, _VK_TAB, _VK_ESCAPE):
|
||||
return # modifiers / control keys are not card data
|
||||
ch = _vk_to_char(vk)
|
||||
if ch:
|
||||
self._card_buffer.append(ch)
|
||||
self._last_activity = time.time()
|
||||
|
||||
def _finish(self, data):
|
||||
"""Deliver the result exactly once (thread-safe), on the Kivy thread."""
|
||||
with self._lock:
|
||||
if self._finished:
|
||||
return
|
||||
self._finished = True
|
||||
self._reading = False
|
||||
cb = self._callback
|
||||
self._callback = None
|
||||
if cb is None:
|
||||
return
|
||||
# Prefer scheduling on the Kivy thread when a Kivy app is running
|
||||
# (the callback touches Kivy widgets). If Kivy isn't running
|
||||
# (manual test / non-GUI context), invoke the callback directly.
|
||||
kivy_running = False
|
||||
try:
|
||||
from kivy.app import App
|
||||
kivy_running = App.get_running_app() is not None
|
||||
except Exception:
|
||||
kivy_running = False
|
||||
if kivy_running:
|
||||
try:
|
||||
from kivy.clock import Clock
|
||||
Clock.schedule_once(lambda dt, d=data: cb(d), 0)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cb(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Simple manual test (no Kivy): run for a few seconds and print any card.
|
||||
print("Windows Card Reader - manual test (swipe a card within 10s)")
|
||||
|
||||
def _cb(data):
|
||||
print(f"CALLBACK: {data!r}")
|
||||
|
||||
reader = WindowsCardReader()
|
||||
reader.read_card_async(_cb)
|
||||
try:
|
||||
time.sleep(10)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
reader.stop_reading()
|
||||
reader.shutdown()
|
||||
print("Test done.")
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Execute the exact server playlist retrieval flow the player performs:
|
||||
1. Load config/app_config.json (player code: screen_name + quickconnect_key)
|
||||
2. Authenticate with the server (POST /api/auth/player)
|
||||
3. Fetch playlist using the player_id + auth_code (GET /api/playlists/{player_id})
|
||||
4. Print the RAW JSON exactly as received (before any local processing)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import traceback
|
||||
|
||||
# Resolve the real src directory (script lives in working_files/, source is in src/)
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC_DIR = os.path.join(os.path.dirname(HERE), 'src')
|
||||
sys.path.insert(0, SRC_DIR)
|
||||
|
||||
from get_playlists_v2 import get_auth_instance # noqa: E402
|
||||
from player_auth import PlayerAuth # noqa: E402
|
||||
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(HERE), 'config', 'app_config.json')
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print("EXECUTE SERVER PLAYLIST RETRIEVAL (player flow)")
|
||||
print("=" * 80)
|
||||
|
||||
# 1. Load the player config
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
print(f"\n[1] Player config loaded from: {CONFIG_PATH}")
|
||||
print(f" server_ip : {config.get('server_ip')}")
|
||||
print(f" port : {config.get('port')}")
|
||||
print(f" screen_name : {config.get('screen_name')} <- player code")
|
||||
print(f" quickconnect_key: {config.get('quickconnect_key')} <- player quick-connect code")
|
||||
print(f" use_https : {config.get('use_https')}")
|
||||
print(f" verify_ssl : {config.get('verify_ssl')}")
|
||||
|
||||
# 2. Build server URL the same way ensure_authenticated() does
|
||||
import re
|
||||
server_ip = config.get("server_ip", "")
|
||||
port = config.get("port", "")
|
||||
use_https = config.get("use_https", True)
|
||||
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
|
||||
if re.match(ip_pattern, server_ip):
|
||||
if use_https:
|
||||
server_url = f'https://{server_ip}:{port}' if port else f'https://{server_ip}'
|
||||
else:
|
||||
server_url = f'http://{server_ip}:{port}' if port else f'http://{server_ip}'
|
||||
else:
|
||||
server_url = f'https://{server_ip}' if use_https else f'http://{server_ip}'
|
||||
print(f"\n[2] Server URL used: {server_url}")
|
||||
|
||||
# 3. Authenticate with the player code
|
||||
auth = get_auth_instance(
|
||||
config_file=os.path.join(SRC_DIR, 'player_auth.json'),
|
||||
use_https=config.get('use_https', True),
|
||||
verify_ssl=config.get('verify_ssl', True)
|
||||
)
|
||||
|
||||
print(f"\n[3] Authenticating player '{config.get('screen_name')}' with quickconnect code...")
|
||||
success, error = auth.authenticate(
|
||||
server_url=server_url,
|
||||
hostname=config.get('screen_name', ''),
|
||||
quickconnect_code=config.get('quickconnect_key', '')
|
||||
)
|
||||
if not success:
|
||||
print(f" AUTH FAILED: {error}")
|
||||
print(" Cannot retrieve playlist without a valid player code/auth.")
|
||||
return
|
||||
print(f" Authenticated as: {auth.get_player_name()} (player_id={auth.get_player_id()}, "
|
||||
f"playlist_id={auth.auth_data.get('playlist_id')})")
|
||||
|
||||
# 4. Fetch the playlist (GET /api/playlists/{player_id})
|
||||
print(f"\n[4] Fetching playlist from: {server_url}/api/playlists/{auth.get_player_id()}")
|
||||
playlist_data = auth.get_playlist()
|
||||
|
||||
if playlist_data is None:
|
||||
print(" PLAYLIST FETCH FAILED")
|
||||
return
|
||||
|
||||
# 5. Print the RAW JSON exactly as received from the server
|
||||
print(f"\n[5] RAW JSON received from server (status 200):\n")
|
||||
print(json.dumps(playlist_data, indent=2, ensure_ascii=False))
|
||||
|
||||
# 6. Save the raw response for inspection
|
||||
out_path = os.path.join(HERE, 'raw_server_playlist.json')
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(playlist_data, f, indent=2, ensure_ascii=False)
|
||||
print(f"\n[6] Raw server response saved to: {out_path}")
|
||||
|
||||
# 7. Summary
|
||||
print("\n" + "=" * 80)
|
||||
print(f"SUMMARY: playlist_version={playlist_data.get('playlist_version')}, "
|
||||
f"count={playlist_data.get('count')}, items={len(playlist_data.get('playlist', []))}")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"count": 6,
|
||||
"player_id": 2,
|
||||
"player_name": "Windows-Player1",
|
||||
"playlist": [
|
||||
{
|
||||
"audio": "off",
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"edit_on_player": false,
|
||||
"file_name": "anders-jilden-cYrMQA7a3Wc-unsplash.jpg",
|
||||
"id": 9,
|
||||
"muted": true,
|
||||
"position": 1,
|
||||
"type": "image",
|
||||
"url": "http://192.168.0.107:8080/static/uploads/anders-jilden-cYrMQA7a3Wc-unsplash.jpg"
|
||||
},
|
||||
{
|
||||
"audio": "off",
|
||||
"description": null,
|
||||
"duration": 14,
|
||||
"edit_on_player": true,
|
||||
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
||||
"id": 2,
|
||||
"muted": true,
|
||||
"position": 2,
|
||||
"type": "image",
|
||||
"url": "http://192.168.0.107:8080/static/uploads/sean-oulashin-KMn4VEeEPR8-unsplash.jpg"
|
||||
},
|
||||
{
|
||||
"audio": "on",
|
||||
"description": null,
|
||||
"duration": 31,
|
||||
"edit_on_player": false,
|
||||
"file_name": "sample-30s.mp4",
|
||||
"id": 4,
|
||||
"muted": false,
|
||||
"position": 3,
|
||||
"type": "video",
|
||||
"url": "http://192.168.0.107:8080/static/uploads/sample-30s.mp4"
|
||||
},
|
||||
{
|
||||
"audio": "off",
|
||||
"description": null,
|
||||
"duration": 50,
|
||||
"edit_on_player": true,
|
||||
"file_name": "edited_media/5/eye_e_v2.jpg",
|
||||
"id": 5,
|
||||
"muted": true,
|
||||
"position": 4,
|
||||
"type": "image",
|
||||
"url": "http://192.168.0.107:8080/static/uploads/edited_media/5/eye_e_v2.jpg"
|
||||
},
|
||||
{
|
||||
"audio": "off",
|
||||
"description": "https://moto-adv.com/",
|
||||
"duration": 30,
|
||||
"edit_on_player": false,
|
||||
"file_name": "weblink-ecc2705c34e5",
|
||||
"id": 7,
|
||||
"muted": true,
|
||||
"position": 5,
|
||||
"type": "weblink",
|
||||
"url": "https://moto-adv.com/"
|
||||
},
|
||||
{
|
||||
"audio": "off",
|
||||
"description": null,
|
||||
"duration": 30,
|
||||
"edit_on_player": false,
|
||||
"file_name": "jack-anstey-XVoyX7l9ocY-unsplash.jpg",
|
||||
"id": 8,
|
||||
"muted": true,
|
||||
"position": 6,
|
||||
"type": "image",
|
||||
"url": "http://192.168.0.107:8080/static/uploads/jack-anstey-XVoyX7l9ocY-unsplash.jpg"
|
||||
}
|
||||
],
|
||||
"playlist_id": 1,
|
||||
"playlist_version": 32
|
||||
}
|
||||
Reference in New Issue
Block a user