diff --git a/documentation/CODE_SIGNING_SMART_APP_CONTROL.md b/documentation/CODE_SIGNING_SMART_APP_CONTROL.md new file mode 100644 index 0000000..4920a9a --- /dev/null +++ b/documentation/CODE_SIGNING_SMART_APP_CONTROL.md @@ -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.) diff --git a/windows/create_self_signed_cert.ps1 b/windows/create_self_signed_cert.ps1 new file mode 100644 index 0000000..e202c90 --- /dev/null +++ b/windows/create_self_signed_cert.ps1 @@ -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 diff --git a/windows/sign_exe.ps1 b/windows/sign_exe.ps1 new file mode 100644 index 0000000..831fe3b --- /dev/null +++ b/windows/sign_exe.ps1 @@ -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 +} diff --git a/windows/verify_sendinput_fix.py b/windows/verify_sendinput_fix.py new file mode 100644 index 0000000..ea8f09b --- /dev/null +++ b/windows/verify_sendinput_fix.py @@ -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() diff --git a/windows/win_card_reader.py b/windows/win_card_reader.py new file mode 100644 index 0000000..7cbf7b0 --- /dev/null +++ b/windows/win_card_reader.py @@ -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: /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.") diff --git a/working_files/execute_playlist_retrieve.py b/working_files/execute_playlist_retrieve.py new file mode 100644 index 0000000..2cf9b14 --- /dev/null +++ b/working_files/execute_playlist_retrieve.py @@ -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) diff --git a/working_files/raw_server_playlist.json b/working_files/raw_server_playlist.json new file mode 100644 index 0000000..32791a4 --- /dev/null +++ b/working_files/raw_server_playlist.json @@ -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 +} \ No newline at end of file