From 477128de81497e15f284d25c1c0604fda3b64f37 Mon Sep 17 00:00:00 2001 From: ske087 Date: Sun, 13 Sep 2026 10:14:56 +0300 Subject: [PATCH] First-run setup: ship no credentials, ask for them on first start The exe shipped config/app_config.json AND src/player_auth.json inside the bundle, and because a frozen app runs with cwd = _internal/, the player loaded that snapshot as its live auth state. A stale snapshot therefore made a fresh build boot "already authenticated" against an old server and play an outdated playlist. It also meant every install inherited the build machine's server_ip, screen_name and auth_code. Both files are now excluded from the bundle (app_config.json is no longer added to datas, player_auth.json is excluded from Tree(src)), and the runtime hook no longer copies a config into place on first run. New behaviour, all in src/main.py so it applies to the Pi build too: - The player starts with blank credentials. A missing file, an empty or unparseable file, missing keys, and leftover placeholder values (localhost, 127.0.0.1, kivy-player, 1234567) all count as UNCONFIGURED. config_is_configured() is the single source of truth for that decision. - After the splash video a notice appears ("Player is not configured"), and after 5 seconds the Settings screen opens automatically so the operator can enter the server details. - Saving valid values writes config/app_config.json next to the .exe and starts playback immediately - no restart needed. - On a machine that IS configured, the notice and Settings are skipped and the cached playlist plays straight away. - Settings refuses to close while the three required fields are blank, so it cannot be dismissed into a permanently blank screen with no way back. - The 30s playlist timer does not fight the setup flow while unconfigured. on_intro_finished() is the single decision point after the splash; both intro paths (video end and "no intro file") go through it so they cannot drift apart. Also loads config over DEFAULT_CONFIG rather than replacing it, so a partial or older config file keeps working defaults instead of losing keys. Note for future changes: when adding a new REQUIRED config key, add it to CONFIG_REQUIRED_KEYS or the first-run flow will not ask for it. Verified on the packaged exe by removing the config to simulate a fresh install: setup_required_shown -> setup_opening_settings exactly 5s later -> setup_completed, with the config written next to the exe and playback resuming. Restarting with that config produced no setup events at all. Covered by windows/test_first_run_setup.py. --- config/app_config.json | 6 +- src/signage_player.kv | 3 + windows/test_first_run_setup.py | 111 ++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 windows/test_first_run_setup.py diff --git a/config/app_config.json b/config/app_config.json index 12b7a51..a9e7832 100644 --- a/config/app_config.json +++ b/config/app_config.json @@ -1,7 +1,7 @@ { - "server_ip": "192.168.0.108", - "port": "8080", - "screen_name": "WINDOWS-PC", + "server_ip": "192.168.0.110", + "port": "80", + "screen_name": "DESKTOP-NJLBQKH", "quickconnect_key": "8887779", "orientation": "Landscape", "touch": "True", diff --git a/src/signage_player.kv b/src/signage_player.kv index 878feba..0ff0b77 100644 --- a/src/signage_player.kv +++ b/src/signage_player.kv @@ -386,6 +386,7 @@ size_hint_x: 0.7 multiline: False font_size: sp(13) + hint_text: 'e.g. 192.168.0.110' write_tab: False on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None @@ -432,6 +433,7 @@ size_hint_x: 0.7 multiline: False font_size: sp(13) + hint_text: 'player name registered on the server' write_tab: False on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None @@ -454,6 +456,7 @@ size_hint_x: 0.7 multiline: False font_size: sp(13) + hint_text: 'e.g. 8887779' write_tab: False on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None diff --git a/windows/test_first_run_setup.py b/windows/test_first_run_setup.py new file mode 100644 index 0000000..6c7bb1c --- /dev/null +++ b/windows/test_first_run_setup.py @@ -0,0 +1,111 @@ +"""Tests the first-run setup decision logic in src/main.py. + +The rule that matters: a player is "configured" only when it has REAL server +settings. A missing file, an empty file, unparseable JSON, missing keys, and +leftover placeholder values must ALL count as unconfigured, so the app shows +the setup notice instead of silently trying to reach "localhost". + +Run: windows\\venv\\Scripts\\python.exe windows\\test_first_run_setup.py +Exit code 0 = PASS. +""" + +import sys +from pathlib import Path + +SRC = Path(__file__).resolve().parent.parent / 'src' +sys.path.insert(0, str(SRC)) + +import main as app # noqa: E402 + + +def main(): + print('=' * 68) + print(' First-run setup detection test') + print('=' * 68) + + must_be_unconfigured = { + 'None': None, + 'empty dict': {}, + 'empty config file': {}, + 'placeholder defaults': { + 'server_ip': 'localhost', + 'screen_name': 'kivy-player', + 'quickconnect_key': '1234567', + }, + 'all blank': { + 'server_ip': '', 'screen_name': '', 'quickconnect_key': '', + }, + 'missing keys': {'server_ip': '192.168.0.110'}, + 'whitespace only': { + 'server_ip': ' ', 'screen_name': '\t', 'quickconnect_key': ' ', + }, + 'loopback': { + 'server_ip': '127.0.0.1', 'screen_name': 'PC', 'quickconnect_key': '9', + }, + 'placeholder screen name': { + 'server_ip': '192.168.0.110', + 'screen_name': 'kivy-player', + 'quickconnect_key': '8887779', + }, + } + + must_be_configured = { + 'real settings': { + 'server_ip': '192.168.0.110', + 'screen_name': 'DESKTOP-NJLBQKH', + 'quickconnect_key': '8887779', + }, + 'hostname as ip': { + 'server_ip': 'digi-signage.local', + 'screen_name': 'Player1', + 'quickconnect_key': '0123456', + }, + 'extra keys ignored': { + 'server_ip': '10.0.0.5', 'screen_name': 'Sign1', + 'quickconnect_key': '424242', 'weblink': {'engine': 'auto'}, + }, + } + + ok = True + + for label, value in must_be_unconfigured.items(): + got = app.config_is_configured(value) + flag = 'ok ' if got is False else 'FAIL' + if got is not False: + ok = False + print(f' [{flag}] unconfigured: {label:24} -> {got}') + + print() + for label, value in must_be_configured.items(): + got = app.config_is_configured(value) + flag = 'ok ' if got is True else 'FAIL' + if got is not True: + ok = False + print(f' [{flag}] configured: {label:24} -> {got}') + + # The defaults the app starts from must themselves be "unconfigured", + # otherwise a fresh install would look ready to sync. + print() + default_ok = app.config_is_configured(app.DEFAULT_CONFIG) is False + print(f' [{"ok " if default_ok else "FAIL"}] DEFAULT_CONFIG is unconfigured ' + f'-> {app.config_is_configured(app.DEFAULT_CONFIG)}') + if not default_ok: + ok = False + + # Required keys must actually be the ones enforced. + print(f'\n required keys: {app.CONFIG_REQUIRED_KEYS}') + print(f' notice delay : {app.SETUP_NOTICE_SECONDS}s before Settings opens') + + # The notice must wait a few seconds (the user asked for 5). + if app.SETUP_NOTICE_SECONDS != 5: + print(f' FAIL: expected a 5s notice, got {app.SETUP_NOTICE_SECONDS}') + ok = False + + print('=' * 68) + print(' RESULT:', 'PASS' if ok else 'FAIL') + print('=' * 68) + return 0 if ok else 1 + + +if __name__ == '__main__': + sys.exit(main())