Files
Kiwy-Signage/windows/build.spec
T
ske087 a19627885c Add unified WeblinkSession controller and interaction-driven playback
Web links were implemented three times (main.py subprocess, run_win.py
subprocess + Win32 overlay, cef_browser.py embedded CEF), each owning its own
process handle, watchdog and teardown. That ambiguity caused leaked browsers,
skipped items, lost foreground and blank screens when a page failed to load.

Replace all three with a single owner in src/weblink_session.py:

- WeblinkSession: validate -> launch -> verify -> watch -> teardown.
  Generation-tokened so stale callbacks are ignored, idempotent close(),
  atexit-safe, never more than one browser alive.
- WeblinkAdapter: the only platform-specific part (launch / wait_visible /
  is_alive / teardown / prewarm). Platform layers inject engines through
  SignagePlayer.weblink_adapter_factory.
- ChromiumSubprocessAdapter: default engine (Pi chromium, Windows chrome/msedge).
- InteractionWatcher: decides when an item is finished.
- WebInputSources: /dev/input/event* (Linux) plus a GetCursorPos pointer tap
  (needed on Windows for embedded CEF, which has no child process).

Interaction model: web links are an interactive surface, not timed media.
The player advances only when the configured duration has elapsed AND the
viewer has not interacted for 10s, measured from the most recent interaction.
A touch in the final seconds of a slot therefore pushes the advance 10s past
that touch, and each further touch pushes it again, so a page is never pulled
out from under someone using it. An untouched page still advances on schedule.
A drag burst counts as one interaction but the countdown tracks its last event,
so an item cannot be cut off mid-gesture. max_dwell (duration x factor, floored
by min_max_dwell) is an absolute backstop against a wedged browser or a jammed
touchscreen.

Verified start-up: the visibility wait runs on the watcher thread, never on
Kivy's main thread. If the browser window never appears the item is reported
failed and skipped, instead of resetting the error counter and leaving a black
screen up for the whole duration.

Config: new "weblink" block in config/app_config.json (engine,
interaction_postpone, interaction_debounce, interaction_grace,
max_dwell_factor, min_max_dwell, launch_timeout, prewarm) with safe defaults,
so an absent block still works.

Also add weblink_session to the PyInstaller hiddenimports so the frozen exe
bundles the new module.
2026-09-10 16:43:36 +03:00

308 lines
8.9 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',
'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)
config_data = []
config_file = CONFIG_DIR / 'app_config.json'
if config_file.exists():
config_data.append((str(config_file), 'config'))
# 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
source_tree = Tree(str(SRC_DIR), prefix='', excludes=['*.pyc', '__pycache__', '*.ini'])
# --- 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,
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',
)