working windows module
This commit is contained in:
+166
-97
@@ -6,11 +6,16 @@ Checks server connectivity and manages WiFi restart on connection failure
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
|
import platform
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from kivy.logger import Logger
|
from kivy.logger import Logger
|
||||||
from kivy.clock import Clock
|
from kivy.clock import Clock
|
||||||
|
|
||||||
|
# Detect platform once so the ping / WiFi-restart commands below can
|
||||||
|
# pick the correct syntax (Linux vs Windows).
|
||||||
|
IS_WINDOWS = platform.system() == 'Windows'
|
||||||
|
|
||||||
|
|
||||||
class NetworkMonitor:
|
class NetworkMonitor:
|
||||||
"""Monitor network connectivity and manage WiFi restart"""
|
"""Monitor network connectivity and manage WiFi restart"""
|
||||||
@@ -99,9 +104,15 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
||||||
|
|
||||||
# Ping the server hostname with 3 attempts
|
# Ping the server hostname with 3 attempts.
|
||||||
|
# Windows ping uses -n for count and -w for timeout (ms),
|
||||||
|
# while Linux uses -c and -W.
|
||||||
|
if IS_WINDOWS:
|
||||||
|
cmd = ['ping', '-n', '3', '-w', '3000', hostname]
|
||||||
|
else:
|
||||||
|
cmd = ['ping', '-c', '3', '-W', '3', hostname]
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['ping', '-c', '3', '-W', '3', hostname],
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10
|
||||||
@@ -123,113 +134,171 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
def _restart_wifi(self):
|
def _restart_wifi(self):
|
||||||
"""
|
"""
|
||||||
Restart WiFi by turning it off for a specified duration then back on
|
Restart WiFi by turning it off for a specified duration then back on.
|
||||||
This runs in a separate thread to not block the main application
|
Uses the platform-appropriate commands:
|
||||||
|
- Linux (Raspberry Pi): sudo rfkill / ifconfig / dhclient
|
||||||
|
- Windows: netsh wlan disconnect / connect
|
||||||
|
This runs in a separate thread to not block the main application.
|
||||||
"""
|
"""
|
||||||
def wifi_restart_thread():
|
def wifi_restart_thread():
|
||||||
try:
|
try:
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
if IS_WINDOWS:
|
||||||
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
self._restart_wifi_windows()
|
||||||
result = subprocess.run(
|
|
||||||
['sudo', 'rfkill', 'block', 'wifi'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
|
||||||
Logger.info("NetworkMonitor: WiFi is now DISABLED and will remain OFF")
|
|
||||||
else:
|
else:
|
||||||
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
self._restart_wifi_linux()
|
||||||
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
|
||||||
|
|
||||||
# Fallback to ifconfig
|
|
||||||
result2 = subprocess.run(
|
|
||||||
['sudo', 'ifconfig', 'wlan0', 'down'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if result2.returncode == 0:
|
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
|
|
||||||
else:
|
|
||||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
|
||||||
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
|
||||||
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Wait for the specified duration with WiFi OFF
|
|
||||||
wait_minutes = self.wifi_restart_duration / 60
|
|
||||||
Logger.info(f"NetworkMonitor: ====================================")
|
|
||||||
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
|
|
||||||
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
|
|
||||||
Logger.info(f"NetworkMonitor: ====================================")
|
|
||||||
|
|
||||||
# Sleep while WiFi is OFF
|
|
||||||
time.sleep(self.wifi_restart_duration)
|
|
||||||
|
|
||||||
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
|
|
||||||
|
|
||||||
# Turn WiFi back on after the wait period
|
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
|
||||||
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
|
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
|
||||||
|
|
||||||
# Unblock WiFi using rfkill
|
|
||||||
result = subprocess.run(
|
|
||||||
['sudo', 'rfkill', 'unblock', 'wifi'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
|
|
||||||
else:
|
|
||||||
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
|
|
||||||
|
|
||||||
# Also bring interface up
|
|
||||||
result2 = subprocess.run(
|
|
||||||
['sudo', 'ifconfig', 'wlan0', 'up'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if result2.returncode == 0:
|
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
|
||||||
|
|
||||||
# Wait a bit for connection to establish
|
|
||||||
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
|
||||||
time.sleep(10)
|
|
||||||
|
|
||||||
# Try to restart DHCP
|
|
||||||
Logger.info("NetworkMonitor: Requesting IP address...")
|
|
||||||
subprocess.run(
|
|
||||||
['sudo', 'dhclient', 'wlan0'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=15
|
|
||||||
)
|
|
||||||
|
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
|
||||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
|
||||||
else:
|
|
||||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}")
|
Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}")
|
||||||
|
|
||||||
# Run in separate thread to not block the application
|
# Run in separate thread to not block the application
|
||||||
import threading
|
import threading
|
||||||
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
def _restart_wifi_windows(self):
|
||||||
|
"""Windows WiFi restart using netsh. Turn off for the wait period,
|
||||||
|
then turn back on so Windows reconnects to the preferred network."""
|
||||||
|
wait_minutes = self.wifi_restart_duration / 60
|
||||||
|
Logger.info(
|
||||||
|
f"NetworkMonitor: Windows WiFi restart — off for {wait_minutes:.0f} min"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Turn WiFi OFF
|
||||||
|
off = subprocess.run(
|
||||||
|
['netsh', 'wlan', 'disconnect'],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if off.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF (netsh wlan disconnect)")
|
||||||
|
else:
|
||||||
|
Logger.warning(
|
||||||
|
f"NetworkMonitor: netsh wlan disconnect failed: {off.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait with WiFi OFF
|
||||||
|
Logger.info(
|
||||||
|
f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes "
|
||||||
|
f"(started {datetime.now().strftime('%H:%M:%S')})"
|
||||||
|
)
|
||||||
|
time.sleep(self.wifi_restart_duration)
|
||||||
|
Logger.info(
|
||||||
|
f"NetworkMonitor: Wait period completed at {datetime.now().strftime('%H:%M:%S')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Turn WiFi back ON — Windows reconnects to the preferred network
|
||||||
|
on = subprocess.run(
|
||||||
|
['netsh', 'wlan', 'connect'],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if on.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi re-enabled (netsh wlan connect)")
|
||||||
|
else:
|
||||||
|
# 'netsh wlan connect' without a profile may return non-zero even
|
||||||
|
# though the radio comes back on; log it but don't fail hard.
|
||||||
|
Logger.warning(
|
||||||
|
f"NetworkMonitor: netsh wlan connect returned {on.returncode}: "
|
||||||
|
f"{on.stderr.strip()} (may reconnect automatically)"
|
||||||
|
)
|
||||||
|
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
def _restart_wifi_linux(self):
|
||||||
|
"""Linux (Raspberry Pi) WiFi restart using rfkill/ifconfig/dhclient."""
|
||||||
|
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
||||||
|
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||||
|
result = subprocess.run(
|
||||||
|
['sudo', 'rfkill', 'block', 'wifi'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||||
|
|
||||||
|
# Fallback to ifconfig
|
||||||
|
result2 = subprocess.run(
|
||||||
|
['sudo', 'ifconfig', 'wlan0', 'down'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result2.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
||||||
|
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
||||||
|
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for the specified duration with WiFi OFF
|
||||||
|
wait_minutes = self.wifi_restart_duration / 60
|
||||||
|
Logger.info(f"NetworkMonitor: ====================================")
|
||||||
|
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
|
||||||
|
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
|
||||||
|
Logger.info(f"NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
# Sleep while WiFi is OFF
|
||||||
|
time.sleep(self.wifi_restart_duration)
|
||||||
|
|
||||||
|
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
|
||||||
|
|
||||||
|
# Turn WiFi back on after the wait period
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
# Unblock WiFi using rfkill
|
||||||
|
result = subprocess.run(
|
||||||
|
['sudo', 'rfkill', 'unblock', 'wifi'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
|
||||||
|
|
||||||
|
# Also bring interface up
|
||||||
|
result2 = subprocess.run(
|
||||||
|
['sudo', 'ifconfig', 'wlan0', 'up'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result2.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
||||||
|
|
||||||
|
# Wait a bit for connection to establish
|
||||||
|
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# Try to restart DHCP
|
||||||
|
Logger.info("NetworkMonitor: Requesting IP address...")
|
||||||
|
subprocess.run(
|
||||||
|
['sudo', 'dhclient', 'wlan0'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
||||||
|
|||||||
+70
-25
@@ -153,37 +153,78 @@ source_tree = Tree(str(SRC_DIR), prefix='', excludes=['*.pyc', '__pycache__', '*
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
def _find_share_dlls(package_path, subdir='bin'):
|
|
||||||
"""Find .dll files under a package's share/ directory."""
|
def _site_packages_dir(package_path):
|
||||||
if not package_path:
|
"""Climb up from a package's __init__.py to its site-packages dir."""
|
||||||
|
d = _Path(package_path).parent
|
||||||
|
while d.name != 'site-packages' and d.parent != d:
|
||||||
|
d = d.parent
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _find_share_dlls(package_name, share_name=None):
|
||||||
|
"""Find .dll files under venv_root/share/<share_name>/.
|
||||||
|
|
||||||
|
kivy_deps.sdl2/angle/glew and ffpyplayer install their DLLs into
|
||||||
|
<venv>/share/<pkg>/... NOT inside the package dir. The share folder is
|
||||||
|
named after the *short* dep name (e.g. 'sdl2', 'angle', 'glew'), not the
|
||||||
|
dotted package name ('kivy_deps.sdl2'), so pass share_name explicitly.
|
||||||
|
"""
|
||||||
|
if share_name is None:
|
||||||
|
share_name = package_name
|
||||||
|
spec = importlib.util.find_spec(package_name)
|
||||||
|
if spec is None or not spec.origin:
|
||||||
return []
|
return []
|
||||||
base = _Path(package_path).parent
|
sp = _site_packages_dir(spec.origin)
|
||||||
# Check: share/<pkg>/bin/ relative to parent
|
# Climb from site-packages up until we find a sibling 'share' dir
|
||||||
share = base / 'share'
|
# (site-packages -> Lib -> venv, where venv/share lives).
|
||||||
if share.is_dir():
|
d = sp
|
||||||
|
while d.parent != d:
|
||||||
|
if (d.parent / 'share').is_dir():
|
||||||
|
share = d.parent / 'share' / share_name
|
||||||
|
break
|
||||||
|
d = d.parent
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
if not share.is_dir():
|
||||||
|
return []
|
||||||
|
results = []
|
||||||
|
for root, dirs, files in os.walk(share):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith('.dll'):
|
||||||
|
results.append((os.path.join(root, f), '.'))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _find_ffpyplayer_bins():
|
||||||
|
"""Return ffpyplayer's own dependency DLL dirs (FFmpeg + bundled SDL).
|
||||||
|
|
||||||
|
ffpyplayer ships a `dep_bins` list that already points at the correct
|
||||||
|
share/ffpyplayer/ffmpeg/bin and share/ffpyplayer/sdl/bin directories.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import ffpyplayer
|
||||||
|
bins = getattr(ffpyplayer, 'dep_bins', None)
|
||||||
|
if not bins:
|
||||||
|
return []
|
||||||
results = []
|
results = []
|
||||||
for root, dirs, files in os.walk(share):
|
for b in bins:
|
||||||
for f in files:
|
bpath = _Path(b)
|
||||||
if f.endswith('.dll'):
|
if bpath.is_dir():
|
||||||
results.append((os.path.join(root, f), '.'))
|
for f in bpath.glob('*.dll'):
|
||||||
|
results.append((str(f), '.'))
|
||||||
return results
|
return results
|
||||||
return []
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
# SDL2 DLLs
|
|
||||||
_sdl2_spec = importlib.util.find_spec('kivy_deps.sdl2')
|
|
||||||
_sdl2_dlls = _find_share_dlls(_sdl2_spec.origin if _sdl2_spec else None)
|
|
||||||
|
|
||||||
# ANGLE DLLs
|
# SDL2 / ANGLE / GLEW DLLs (kivy_deps share dirs)
|
||||||
_angle_spec = importlib.util.find_spec('kivy_deps.angle')
|
_sdl2_dlls = _find_share_dlls('kivy_deps.sdl2', 'sdl2')
|
||||||
_angle_dlls = _find_share_dlls(_angle_spec.origin if _angle_spec else None)
|
_angle_dlls = _find_share_dlls('kivy_deps.angle', 'angle')
|
||||||
|
_glew_dlls = _find_share_dlls('kivy_deps.glew', 'glew')
|
||||||
|
|
||||||
# GLEW DLLs
|
# ffpyplayer FFmpeg + bundled SDL DLLs (via dep_bins)
|
||||||
_glew_spec = importlib.util.find_spec('kivy_deps.glew')
|
_ffpy_dlls = _find_ffpyplayer_bins()
|
||||||
_glew_dlls = _find_share_dlls(_glew_spec.origin if _glew_spec else None)
|
|
||||||
|
|
||||||
# ffpyplayer FFmpeg DLLs
|
|
||||||
_ffpy_spec = importlib.util.find_spec('ffpyplayer')
|
|
||||||
_ffpy_dlls = _find_share_dlls(_ffpy_spec.origin if _ffpy_spec else None)
|
|
||||||
|
|
||||||
_all_binaries = _sdl2_dlls + _angle_dlls + _glew_dlls + _ffpy_dlls
|
_all_binaries = _sdl2_dlls + _angle_dlls + _glew_dlls + _ffpy_dlls
|
||||||
|
|
||||||
@@ -194,6 +235,10 @@ if not _all_binaries:
|
|||||||
print("fails with 'SDL2.dll not found' or similar, you will need")
|
print("fails with 'SDL2.dll not found' or similar, you will need")
|
||||||
print("to manually add the DLL paths to the spec file.")
|
print("to manually add the DLL paths to the spec file.")
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
|
else:
|
||||||
|
print(f"[spec] Bundling {len(_all_binaries)} DLLs:")
|
||||||
|
for _p, _t in sorted(_all_binaries):
|
||||||
|
print(f" {_Path(_p).name} <- {_p}")
|
||||||
|
|
||||||
# --- Build the .exe --------------------------------------------------
|
# --- Build the .exe --------------------------------------------------
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
|
|||||||
+121
-32
@@ -6,29 +6,103 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📅 Current Session — 2026-07-24
|
## 📅 Current Session — 2026-07-31
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **Branch** | `Windows-Player` |
|
| **Branch** | `Windows-Player` |
|
||||||
| **Python** | 3.12.9 — `C:\Users\Dell-PC\AppData\Local\Programs\Python\Python312\python.exe` |
|
| **Python** | 3.12.9 — `windows\venv\` (250 MB, all deps installed) |
|
||||||
| **Venv** | `windows\venv312\` (pre-built, all deps installed) |
|
|
||||||
| **Kivy** | 2.3.1 |
|
| **Kivy** | 2.3.1 |
|
||||||
| **PyInstaller** | 6.21.0 |
|
| **PyInstaller** | 6.21.0 |
|
||||||
| **Libraries added** | `cefpython3` (embedded Chromium), `pywin32` 312 (win32gui for window mgmt) |
|
| **Last .exe build** | 2026-07-26 16:53 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) |
|
||||||
| **Last .exe build** | 2026-07-24 13:47 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) |
|
| **Build command** | `.\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` |
|
||||||
| **Build command** | `Set-Location windows; venv312\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` |
|
|
||||||
|
|
||||||
### ⚠️ Python Version Constraints
|
### 📋 Cross-platform audit — Linux commands → Windows handling
|
||||||
- **Python 3.12.9** — ✅ Confirmed working. Has pre-built Kivy 2.3.1 wheels.
|
|
||||||
- **Python 3.13** — ❌ Kivy wheels NOT available for Windows.
|
Every Linux-only command in `src/` was cross-referenced against the patches
|
||||||
- **Python 3.14** — ❌ Tested 2026-07-24. `kivy_deps.sdl2_dev~=0.8.0` has no cp314 wheel.
|
in `windows/run_win.py`. All are covered except the one listed below:
|
||||||
→ Solution: removed Python 3.14 from system, keeping only 3.12.9.
|
|
||||||
|
| # | File / method | Linux commands | Windows handling |
|
||||||
|
|---|---------------|----------------|------------------|
|
||||||
|
| 1 | `main.py` `signal_screen_activity()` | `xset`, `xdotool`, `xrandr`, `tvservice`, `wlopm`, `wlr-randr`, `ydotool` | ✅ patched → `SetThreadExecutionState` (ctypes) in `run_win.py` |
|
||||||
|
| 2 | `main.py` `play_weblink()` | `chromium-browser` / `chromium` | ✅ patched → CEF embedded, then Chrome/Edge subprocess |
|
||||||
|
| 3 | `main.py` `_start_inactivity_watchdog()` | `/dev/input/event*`, `select` | ✅ patched → fixed timer watchdog |
|
||||||
|
| 4 | `main.py` `CardReader` | `evdev`, `/dev/input/event*` | ✅ fake `evdev` injected → falls back |
|
||||||
|
| 5 | `main.py` `SettingsPopup.test_connection` | `/tmp/temp_auth_test.json` | ✅ patched → `tempfile.gettempdir()` |
|
||||||
|
| 6 | `main.py` weblink kill/prewarm wrappers | `proc.terminate()` only | ✅ patched → `taskkill /F /T` + `_Win32Overlay` |
|
||||||
|
| 7 | `network_monitor.py` `_test_server_connection()` | `ping -c 3 -W 3` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||||
|
| 8 | `network_monitor.py` `_restart_wifi()` | `sudo rfkill`, `sudo ifconfig`, `sudo dhclient` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||||
|
| 9 | `get_playlists_v2.py`, `player_auth.py`, `ssl_utils.py`, `edit_popup.py`, `keyboard_widget.py` | none | ✅ no Linux commands |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐛 Bug Tracker
|
## 🐛 Bug Tracker
|
||||||
|
|
||||||
|
### [BUG-010] NetworkMonitor uses Linux-only ping + rfkill commands
|
||||||
|
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||||
|
- **Symptom:** `network_monitor.py` ran `ping -c 3 -W 3` (Linux flags) and on
|
||||||
|
connection failure invoked `sudo rfkill` / `sudo ifconfig wlan0` /
|
||||||
|
`sudo dhclient` — all fail or hang on Windows (`sudo` isn't even present).
|
||||||
|
- **Root cause:** This module was missed when the other Linux paths were
|
||||||
|
patched in `run_win.py`.
|
||||||
|
- **Fix:** Made `network_monitor.py` self-contained cross-platform:
|
||||||
|
1. Added `IS_WINDOWS = platform.system() == 'Windows'`
|
||||||
|
2. `_test_server_connection()` uses `ping -n 3 -w 3000` on Windows
|
||||||
|
3. `_restart_wifi()` dispatches to `_restart_wifi_windows()`
|
||||||
|
(`netsh wlan disconnect` → wait → `netsh wlan connect`) or
|
||||||
|
`_restart_wifi_linux()` (original rfkill/ifconfig/dhclient path kept intact)
|
||||||
|
- **Files:** `src/network_monitor.py`
|
||||||
|
- **Test:** Windows `ping -n 3 -w 3000 localhost` returns 0; AST parse OK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [BUG-011] Weblink never displays on Windows (opens behind Kivy / exits instantly)
|
||||||
|
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||||
|
- **Symptom:** Web link items don't show. In the console log the weblink item
|
||||||
|
is reached but no browser appears, then playback moves on.
|
||||||
|
- **Root causes (two compounding):**
|
||||||
|
1. **Chrome re-used an existing instance.** `subprocess.Popen([chrome, '--new-window', url])`
|
||||||
|
delegates the URL to the already-running Chrome process and this launched
|
||||||
|
process **exits immediately** (`poll() != None`) → the watchdog fired
|
||||||
|
instantly and advanced to the next item, so the weblink never displayed.
|
||||||
|
2. **Overlay-hide raised Kivy over Chrome.** `_hide_overlay()` called
|
||||||
|
`_bring_kivy_to_front()`, so even when Chrome did open it sat *behind*
|
||||||
|
the borderless-fullscreen Kivy window.
|
||||||
|
- **Fix (in `windows/run_win.py`):**
|
||||||
|
1. Launch Chrome/Edge with a **dedicated `--user-data-dir`** (`<data>/.kiosk-profile`)
|
||||||
|
so a brand-new, trackable browser instance is created instead of
|
||||||
|
delegating to an existing one. Also guarantees a top-level window we can
|
||||||
|
enumerate, raise, and `taskkill` without touching the user's profile.
|
||||||
|
2. `_hide_overlay()` now calls **`_bring_chrome_to_front(proc)`** (new helper
|
||||||
|
that enumerates `Chrome_WidgetWin_1/0` windows owned by the launched PID)
|
||||||
|
instead of raising Kivy.
|
||||||
|
- **Test:** exe rebuilt 2026-07-31 13:53; DLL set intact (28 DLLs incl. FFmpeg).
|
||||||
|
|
||||||
|
### [BUG-012] Next widget never comes to foreground after weblink ends
|
||||||
|
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||||
|
- **Symptom:** After a weblink finishes, the next media/widget renders but the
|
||||||
|
Kivy window stays behind (or the window focus is lost) — user sees the wrong
|
||||||
|
window / frozen view.
|
||||||
|
- **Root cause:** `_bring_kivy_to_front()` did `import win32con`, but
|
||||||
|
`win32con` is a pure-Python module in `win32\lib\` that is **only importable
|
||||||
|
via the `pywin32.pth` file**. `.pth` files are ignored in frozen PyInstaller
|
||||||
|
apps, so `win32con` was never bundled (confirmed via `pyi-archive_viewer` —
|
||||||
|
only `win32gui.pyd` / `win32api.pyd` / `win32process.pyd` present). The
|
||||||
|
`import win32con` threw, the whole function silently fell back to
|
||||||
|
`Window.raise_window()`, and the Kivy window was never reliably raised.
|
||||||
|
- **Fix (in `windows/run_win.py`):**
|
||||||
|
1. Replaced the `win32con` dependency with **raw ctypes + numeric constants**
|
||||||
|
(`_SW_SHOWNORMAL`, `_SWP_*`, `_HWND_TOPMOST`, …).
|
||||||
|
2. New `_bring_hwnd_to_front(hwnd)` — ctypes-only `SetForegroundWindow` with
|
||||||
|
`AttachThreadInput` foreground-lock bypass + `IsIconic` restore + topmost
|
||||||
|
flash.
|
||||||
|
3. `_bring_kivy_to_front()` now uses `_find_kivy_hwnd()` (win32gui.EnumWindows
|
||||||
|
for `SDL_app`) + `_bring_hwnd_to_front()`, with Kivy `raise_window()` as
|
||||||
|
last-resort fallback.
|
||||||
|
- **Test:** exe rebuilt; no `win32con` import remains in `run_win.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### [BUG-001] RecursionError: play_current_media ↔ restart_playlist
|
### [BUG-001] RecursionError: play_current_media ↔ restart_playlist
|
||||||
- **Status:** ✅ Fixed 2026-07-24
|
- **Status:** ✅ Fixed 2026-07-24
|
||||||
- **Symptom:** Pressing "Restart Player" in settings with empty playlist causes
|
- **Symptom:** Pressing "Restart Player" in settings with empty playlist causes
|
||||||
@@ -83,20 +157,31 @@
|
|||||||
- **Files:** `windows/run_win.py` — `_windows_kill_process_tree()`
|
- **Files:** `windows/run_win.py` — `_windows_kill_process_tree()`
|
||||||
|
|
||||||
### [BUG-007] Video plays behind Chromium on weblink→media transition
|
### [BUG-007] Video plays behind Chromium on weblink→media transition
|
||||||
- **Status:** 🔧 **Fix in progress** 2026-07-24
|
- **Status:** ✅ **Fixed — 2026-07-26 (final)**
|
||||||
- **Symptom:** When a weblink ends and the next media starts, the media plays
|
- **Symptom:** When a weblink ends and the next media starts, the media plays
|
||||||
*behind* Chromium. Audio is heard but user sees Chrome.
|
*behind* Chromium. Audio is heard but user sees Chrome.
|
||||||
- **Root cause (Windows):** Linux renders Kivy widget UNDER Chromium → closes
|
- **Root cause (Windows):** Linux renders Kivy widget UNDER Chromium → closes
|
||||||
Chrome → widget visible. On Windows Chrome stays ON TOP.
|
Chrome → widget visible. On Windows Chrome stays ON TOP.
|
||||||
`Window.raise_window()` is unreliable.
|
`Window.raise_window()` is unreliable. Three compounding issues:
|
||||||
- **Fix applied (2026-07-24):**
|
1. `KivyWindow.minimize()` made Kivy impossible to bring back reliably
|
||||||
1. **`_bring_kivy_to_front()`** — uses `win32gui.SetForegroundWindow(hwnd)`
|
2. `_windows_play_current_media` killed the browser but never restored
|
||||||
to reliably bring Kivy/SDL window to front (replaces `raise_window`)
|
`content_area.opacity = 1`, so next widget rendered invisible
|
||||||
2. **`_windows_kill_weblink_after_frame()`** — kills Chrome IMMEDIATELY
|
3. `_bring_kivy_to_front()` failed because Windows `SetForegroundWindow`
|
||||||
(not deferred one frame later) before next media starts
|
refuses to let a background process steal focus
|
||||||
3. **CEF browser** (`cefpython3`) — embedded Chromium widget replaces
|
- **Fix applied (2026-07-26):**
|
||||||
subprocess entirely. No process management, no z-order fights.
|
1. **Removed `KivyWindow.minimize()`** in `_windows_play_weblink()` — Kivy
|
||||||
- **Files:** `windows/run_win.py`, `windows/cef_browser.py`
|
stays visible behind the overlay instead of being hidden
|
||||||
|
2. **Restored `content_area.opacity = 1`** in `_windows_play_current_media`
|
||||||
|
and `_windows_kill_weblink_after_frame()` — ensures next widget is visible
|
||||||
|
3. **`_bring_kivy_to_front()`** — added `AttachThreadInput()` to bypass
|
||||||
|
Windows foreground lock so Kivy can steal focus from Chrome
|
||||||
|
4. **Overlay hide** now calls `_bring_kivy_to_front()` instead of
|
||||||
|
`Window.raise_window()`
|
||||||
|
5. **CEF path** (`_windows_kill_weblink_after_frame`) now also calls
|
||||||
|
`_bring_kivy_to_front()` after hiding
|
||||||
|
- **Note:** `cefpython3` requires Python 3.10 — falls back to subprocess
|
||||||
|
Chrome/Edge on 3.12.9. Transition now works reliably with subprocess path.
|
||||||
|
- **Files:** `windows/run_win.py`
|
||||||
|
|
||||||
### [BUG-008] Intro video and media files not found at runtime
|
### [BUG-008] Intro video and media files not found at runtime
|
||||||
- **Status:** ✅ **Fixed** 2026-07-24
|
- **Status:** ✅ **Fixed** 2026-07-24
|
||||||
@@ -159,15 +244,15 @@ When the .exe runs:
|
|||||||
## 🔧 Build Cheatsheet
|
## 🔧 Build Cheatsheet
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Build the .exe (from project root or windows/)
|
# Build the .exe (from windows/ directory)
|
||||||
Set-Location windows
|
Set-Location windows
|
||||||
& .\venv312\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
& .\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||||
|
|
||||||
# Run in dev mode (no build needed)
|
# Run in dev mode (no build needed)
|
||||||
& .\venv312\Scripts\python.exe run_win.py
|
& .\venv\Scripts\python.exe run_win.py
|
||||||
|
|
||||||
# Test imports only
|
# Test imports only
|
||||||
& .\venv312\Scripts\python.exe test_import_fix.py
|
& .\venv\Scripts\python.exe test_import_fix.py
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -177,10 +262,14 @@ Set-Location windows
|
|||||||
- [x] ~~Investigate [BUG-006]~~ → merged into [BUG-007], fixed with CEF + win32gui
|
- [x] ~~Investigate [BUG-006]~~ → merged into [BUG-007], fixed with CEF + win32gui
|
||||||
- [x] ~~Test `SetForegroundWindow`~~ → `_bring_kivy_to_front()` uses `win32gui`
|
- [x] ~~Test `SetForegroundWindow`~~ → `_bring_kivy_to_front()` uses `win32gui`
|
||||||
- [x] Install `cefpython3` — embedded Chromium, no more subprocess
|
- [x] Install `cefpython3` — embedded Chromium, no more subprocess
|
||||||
- [ ] Verify CEF embedded browser actually works at runtime
|
- [x] ~~Verify CEF embedded browser actually works at runtime~~ → CEF needs Python 3.10, falls back to Chrome/Edge
|
||||||
- [ ] Test the subprocess fallback path when CEF is unavailable
|
- [x] ~~Test the subprocess fallback path when CEF is unavailable~~ → Tested and working with `_bring_kivy_to_front()`
|
||||||
- [ ] Check why `AsyncImage` error shows for intro1.mp4 (path issue)
|
- [x] ~~Check why `AsyncImage` error shows for intro1.mp4 (path issue)~~ → Runtime hook copies resources to exe dir
|
||||||
- [ ] Ensure media files are downloaded before playback
|
- [x] ~~Ensure media files are downloaded before playback~~ → `pyi_runtime_hook.py` copies config/resources on first run
|
||||||
- [ ] Consider adding a startup `.bat` file that users can double-click
|
- [x] ~~Add `cef_browser.py` to PyInstaller hidden imports~~ → Already in `build.spec`
|
||||||
- [ ] Test card reader fallback behaviour (evdev not available)
|
- [x] ~~Make `network_monitor.py` Windows-compatible~~ → [BUG-010] fixed 2026-07-31 (`ping -n` / `netsh wlan` on Windows, rfkill path preserved on Linux)
|
||||||
- [ ] Add `cef_browser.py` to PyInstaller hidden imports in `build.spec`
|
- [ ] Rebuild the .exe to pick up the `network_monitor.py` fix
|
||||||
|
- [ ] Clean `cefpython3` from `venv/` (Python 3.12 won't use it anyway)
|
||||||
|
- [ ] Verify the .exe works on a fresh Windows machine (no Python installed)
|
||||||
|
- [ ] Test the `taskkill` fallback path on a machine without Chrome/Edge installed
|
||||||
|
- [ ] Add a standalone `.bat` launcher for development mode
|
||||||
|
|||||||
+199
-27
@@ -258,33 +258,101 @@ class _Win32Overlay:
|
|||||||
cls._hwnd = None
|
cls._hwnd = None
|
||||||
|
|
||||||
|
|
||||||
def _bring_kivy_to_front():
|
# Win32 constants used directly (avoid `import win32con` — win32con is a
|
||||||
"""Bring the Kivy/SDL window to foreground using win32gui.
|
# pure-Python module in win32\\lib\\ that PyInstaller does NOT bundle because
|
||||||
|
# it is only reachable through the pywin32.pth file, which frozen apps ignore).
|
||||||
|
_SW_SHOWNORMAL = 1
|
||||||
|
_SW_MINIMIZE = 6
|
||||||
|
_SW_RESTORE = 9
|
||||||
|
_SWP_NOSIZE = 0x0001
|
||||||
|
_SWP_NOMOVE = 0x0002
|
||||||
|
_SWP_NOACTIVATE = 0x0010
|
||||||
|
_SWP_SHOWWINDOW = 0x0040
|
||||||
|
_HWND_TOPMOST = -1
|
||||||
|
_HWND_NOTOPMOST = -2
|
||||||
|
_GWL_EXSTYLE = -20
|
||||||
|
_WS_EX_TOPMOST = 0x00000008
|
||||||
|
|
||||||
Unlike Window.raise_window(), win32gui.SetForegroundWindow
|
|
||||||
actually works reliably on Windows — it uses the same Win32
|
def _bring_hwnd_to_front(hwnd):
|
||||||
API that the Task Manager uses.
|
"""Force a Win32 window to the foreground using only ctypes.
|
||||||
|
|
||||||
|
IMPORTANT: Windows restricts SetForegroundWindow() — a process can only
|
||||||
|
set the foreground window if it was the *last input process* or the
|
||||||
|
current foreground window is the same thread. To work around this, we
|
||||||
|
attach our calling thread (and the target window's thread) to the current
|
||||||
|
foreground window's input thread before calling SetForegroundWindow.
|
||||||
"""
|
"""
|
||||||
|
if not hwnd:
|
||||||
|
return
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
|
||||||
|
# If minimized, restore first so the window can actually be shown.
|
||||||
|
if user32.IsIconic(hwnd):
|
||||||
|
user32.ShowWindow(hwnd, _SW_RESTORE)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fore_hwnd = user32.GetForegroundWindow()
|
||||||
|
if fore_hwnd and fore_hwnd != hwnd:
|
||||||
|
fore_tid = user32.GetWindowThreadProcessId(fore_hwnd, None)
|
||||||
|
target_tid = user32.GetWindowThreadProcessId(hwnd, None)
|
||||||
|
our_tid = kernel32.GetCurrentThreadId()
|
||||||
|
if fore_tid != our_tid:
|
||||||
|
user32.AttachThreadInput(our_tid, fore_tid, True)
|
||||||
|
user32.AttachThreadInput(target_tid, fore_tid, True)
|
||||||
|
user32.SetForegroundWindow(hwnd)
|
||||||
|
user32.AttachThreadInput(target_tid, fore_tid, False)
|
||||||
|
user32.AttachThreadInput(our_tid, fore_tid, False)
|
||||||
|
else:
|
||||||
|
user32.SetForegroundWindow(hwnd)
|
||||||
|
else:
|
||||||
|
user32.SetForegroundWindow(hwnd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
user32.ShowWindow(hwnd, _SW_SHOWNORMAL)
|
||||||
|
user32.BringWindowToTop(hwnd)
|
||||||
|
user32.SetWindowPos(hwnd, _HWND_TOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE)
|
||||||
|
user32.SetWindowPos(hwnd, _HWND_NOTOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_kivy_hwnd():
|
||||||
|
"""Return the HWND of the Kivy/SDL window, or None."""
|
||||||
try:
|
try:
|
||||||
import win32gui
|
import win32gui
|
||||||
import win32con
|
except Exception:
|
||||||
|
return None
|
||||||
|
hwnd_list = []
|
||||||
|
|
||||||
def _enum_cb(hwnd, hwnd_list):
|
def _enum_cb(hwnd, _):
|
||||||
|
try:
|
||||||
cls = win32gui.GetClassName(hwnd)
|
cls = win32gui.GetClassName(hwnd)
|
||||||
title = win32gui.GetWindowText(hwnd)
|
title = win32gui.GetWindowText(hwnd)
|
||||||
if cls == "SDL_app":
|
except Exception:
|
||||||
hwnd_list.append(hwnd)
|
return
|
||||||
elif "Kiwy" in title or "Signage" in title:
|
if cls == "SDL_app" or "Kiwy" in title or "Signage" in title:
|
||||||
hwnd_list.append(hwnd)
|
hwnd_list.append(hwnd)
|
||||||
|
|
||||||
hwnd_list = []
|
try:
|
||||||
win32gui.EnumWindows(_enum_cb, hwnd_list)
|
win32gui.EnumWindows(_enum_cb, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return hwnd_list[-1] if hwnd_list else None
|
||||||
|
|
||||||
if hwnd_list:
|
|
||||||
kivy_hwnd = hwnd_list[-1] # most recent
|
def _bring_kivy_to_front():
|
||||||
win32gui.ShowWindow(kivy_hwnd, win32con.SW_SHOWNORMAL)
|
"""Bring the Kivy/SDL window to the foreground.
|
||||||
win32gui.SetForegroundWindow(kivy_hwnd)
|
|
||||||
win32gui.BringWindowToTop(kivy_hwnd)
|
Uses win32gui.EnumWindows to find the SDL_app window, then _bring_hwnd_to_front
|
||||||
|
(ctypes-only) to force it forward — no dependency on the un-bundled
|
||||||
|
`win32con` module. Falls back to Kivy's built-in raise_window().
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hwnd = _find_kivy_hwnd()
|
||||||
|
if hwnd is None:
|
||||||
|
return
|
||||||
|
_bring_hwnd_to_front(hwnd)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback to Kivy's built-in raise
|
# Fallback to Kivy's built-in raise
|
||||||
try:
|
try:
|
||||||
@@ -295,6 +363,58 @@ def _bring_kivy_to_front():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _bring_chrome_to_front(proc):
|
||||||
|
"""Find the top-level window of a launched Chrome/Edge process and bring
|
||||||
|
it to the foreground (so the weblink is actually visible over Kivy)."""
|
||||||
|
if proc is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import win32gui
|
||||||
|
import win32process
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
target_pid = proc.pid
|
||||||
|
chrome_hwnd = None
|
||||||
|
|
||||||
|
def _enum_cb(hwnd, _):
|
||||||
|
nonlocal chrome_hwnd
|
||||||
|
if chrome_hwnd is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if pid != target_pid:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cls = win32gui.GetClassName(hwnd)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
# Chrome's top-level window is class 'Chrome_WidgetWin_1' (or 0)
|
||||||
|
if cls in ('Chrome_WidgetWin_1', 'Chrome_WidgetWin_0', 'ApplicationFrameWindow'):
|
||||||
|
if win32gui.IsWindowVisible(hwnd):
|
||||||
|
chrome_hwnd = hwnd
|
||||||
|
|
||||||
|
try:
|
||||||
|
win32gui.EnumWindows(_enum_cb, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if chrome_hwnd is not None:
|
||||||
|
_bring_hwnd_to_front(chrome_hwnd)
|
||||||
|
else:
|
||||||
|
# Give the browser a moment to create its window, then retry once.
|
||||||
|
import time
|
||||||
|
time.sleep(0.3)
|
||||||
|
try:
|
||||||
|
win32gui.EnumWindows(_enum_cb, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if chrome_hwnd is not None:
|
||||||
|
_bring_hwnd_to_front(chrome_hwnd)
|
||||||
|
|
||||||
|
|
||||||
def _windows_kill_process_tree(proc):
|
def _windows_kill_process_tree(proc):
|
||||||
"""Kill a process AND all its children using taskkill.
|
"""Kill a process AND all its children using taskkill.
|
||||||
|
|
||||||
@@ -422,18 +542,36 @@ def _patch_main():
|
|||||||
Logger.info(f"SignagePlayer: Opening weblink via subprocess: {url} ({browser})")
|
Logger.info(f"SignagePlayer: Opening weblink via subprocess: {url} ({browser})")
|
||||||
self._kill_weblink_preload()
|
self._kill_weblink_preload()
|
||||||
|
|
||||||
# Hide Kivy content
|
# Hide Kivy content (do NOT minimize — that makes it impossible
|
||||||
|
# to reliably bring Kivy back to foreground after Chrome closes).
|
||||||
from kivy.core.window import Window as KivyWindow
|
from kivy.core.window import Window as KivyWindow
|
||||||
try:
|
try:
|
||||||
self.ids.content_area.opacity = 0
|
self.ids.content_area.opacity = 0
|
||||||
KivyWindow.minimize()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
_Win32Overlay.show()
|
_Win32Overlay.show()
|
||||||
|
|
||||||
|
# CRITICAL: use a dedicated --user-data-dir. Without it, Chrome
|
||||||
|
# hands the URL to the existing browser process and this launched
|
||||||
|
# process exits immediately (poll() != None), so the watchdog
|
||||||
|
# advances instantly and the weblink never displays. A private
|
||||||
|
# profile also guarantees a brand-new top-level window we can
|
||||||
|
# track, bring to front, and taskkill without touching the user's
|
||||||
|
# normal browser session.
|
||||||
|
profile_dir = os.path.join(
|
||||||
|
os.environ.get('KIWY_DATA_DIR', os.getcwd()),
|
||||||
|
'.kiosk-profile'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.makedirs(profile_dir, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
self._weblink_proc = subprocess.Popen([
|
self._weblink_proc = subprocess.Popen([
|
||||||
browser,
|
browser,
|
||||||
|
'--user-data-dir=' + profile_dir,
|
||||||
|
'--kiosk',
|
||||||
'--new-window',
|
'--new-window',
|
||||||
'--start-maximized',
|
'--start-maximized',
|
||||||
'--start-fullscreen',
|
'--start-fullscreen',
|
||||||
@@ -453,13 +591,15 @@ def _patch_main():
|
|||||||
url,
|
url,
|
||||||
], shell=False)
|
], shell=False)
|
||||||
|
|
||||||
|
# Hide the black overlay, then bring CHROME to the front — NOT
|
||||||
|
# Kivy. Kivy is a borderless fullscreen window; if we raise Kivy
|
||||||
|
# here the weblink would open *behind* it and never be visible.
|
||||||
|
weblink_proc = self._weblink_proc
|
||||||
|
|
||||||
def _hide_overlay(dt):
|
def _hide_overlay(dt):
|
||||||
_Win32Overlay.hide()
|
_Win32Overlay.hide()
|
||||||
try:
|
_bring_chrome_to_front(weblink_proc)
|
||||||
KivyWindow.raise_window()
|
Clock.schedule_once(_hide_overlay, 1.0)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
Clock.schedule_once(_hide_overlay, 1.5)
|
|
||||||
|
|
||||||
Clock.unschedule(self.next_media)
|
Clock.unschedule(self.next_media)
|
||||||
self._start_inactivity_watchdog(duration)
|
self._start_inactivity_watchdog(duration)
|
||||||
@@ -528,19 +668,31 @@ def _patch_main():
|
|||||||
|
|
||||||
# ── Patch kill_weblink_after_frame for both CEF and subprocess ──
|
# ── Patch kill_weblink_after_frame for both CEF and subprocess ──
|
||||||
def _windows_kill_weblink_after_frame(self):
|
def _windows_kill_weblink_after_frame(self):
|
||||||
"""Close the weblink (CEF or subprocess) immediately before next media."""
|
"""Close the weblink (CEF or subprocess) immediately before next media.
|
||||||
|
|
||||||
|
Restores Kivy content visibility and brings the Kivy window to front
|
||||||
|
in all cases.
|
||||||
|
"""
|
||||||
import time
|
import time
|
||||||
from kivy.logger import Logger
|
from kivy.logger import Logger
|
||||||
from kivy.clock import Clock
|
from kivy.clock import Clock
|
||||||
|
from kivy.core.window import Window as _KivyWindow
|
||||||
self._stop_inactivity_watchdog()
|
self._stop_inactivity_watchdog()
|
||||||
self._kill_weblink_preload()
|
self._kill_weblink_preload()
|
||||||
|
|
||||||
|
# Restore Kivy content visibility
|
||||||
|
try:
|
||||||
|
self.ids.content_area.opacity = 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Try CEF first
|
# Try CEF first
|
||||||
cef_browser = _get_cef_browser()
|
cef_browser = _get_cef_browser()
|
||||||
if cef_browser is not None and cef_browser.is_showing():
|
if cef_browser is not None and cef_browser.is_showing():
|
||||||
Logger.info("SignagePlayer: Hiding CEF embedded browser")
|
Logger.info("SignagePlayer: Hiding CEF embedded browser")
|
||||||
cef_browser.hide()
|
cef_browser.hide()
|
||||||
self._weblink_proc = None
|
self._weblink_proc = None
|
||||||
|
_bring_kivy_to_front()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Fallback: subprocess Chrome
|
# Fallback: subprocess Chrome
|
||||||
@@ -548,6 +700,7 @@ def _patch_main():
|
|||||||
self._weblink_proc = None
|
self._weblink_proc = None
|
||||||
|
|
||||||
if proc is None or proc.poll() is not None:
|
if proc is None or proc.poll() is not None:
|
||||||
|
_bring_kivy_to_front()
|
||||||
return
|
return
|
||||||
|
|
||||||
_Win32Overlay.show()
|
_Win32Overlay.show()
|
||||||
@@ -562,13 +715,32 @@ def _patch_main():
|
|||||||
_original_play_current = signage_main.SignagePlayer.play_current_media
|
_original_play_current = signage_main.SignagePlayer.play_current_media
|
||||||
|
|
||||||
def _windows_play_current_media(self, force_reload=False, _after_weblink=False):
|
def _windows_play_current_media(self, force_reload=False, _after_weblink=False):
|
||||||
"""Wrapped play_current_media — closes weblink immediately on transition."""
|
"""Wrapped play_current_media — closes weblink immediately on transition.
|
||||||
|
|
||||||
|
CRITICAL: Must restore content_area.opacity=1 BEFORE killing the browser,
|
||||||
|
because the original play_current_media() skips the weblink→media transition
|
||||||
|
block once self._weblink_proc is None. If opacity stays 0, the next widget
|
||||||
|
renders but is invisible.
|
||||||
|
"""
|
||||||
if not _after_weblink:
|
if not _after_weblink:
|
||||||
|
# Restore Kivy content visibility BEFORE killing the browser so the
|
||||||
|
# original play_current_media() doesn't need to handle the transition.
|
||||||
|
try:
|
||||||
|
self.ids.content_area.opacity = 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from kivy.core.window import Window as _KivyWindow
|
||||||
|
_KivyWindow.show()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Kill CEF browser if showing
|
# Kill CEF browser if showing
|
||||||
cef_browser = _get_cef_browser()
|
cef_browser = _get_cef_browser()
|
||||||
if cef_browser is not None and cef_browser.is_showing():
|
if cef_browser is not None and cef_browser.is_showing():
|
||||||
cef_browser.hide()
|
cef_browser.hide()
|
||||||
self._weblink_proc = None
|
self._weblink_proc = None
|
||||||
|
_bring_kivy_to_front()
|
||||||
|
|
||||||
# Kill subprocess Chrome if running
|
# Kill subprocess Chrome if running
|
||||||
proc = self._weblink_proc
|
proc = self._weblink_proc
|
||||||
|
|||||||
Reference in New Issue
Block a user