diff --git a/src/edit_popup.py b/src/edit_popup.py index ac71e45..70ad990 100644 --- a/src/edit_popup.py +++ b/src/edit_popup.py @@ -82,11 +82,17 @@ class DrawingLayer(Widget): class EditPopup(Popup): """Popup for editing/annotating images""" - def __init__(self, player_instance, image_path, user_card_data=None, **kwargs): + def __init__(self, player_instance, image_path, user_card_data=None, + media_id=None, original_filename=None, **kwargs): super(EditPopup, self).__init__(**kwargs) self.player = player_instance self.image_path = image_path self.user_card_data = user_card_data # Store card data to send to server on save + # Server naming context: which media item (id) is being edited and what + # its original file name is on the server. The server stores edited + # media under 'edited_media//', so we must reproduce that. + self.media_id = media_id + self.original_filename = original_filename # server-side file_name # Auto-close timer (5 minutes) self.auto_close_timeout = 300 # 5 minutes in seconds @@ -259,8 +265,15 @@ class EditPopup(Popup): def save_image(self, instance): """Save the edited image""" try: - # Create edited_media directory if it doesn't exist - edited_dir = os.path.join(self.player.base_dir, 'media', 'edited_media') + # Edited media is stored on the server under + # 'edited_media//'. Reproduce that subfolder locally so + # the upload naming matches what the server expects. Fall back to + # the flat 'edited_media/' folder when no media_id is available. + edited_base = os.path.join(self.player.base_dir, 'media', 'edited_media') + if self.media_id is not None: + edited_dir = os.path.join(edited_base, str(self.media_id)) + else: + edited_dir = edited_base os.makedirs(edited_dir, exist_ok=True) # Get original filename @@ -310,8 +323,22 @@ class EditPopup(Popup): # Overwrite the file shutil.copy2(output_path, self.image_path) - # Force file system sync to ensure data is written to disk - os.sync() + # Force file system sync to ensure data is written to disk. + # NOTE: os.sync() is Linux-only and raises AttributeError on + # Windows โ€” that used to abort the whole pipeline before the + # metadata/upload steps. Use a cross-platform fsync that is + # best-effort and can never break the save/upload flow. + try: + if hasattr(os, 'sync'): + os.sync() + else: + with open(output_path, 'rb') as _f: + try: + os.fsync(_f.fileno()) + except Exception: + pass + except Exception as _sync_err: + Logger.warning(f"EditPopup: File sync skipped ({_sync_err})") # Verify the overwrite new_size = os.path.getsize(self.image_path) @@ -326,9 +353,15 @@ class EditPopup(Popup): self.ids.top_toolbar.opacity = 1 self.ids.right_sidebar.opacity = 1 - # Create and save metadata - json_filename = self._save_metadata(edited_dir, new_name, base_name, - new_version if version_match else 1, output_filename) + # Create and save metadata. This runs in its own guarded + # block so that a failure here cannot silently stop the + # upload โ€” the two steps are intentionally decoupled. + json_filename = None + try: + json_filename = self._save_metadata(edited_dir, new_name, base_name, + new_version if version_match else 1, output_filename) + except Exception as meta_err: + Logger.error(f"EditPopup: Metadata save failed: {meta_err}") # Upload to server in background (continues after popup closes) upload_thread = threading.Thread( @@ -414,6 +447,14 @@ class EditPopup(Popup): 'version': version, 'user_card_data': self.user_card_data # Card data from reader (or None) } + # Include the server-side file name and media id so the server can + # attach the edit to the correct media item. + if self.original_filename: + metadata['original_filename'] = self.original_filename + else: + metadata['original_filename'] = os.path.basename(self.image_path) + if self.media_id is not None: + metadata['media_id'] = self.media_id # Save metadata JSON json_filename = f"{new_name}_metadata.json" @@ -444,16 +485,37 @@ class EditPopup(Popup): Logger.warning("EditPopup: Missing server URL or auth code (upload skipped)") return False - # Load metadata from file - with open(metadata_path, 'r') as meta_file: - metadata = json.load(meta_file) + # Load metadata from file (or build it in memory if the metadata + # file was not written โ€” the upload must still go through). + metadata = None + if metadata_path and os.path.exists(metadata_path): + try: + with open(metadata_path, 'r') as meta_file: + metadata = json.load(meta_file) + except Exception as e: + Logger.warning(f"EditPopup: Could not read metadata file: {e}") + if not metadata: + metadata = { + 'time_of_modification': datetime.now().isoformat(), + 'original_name': os.path.basename(image_path), + 'new_name': os.path.basename(image_path), + 'version': 1, + 'user_card_data': self.user_card_data, + } + if self.original_filename: + metadata['original_filename'] = self.original_filename + if self.media_id is not None: + metadata['media_id'] = self.media_id # Prepare upload URL - send to the original file endpoint upload_url = f"{server_url}/api/player-edit-media" headers = {'Authorization': f'Bearer {auth_code}'} - # Add the original filename to metadata so server knows which file was edited - metadata['original_filename'] = os.path.basename(metadata['original_path']) + # Ensure the original filename (server-side name) is present so the + # server knows which file was edited. Prefer the media context we + # captured when the edit popup opened. + if not metadata.get('original_filename'): + metadata['original_filename'] = os.path.basename(metadata.get('original_path', image_path)) # Disable SSL verification for self-signed certificates (like main code does) # Note: This is NOT recommended for production with untrusted servers diff --git a/src/get_playlists_v2.py b/src/get_playlists_v2.py index c773b46..ae5a268 100644 --- a/src/get_playlists_v2.py +++ b/src/get_playlists_v2.py @@ -248,13 +248,11 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None) # Web-link items have no file to download โ€” pass the link through unchanged. if item_type == 'weblink': logger.info(f"๐Ÿ”— Web link item (no download): {file_url}") - updated_playlist.append({ - 'file_name': file_name, - 'type': 'weblink', - 'url': file_url, # keep the original web address (not a local path) - 'duration': duration, - 'edit_on_player': False, - }) + # Preserve every server field (audio/muted/description/id/position/...) + # instead of rebuilding a fixed dict, so nothing is silently dropped. + weblink_item = dict(media) + weblink_item['type'] = 'weblink' + updated_playlist.append(weblink_item) continue local_path = os.path.join(media_dir, file_name) @@ -318,14 +316,13 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None) # Don't skip - may still add to playlist # Always add the media item to the playlist, even if download failed - # (it might already exist or be available later) - updated_media = { - 'file_name': file_name, - 'type': item_type, # Preserve media type (image/video/...) - 'url': os.path.relpath(local_path, os.path.dirname(media_dir)), - 'duration': duration, - 'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag - } + # (it might already exist or be available later). + # Preserve EVERY server field (audio/muted/description/id/position/...) + # by copying the original dict and only overriding the URL with the + # local path โ€” previously the fixed dict below dropped `audio`, `muted`, + # `description`, `id` and `position` from the saved playlist. + updated_media = dict(media) + updated_media['url'] = os.path.relpath(local_path, os.path.dirname(media_dir)) updated_playlist.append(updated_media) return updated_playlist diff --git a/windows/README_WINDOWS_BUILD.md b/windows/README_WINDOWS_BUILD.md index 298373f..e017895 100644 --- a/windows/README_WINDOWS_BUILD.md +++ b/windows/README_WINDOWS_BUILD.md @@ -10,7 +10,7 @@ The original app was built for **Raspberry Pi (Linux)**, using these technologie |-----------|---------------------|-------------------| | **GUI** | Kivy 2.3+ | Kivy 2.3+ (works cross-platform) | | **Video** | ffpyplayer | ffpyplayer (needs FFmpeg DLLs) | -| **Card Reader** | evdev (Linux input) | **Not available** โ€” gracefully disabled | +| **Card Reader** | evdev (Linux input) | โœ… Raw Input API + LL-hook fallback | | **Screen Keep-Awake** | xset, xdotool, Wayland | `SetThreadExecutionState` (Win32 API) | | **Weblink** | chromium-browser (kiosk) | Chrome/Edge (--kiosk mode) | | **Audio** | ALSA/PulseAudio | DirectSound | @@ -27,9 +27,9 @@ The original app was built for **Raspberry Pi (Linux)**, using these technologie - โœ… Web links (opens in Chrome/Edge kiosk) - โœ… Network monitoring - โœ… Auto-update playlist +- โœ… Card reader authentication (Raw Input API โ€” see below) ### What is disabled on Windows -- โŒ Card reader (evdev is Linux-only; `EVDEV_AVAILABLE = False`) - โŒ HDMI power management (tvservice is RPi-specific) - โŒ WiFi restart (uses Linux `nmcli`) @@ -112,6 +112,50 @@ For a **single-file .exe**, edit `build.spec` โ€” uncomment the `exe_onefile` se } ``` +## ๐Ÿ’ณ Card Reader (Windows Edition) + +The card reader now works on Windows via the **Raw Input API** (with a +low-level keyboard-hook fallback). It replaces the Linux-only `evdev` +implementation automatically when `run_win.py` starts. + +- Detection mirrors the Linux logic: + 1. A device named with `card` / `reader` / `rfid` + 2. A USB HID keyboard (non-PS/2) โ€” most card readers enumerate this way + 3. Any remaining keyboard (excluding touchscreens/mice) +- Only keystrokes from the **selected device** are captured, so the + operator's real keyboard cannot pollute card data. +- Card data ends on **Enter** (same as Linux). + +### Card reader config (optional) + +Add any of these to `config\app_config.json` next to the .exe: + +```json +{ + "card_reader_mode": "auto", // "auto" | "raw" | "hook" + "card_reader_device": "", // e.g. "VID_08FF" to force a specific device + "card_reader_timeout": 5 // seconds +} +``` + +- `card_reader_mode`: `auto` (default, tries Raw Input then falls back), + `raw` (force Raw Input), or `hook` (force the low-level keyboard hook). +- `card_reader_device`: optional substring of the device name to pin the + reader (e.g. `VID_08FF`, `HID#VID_08FF`). Run the manual test below to see + the exact device names on your host. +- `card_reader_timeout`: how long the swipe popup waits (default 5 s). + +### Manual card reader test (no GUI) + +```batch +cd windows +venv\Scripts\activate +python win_card_reader.py +``` + +Swipe a card within 10 seconds โ€” the tool prints the captured data, then +exits. The detected devices are listed in the console/log. + ## ๐Ÿงช Testing ```batch diff --git a/windows/build_win.bat b/windows/build_win.bat index feef21c..6914c79 100644 --- a/windows/build_win.bat +++ b/windows/build_win.bat @@ -107,6 +107,38 @@ if %ERRORLEVEL% neq 0 ( exit /b 1 ) +REM ---- Optional code signing ---------------------------------------- +REM For production PCs with Smart App Control ON, the exe MUST be signed +REM by a cert from a reputable public CA. If you have a .pfx, put its path +REM in the env var KIWY_SIGN_PFX (and optionally KIWY_SIGN_PFX_PASSWORD), +REM or drop a pfx named "kiwy_signing.pfx" in this folder. The build will +REM then auto-sign via sign_exe.ps1. +echo. +echo [STEP] Checking for code-signing certificate... + +set "SIGN_PFX=%KIWY_SIGN_PFX%" +if not defined SIGN_PFX if exist "%~dp0kiwy_signing.pfx" set "SIGN_PFX=%~dp0kiwy_signing.pfx" + +if defined SIGN_PFX ( + echo [INFO ] Code-signing cert found: %SIGN_PFX% + if defined KIWY_SIGN_PFX_PASSWORD ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0sign_exe.ps1" -CertPath "%SIGN_PFX%" -CertPassword "%KIWY_SIGN_PFX_PASSWORD%" + ) else ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0sign_exe.ps1" -CertPath "%SIGN_PFX%" + ) + if not "!ERRORLEVEL!"=="0" ( + echo [WARNING] Signing failed or signtool missing - the exe is NOT signed. + echo Smart App Control machines will still block it. + ) else ( + echo [OK] Executable signed successfully. + ) +) else ( + echo [INFO ] No signing cert found - skipping signing. + echo [INFO ] To sign automatically, set KIWY_SIGN_PFX to your .pfx path + echo or place "kiwy_signing.pfx" in this folder. + echo [INFO ] NOTE: Unsigned exe will be BLOCKED on PCs with Smart App Control ON. +) + REM ---- Success ---- echo. echo ============================================ diff --git a/windows/development-track.md b/windows/development-track.md index 5064014..e102338 100644 --- a/windows/development-track.md +++ b/windows/development-track.md @@ -6,7 +6,7 @@ --- -## ๐Ÿ“… Current Session โ€” 2026-07-31 +## ๐Ÿ“… Current Session โ€” 2026-08-07 | Field | Value | |-------|-------| @@ -14,9 +14,37 @@ | **Python** | 3.12.9 โ€” `windows\venv\` (250 MB, all deps installed) | | **Kivy** | 2.3.1 | | **PyInstaller** | 6.21.0 | -| **Last .exe build** | 2026-07-26 16:53 โ€” `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) | +| **Last .exe build** | 2026-08-07 08:23 โ€” `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (98.8 MB) | | **Build command** | `.\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` | +### Overnight soak test findings (2026-08-07 morning) + +- **Symptom:** player froze on the last video widget (never advanced past + `video_loaded`); heartbeat stopped; 7 leaked `msedge.exe` processes left + running in the background. +- **Root cause (two compounding bugs in `run_win.py`):** + 1. **Leaked browser + instant-exit handoff.** A previously leaked Chrome/Edge + process held the `.kiosk-profile` lock. The next weblink launch handed + the URL to that leaked instance and **exited in ~2s** (`07:04:57` launch โ†’ + `07:04:59 next_media_called`). The watchdog advanced instantly, so the + weblink never showed AND the leaked browser window was never killed โ†’ + `msedge.exe` processes accumulated overnight. + 2. **Main-thread freeze.** The focus keeper ran heavy Win32 work + (`EnumWindows` + `AttachThreadInput` + `SetForegroundWindow` + `SendInput`) + synchronously on the Kivy thread every second. With leaked Edge windows + fighting back, this wedged the event loop โ†’ video never advanced, heartbeat + stopped (`08-07 07:09`). +- **Fix (in `run_win.py`, rebuilt 08:23):** + 1. New `_windows_kill_browsers_on_profile()` โ€” scans `chrome/msedge/chromium` + command lines (WMIC, PowerShell fallback), taskkills any browser holding + the `.kiosk-profile` lock. Called **before every weblink launch**. + 2. Watchdog now has `MIN_ALIVE_BEFORE_EARLY_ADVANCE = 8s` โ€” an instant + (~2s) handoff exit no longer advances/skips the weblink. + 3. `_bring_kivy_to_front(async_ok=True)` runs the heavy Win32 bring-to-front + on a **background worker thread** guarded by a lock, so the Kivy main + thread is never blocked. Synchronous `async_ok=False` still available for + explicit transitions. + ### ๐Ÿ“‹ Cross-platform audit โ€” Linux commands โ†’ Windows handling Every Linux-only command in `src/` was cross-referenced against the patches diff --git a/windows/pyi_runtime_hook.py b/windows/pyi_runtime_hook.py index 312c171..9477482 100644 --- a/windows/pyi_runtime_hook.py +++ b/windows/pyi_runtime_hook.py @@ -10,6 +10,37 @@ import sys import platform from pathlib import Path + +def _set_process_dpi_awareness(): + """Declare per-monitor DPI awareness BEFORE SDL/Kivy initialize. + + On a display scaled above 100% (e.g. 1920x1080 @ 125%), Windows + virtualizes a non-DPI-aware app to the scaled-down size (1536x864). + Kivy then sizes its content area to the virtualized resolution, leaving a + black strip on one side and making images/videos render at the wrong size. + Must run before any SDL window is created, so this lives in the runtime + hook (the first Python code that runs in the frozen app). + """ + try: + try: + aware = ctypes.c_int(2) # PROCESS_PER_MONITOR_DPI_AWARE_V2 + ctypes.windll.shcore.SetProcessDpiAwareness(aware) + return + except Exception: + pass + try: + ctypes.windll.user32.SetProcessDPIAware() + return + except Exception: + pass + except Exception: + pass + + +if platform.system() == 'Windows': + import ctypes + _set_process_dpi_awareness() + # โ”€โ”€ IMPORTANT: Set Windows environment BEFORE any Kivy code runs โ”€โ”€ # This must happen before main.py's top-level code executes, because # main.py sets SDL_VIDEODRIVER=wayland,x11,dummy which would crash on Windows. @@ -23,6 +54,8 @@ os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8' os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0' os.environ['KIVY_NO_FILELOG'] = '1' os.environ['KIVY_INPUTPROVIDERS'] = '' # Let Kivy auto-detect on Windows +# Use native physical pixels (fixes black strip on DPI-scaled displays). +os.environ.setdefault('SDL_VIDEO_HIGHDPI', '1') # โ”€โ”€ Capture ALL early output to a crash log โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Ensure we catch any exception that happens before Logger is available.