Add Windows card reader, code-signing helpers and playlist diagnostics
- windows/win_card_reader.py: Windows-native card reader via the Raw Input API with a low-level keyboard-hook fallback. - windows/sign_exe.ps1: sign the built executable with a .pfx certificate. - windows/create_self_signed_cert.ps1: generate a self-signed cert for local testing (not trusted by Smart App Control). - windows/verify_sendinput_fix.py: verification helper for the SendInput foreground-unlock fix in run_win.py. - documentation/CODE_SIGNING_SMART_APP_CONTROL.md: signing guidance. - working_files/execute_playlist_retrieve.py, working_files/raw_server_playlist.json: playlist retrieval diagnostics. Note: windows/archive_list.txt and windows/build_last.txt are build output and were committed by request rather than by convention.
This commit is contained in:
@@ -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,110 @@
|
|||||||
|
<#
|
||||||
|
================================================================================
|
||||||
|
Kiwy Signage Player - Self-Signed Certificate Creator
|
||||||
|
================================================================================
|
||||||
|
Creates a self-signed code-signing certificate for DEV/TEST machines.
|
||||||
|
|
||||||
|
IMPORTANT (please read before running):
|
||||||
|
A self-signed certificate, even when trusted locally, does NOT satisfy
|
||||||
|
Smart App Control (SAC). SAC only trusts reputable public CAs. This script
|
||||||
|
is therefore ONLY for development / test PCs where you have admin rights
|
||||||
|
and where SAC is either OFF or the app is run with SAC disabled.
|
||||||
|
|
||||||
|
For production PCs (SAC ON, no admin), you MUST buy a code-signing cert
|
||||||
|
from a public CA (Sectigo/SSL.com/DigiCert/GlobalSign) and use
|
||||||
|
sign_exe.ps1 with that .pfx.
|
||||||
|
|
||||||
|
What this does:
|
||||||
|
1. Creates a self-signed code-signing cert in the Current User store
|
||||||
|
(never expires, exportable so you can move it to the build machine).
|
||||||
|
2. Exports it to a .pfx (password-protected) so build_win.bat can sign.
|
||||||
|
3. Asks if you want to trust it on THIS machine (installs to Root + Trusted
|
||||||
|
Publisher + Trusted People) so the player runs without SmartScreen/
|
||||||
|
Defender prompts on this dev PC.
|
||||||
|
|
||||||
|
Usage (run as Administrator):
|
||||||
|
.\create_self_signed_cert.ps1
|
||||||
|
.\create_self_signed_cert.ps1 -CertName "Kiwy Signage Dev" -PfxPassword "ChangeMe!1" -ExportPath "C:\certs\kiwy_dev.pfx"
|
||||||
|
================================================================================
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$CertName = 'Kiwy Signage Player (Dev)',
|
||||||
|
[string]$Subject = 'CN=Kiwy Signage Player (Dev)',
|
||||||
|
[string]$PfxPassword = 'KiwySignage2026!',
|
||||||
|
[string]$ExportPath = (Join-Path $PSScriptRoot 'kiwy_dev_signing.pfx'),
|
||||||
|
[switch]$SkipTrust
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
|
|
||||||
|
# ── 1. Create the self-signed code-signing certificate ──────────────
|
||||||
|
Write-Host "[STEP] Creating self-signed code-signing certificate..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$cert = New-SelfSignedCertificate `
|
||||||
|
-Subject $Subject `
|
||||||
|
-FriendlyName $CertName `
|
||||||
|
-Type CodeSigningCert `
|
||||||
|
-CertStoreLocation 'Cert:\CurrentUser\My' `
|
||||||
|
-KeyExportPolicy Exportable `
|
||||||
|
-KeyAlgorithm RSA `
|
||||||
|
-KeyLength 2048 `
|
||||||
|
-NotAfter (Get-Date).AddYears(10)
|
||||||
|
|
||||||
|
if (-not $cert) {
|
||||||
|
Write-Host "[ERROR] Failed to create certificate." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "[OK ] Created cert: $($cert.Subject) thumbprint=$($cert.Thumbprint)" -ForegroundColor Green
|
||||||
|
|
||||||
|
# ── 2. Export to PFX ─────────────────────────────────────────────────
|
||||||
|
Write-Host "[STEP] Exporting to PFX: $ExportPath" -ForegroundColor Cyan
|
||||||
|
$securePwd = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText
|
||||||
|
try {
|
||||||
|
Export-PfxCertificate -Cert $cert -FilePath $ExportPath -Password $securePwd -Force | Out-Null
|
||||||
|
Write-Host "[OK ] PFX written: $ExportPath" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "[WARN ] Could not export PFX (still usable from cert store): $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 3. Trust it on THIS machine (Root + Trusted Publisher) ───────────
|
||||||
|
if (-not $SkipTrust) {
|
||||||
|
Write-Host "[STEP] Installing to Trusted Root + Trusted Publisher (requires admin)..." -ForegroundColor Cyan
|
||||||
|
try {
|
||||||
|
$rootStore = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||||
|
'Root', 'CurrentUser')
|
||||||
|
$rootStore.Open('ReadWrite')
|
||||||
|
$rootStore.Add($cert)
|
||||||
|
$rootStore.Close()
|
||||||
|
|
||||||
|
$pubStore = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||||
|
'TrustedPublisher', 'CurrentUser')
|
||||||
|
$pubStore.Open('ReadWrite')
|
||||||
|
$pubStore.Add($cert)
|
||||||
|
$pubStore.Close()
|
||||||
|
|
||||||
|
$peopleStore = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||||
|
'TrustedPeople', 'CurrentUser')
|
||||||
|
$peopleStore.Open('ReadWrite')
|
||||||
|
$peopleStore.Add($cert)
|
||||||
|
$peopleStore.Close()
|
||||||
|
|
||||||
|
Write-Host "[OK ] Certificate trusted on this machine." -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "[WARN ] Trust install failed (run as Administrator): $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "================ RESULT ================" -ForegroundColor Green
|
||||||
|
Write-Host "Cert : $($cert.Subject)"
|
||||||
|
Write-Host "Thumb : $($cert.Thumbprint)"
|
||||||
|
Write-Host "PFX : $ExportPath (password: $PfxPassword)"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "To sign the exe with this cert:"
|
||||||
|
Write-Host " .\sign_exe.ps1 -CertPath `"$ExportPath`" -CertPassword `"$PfxPassword`""
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "REMINDER: This self-signed cert is for DEV ONLY. Production PCs"
|
||||||
|
Write-Host "with Smart App Control ON need a cert from a public CA."
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
@@ -0,0 +1,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