9f5409685d
Web links previously launched a separate Chrome/Edge kiosk process, which
caused the whole class of bugs in the tracker: the browser opening behind the
player, being handed off to an already-running instance and exiting instantly,
fighting for foreground/z-order, and leaking msedge.exe/chrome.exe processes
that were never closed.
WebView2 renders as a CHILD HWND of Kivy's own SDL window instead, so there is
no separate top-level browser to open behind the player, nothing to hand the
URL off to, no z-order contest, and no leaked browser process.
Windows/webview2_browser.py
- Environment -> controller -> navigate, driven through pythonnet.
- Async .NET Tasks are polled from Kivy's Clock. Calling GetAwaiter()
.GetResult() would deadlock: the continuation needs the same thread's
message pump.
- The controller is a .NET IntPtr, not a Python int (CreateAsync overloads
do not match otherwise).
- NavigationCompleted is tracked so a page that never loads can be told
apart from one that did. This matters on a closed network: an unreachable
host paints a Chromium error page, and without this the player would show
a blank/error screen for the item's whole slot instead of skipping it.
- is_alive() reports True while starting up. Start-up is async, so a
controller that does not exist yet is not a dead browser; treating it as
one made the first web link after a cold start be skipped instantly.
Windows/webview2_runtime.py
- Detects the Runtime (registry pv value, SDK probe as fallback) and
installs it silently when missing, unelevated, which produces a per-user
install and therefore never raises a UAC prompt on the signage display.
- Success is decided by RE-READING the installed version, not by the
installer exit code: Edge Update returns a non-zero HRESULT
(-2147219416) when the Runtime is already current, which is not a failure.
- On a closed network the online bootstrapper can never succeed, so it fails
fast with an actionable message instead of hanging for the full timeout.
- Failed attempts are cooldown-gated so a broken machine does not re-run an
installer on every start.
Offline hardening
- Browser arguments disable component updates, field trials, safe-browsing
list fetches, translate and other internet chatter. On an isolated LAN
each of those would otherwise have to time out, costing start-up latency.
Pages on the local server are unaffected.
Engine order (best first): WebView2 -> CEF -> Chrome/Edge subprocess. CEF has
no wheels past Python 3.9 so it is dormant on this build; the subprocess engine
remains only as a last resort.
Also fixes the reason the Windows adapters were never used at all:
SignagePlayer.__init__ assigned self.weblink_adapter_factory = None, which
shadowed the CLASS attribute that run_win.py injects. play_weblink() therefore
fell back to the generic adapter, whose find_browser() uses shutil.which() and
finds nothing on Windows because Chrome/Edge are not on PATH. The instance
attribute is now only set when the class attribute is absent.
Verified: windows/test_webview2_embed.py, test_webview2_navigation.py and
test_webview2_offline.py all pass (a locally served page renders with all
internet traffic disabled), and the packaged exe reports
"weblink_launch engine=webview2-embedded" -> "weblink_launched" on every cycle
with no leaked browser processes.
370 lines
12 KiB
RPMSpec
370 lines
12 KiB
RPMSpec
# -*- mode: python ; coding: utf-8 -*-
|
|
"""
|
|
PyInstaller spec file for Kiwy Signage Player (Windows .exe)
|
|
|
|
Build command (from windows/ directory):
|
|
pyinstaller build.spec --clean --noconfirm
|
|
|
|
OR use the build script:
|
|
build_win.bat
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# --- Paths -----------------------------------------------------------
|
|
# This spec file is in windows/build.spec, so the project root is
|
|
# always two levels up from this file's real location.
|
|
# __file__ may not be available in PyInstaller spec context fallback to cwd.
|
|
try:
|
|
_spec_dir = Path(__file__).resolve().parent
|
|
except NameError:
|
|
_spec_dir = Path(os.getcwd()).resolve()
|
|
# _spec_dir is now the absolute path to the windows/ directory
|
|
BUILD_DIR = _spec_dir
|
|
ROOT_DIR = BUILD_DIR.parent
|
|
SRC_DIR = ROOT_DIR / 'src'
|
|
CONFIG_DIR = ROOT_DIR / 'config'
|
|
RESOURCES_DIR = CONFIG_DIR / 'resources'
|
|
|
|
# --- Determine hidden imports that PyInstaller might miss -------------
|
|
hidden_imports = [
|
|
# Kivy core modules
|
|
'kivy.core.window',
|
|
'kivy.core.video',
|
|
'kivy.core.audio',
|
|
'kivy.core.text',
|
|
'kivy.core.image',
|
|
'kivy.core.gl',
|
|
'kivy.core.camera',
|
|
'kivy.core.clipboard',
|
|
'kivy.core.spelling',
|
|
'kivy.core.text.markup',
|
|
'kivy.core.window.window_sdl2',
|
|
'kivy.core.image.img_sdl2',
|
|
'kivy.core.video.video_ffpyplayer',
|
|
'kivy.core.audio.audio_ffpyplayer',
|
|
# Kivy modules
|
|
'kivy.uix.video',
|
|
'kivy.uix.vkeyboard',
|
|
'kivy.uix.popup',
|
|
'kivy.uix.image',
|
|
'kivy.uix.button',
|
|
'kivy.uix.label',
|
|
'kivy.uix.textinput',
|
|
'kivy.uix.boxlayout',
|
|
'kivy.uix.floatlayout',
|
|
'kivy.uix.slider',
|
|
'kivy.uix.widget',
|
|
'kivy.uix.checkbox',
|
|
'kivy.graphics',
|
|
'kivy.graphics.texture',
|
|
'kivy.graphics.vertex_instructions',
|
|
'kivy.graphics.context_instructions',
|
|
'kivy.clock',
|
|
'kivy.loader',
|
|
'kivy.animation',
|
|
'kivy.lang',
|
|
'kivy.logger',
|
|
'kivy.config',
|
|
'kivy.properties',
|
|
'kivy.metrics',
|
|
'kivy.factory',
|
|
# Graphics providers
|
|
'kivy.graphics.opengl',
|
|
'kivy.graphics.opengl_utils',
|
|
'kivy.graphics.fbo',
|
|
'kivy.graphics.gl_instructions',
|
|
'kivy.graphics.stencil_instructions',
|
|
'kivy.graphics.scissor_instructions',
|
|
'kivy.graphics.buffer',
|
|
'kivy.graphics.vbo',
|
|
'kivy.graphics.shader',
|
|
'kivy.graphics.compiler',
|
|
# ffpyplayer
|
|
'ffpyplayer',
|
|
'ffpyplayer.player',
|
|
'ffpyplayer.pic',
|
|
'ffpyplayer.writer',
|
|
# Networking
|
|
'requests',
|
|
'aiohttp',
|
|
'urllib3',
|
|
'certifi',
|
|
'bcrypt',
|
|
# Platform
|
|
'ctypes',
|
|
'ctypes.wintypes',
|
|
'subprocess',
|
|
'shutil',
|
|
'glob',
|
|
'selectors',
|
|
'tempfile',
|
|
# Windows-specific
|
|
'cef_browser',
|
|
'webview2_browser',
|
|
'webview2_runtime',
|
|
'win32gui',
|
|
'win32con',
|
|
# Unified web-link controller (launch / verified visibility / interaction
|
|
# watcher / teardown) — imported by main.py and run_win.py
|
|
'weblink_session',
|
|
# Windows-native card reader (Raw Input API + LL-hook fallback)
|
|
'win_card_reader',
|
|
]
|
|
|
|
# Exclude Linux-only modules
|
|
excluded_imports = [
|
|
'gi', # GTK introspection (Linux)
|
|
'gi.repository',
|
|
'evdev', # We inject a fake evdev module in run_win.py
|
|
# GStreamer — we use ffpyplayer, not GStreamer
|
|
'kivy.lib.gstplayer',
|
|
# cefpython3: keep only Python 3.12 .pyd, exclude other version .pyd files
|
|
'cefpython3.cefpython_py27',
|
|
'cefpython3.cefpython_py34',
|
|
'cefpython3.cefpython_py35',
|
|
'cefpython3.cefpython_py36',
|
|
'cefpython3.cefpython_py37',
|
|
'cefpython3.cefpython_py38',
|
|
'cefpython3.cefpython_py39',
|
|
'cefpython3.cefpython_py310',
|
|
'cefpython3.cefpython_py311',
|
|
]
|
|
|
|
# --- Application data files to bundle --------------------------------
|
|
# Resources (icons, intro video, etc.)
|
|
resources_data = []
|
|
for item in RESOURCES_DIR.iterdir():
|
|
if item.is_file():
|
|
target_dir = 'config/resources'
|
|
resources_data.append((str(item), target_dir))
|
|
|
|
# Config directory.
|
|
#
|
|
# app_config.json is deliberately NOT bundled. Including it shipped the
|
|
# developer's own server_ip / screen_name inside the exe, so a fresh install
|
|
# silently connected to the wrong server (or to a placeholder) instead of
|
|
# asking the operator. The player now starts unconfigured, shows a notice after
|
|
# the splash video and opens Settings to collect the real values, which are
|
|
# then saved next to the .exe.
|
|
config_data = []
|
|
config_file = CONFIG_DIR / 'app_config.json'
|
|
if config_file.exists():
|
|
print("[spec] app_config.json is NOT bundled (first-run setup collects it)")
|
|
|
|
# --- Bundled web engines ---------------------------------------------
|
|
# Embedded WebView2 (Edge) SDK: the managed assembly plus the native loader
|
|
# DLL. The *runtime* itself is a Microsoft-shipped evergreen component and is
|
|
# deliberately NOT bundled (that is the point of using WebView2 — no ~150 MB
|
|
# Chromium payload inside our exe).
|
|
webview2_data = []
|
|
webview2_sdk = BUILD_DIR / 'webview2_sdk'
|
|
if webview2_sdk.is_dir():
|
|
for item in webview2_sdk.iterdir():
|
|
if item.is_file():
|
|
webview2_data.append((str(item), 'webview2_sdk'))
|
|
print(f"[spec] Bundling {len(webview2_data)} WebView2 SDK file(s) from {webview2_sdk}")
|
|
|
|
# WebView2 Runtime installer, so a machine that ships WITHOUT the Runtime can
|
|
# install it on first start (see windows/webview2_runtime.py).
|
|
#
|
|
# Only the small bootstrapper (~1.7 MB) is bundled by default; it downloads the
|
|
# Runtime from Microsoft. Dropping the ~203 MB offline standalone installer
|
|
# into windows/webview2_runtime/ bundles it too, which is what you want for
|
|
# machines with no internet — but it triples the exe size, so it is opt-in.
|
|
webview2_runtime = BUILD_DIR / 'webview2_runtime'
|
|
_bootstrap = webview2_runtime / 'MicrosoftEdgeWebview2Setup.exe'
|
|
_standalone = webview2_runtime / 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe'
|
|
if _bootstrap.is_file():
|
|
webview2_data.append((str(_bootstrap), 'webview2_runtime'))
|
|
print(f"[spec] Bundling WebView2 Runtime bootstrapper ({_bootstrap.stat().st_size / 1024 / 1024:.1f} MB)")
|
|
if _standalone.is_file():
|
|
webview2_data.append((str(_standalone), 'webview2_runtime'))
|
|
print(f"[spec] Bundling WebView2 offline standalone installer "
|
|
f"({_standalone.stat().st_size / 1024 / 1024:.0f} MB) — exe will be much larger")
|
|
if not _bootstrap.is_file() and not _standalone.is_file():
|
|
print("=" * 70)
|
|
print("WARNING: no WebView2 Runtime installer in windows/webview2_runtime/.")
|
|
print("Machines without the Runtime cannot show web links (they fall back")
|
|
print("to the Chrome/Edge subprocess engine).")
|
|
print("=" * 70)
|
|
else:
|
|
print("=" * 70)
|
|
print("WARNING: windows/webview2_sdk/ not found.")
|
|
print("Web links will fall back to the Chrome/Edge subprocess engine.")
|
|
print("=" * 70)
|
|
|
|
# Source files - .kv file
|
|
kv_file = SRC_DIR / 'signage_player.kv'
|
|
kv_data = []
|
|
if kv_file.exists():
|
|
kv_data.append((str(kv_file), '.'))
|
|
|
|
# Bundle the entire src directory as a tree.
|
|
#
|
|
# EXCLUDE player_auth.json: it holds LIVE credentials (auth_code, player_id,
|
|
# server_url). Bundling it means the frozen app starts up in _internal/ and
|
|
# loads that snapshot as its auth state — so a freshly built exe boots
|
|
# "already authenticated" against whatever server the file happened to name,
|
|
# and plays a stale playlist. Auth must be created at runtime in the data dir
|
|
# next to the .exe (see run_win.py `_patch_auth_paths`).
|
|
source_tree = Tree(
|
|
str(SRC_DIR),
|
|
prefix='',
|
|
excludes=['*.pyc', '__pycache__', '*.ini', 'player_auth.json'],
|
|
)
|
|
|
|
# --- Collect binary DLLs from kivy_deps and ffpyplayer ----------------
|
|
import importlib.util
|
|
from pathlib import Path as _Path
|
|
|
|
|
|
def _site_packages_dir(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 []
|
|
sp = _site_packages_dir(spec.origin)
|
|
# Climb from site-packages up until we find a sibling 'share' dir
|
|
# (site-packages -> Lib -> venv, where venv/share lives).
|
|
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 = []
|
|
for b in bins:
|
|
bpath = _Path(b)
|
|
if bpath.is_dir():
|
|
for f in bpath.glob('*.dll'):
|
|
results.append((str(f), '.'))
|
|
return results
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
# SDL2 / ANGLE / GLEW DLLs (kivy_deps share dirs)
|
|
_sdl2_dlls = _find_share_dlls('kivy_deps.sdl2', 'sdl2')
|
|
_angle_dlls = _find_share_dlls('kivy_deps.angle', 'angle')
|
|
_glew_dlls = _find_share_dlls('kivy_deps.glew', 'glew')
|
|
|
|
# ffpyplayer FFmpeg + bundled SDL DLLs (via dep_bins)
|
|
_ffpy_dlls = _find_ffpyplayer_bins()
|
|
|
|
_all_binaries = _sdl2_dlls + _angle_dlls + _glew_dlls + _ffpy_dlls
|
|
|
|
if not _all_binaries:
|
|
print("=" * 70)
|
|
print("WARNING: No Kivy/ffpyplayer DLLs found via share/ directories.")
|
|
print("PyInstaller may still auto-detect them, but if the .exe")
|
|
print("fails with 'SDL2.dll not found' or similar, you will need")
|
|
print("to manually add the DLL paths to the spec file.")
|
|
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 --------------------------------------------------
|
|
a = Analysis(
|
|
['run_win.py'], # Entry point (relative to this spec)
|
|
pathex=[str(BUILD_DIR), str(SRC_DIR)], # Where to find modules
|
|
binaries=_all_binaries,
|
|
datas=resources_data + config_data + kv_data + webview2_data,
|
|
hiddenimports=hidden_imports,
|
|
hookspath=[],
|
|
hooksconfig={},
|
|
runtime_hooks=[str(BUILD_DIR / 'pyi_runtime_hook.py')],
|
|
excludes=excluded_imports,
|
|
noarchive=False,
|
|
module_collection_mode={
|
|
'kivy': 'pyz',
|
|
'kivy.core': 'pyz',
|
|
'kivy.uix': 'pyz',
|
|
'kivy.graphics': 'pyz',
|
|
},
|
|
)
|
|
|
|
# Add the source tree (main.py, etc.)
|
|
a.datas += source_tree
|
|
|
|
pyz = PYZ(a.pure)
|
|
|
|
exe = EXE(
|
|
pyz,
|
|
a.scripts,
|
|
a.binaries,
|
|
a.zipfiles,
|
|
a.datas,
|
|
[],
|
|
name='KiwySignagePlayer',
|
|
debug=False,
|
|
bootloader_ignore_signals=False,
|
|
strip=False,
|
|
upx=True,
|
|
upx_exclude=[],
|
|
runtime_tmpdir=None,
|
|
console=True, # Show console for debugging startup errors
|
|
disable_windowed_traceback=False,
|
|
argv_emulation=False,
|
|
target_arch=None,
|
|
codesign_identity=None,
|
|
entitlements_file=None,
|
|
icon=str(BUILD_DIR / 'app_icon.ico') if (BUILD_DIR / 'app_icon.ico').exists() else None,
|
|
version=str(BUILD_DIR / 'version_info.txt') if (BUILD_DIR / 'version_info.txt').exists() else None,
|
|
)
|
|
|
|
# --- COLLECT everything into a single folder -------------------------
|
|
coll = COLLECT(
|
|
exe,
|
|
a.binaries,
|
|
a.zipfiles,
|
|
a.datas,
|
|
strip=False,
|
|
upx=True,
|
|
upx_exclude=[],
|
|
name='KiwySignagePlayer',
|
|
)
|