# -*- 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//. kivy_deps.sdl2/angle/glew and ffpyplayer install their DLLs into /share//... 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', )