diff --git a/bin/nexus_window.py b/bin/nexus_window.py index ff6dcb0..aab5396 100644 --- a/bin/nexus_window.py +++ b/bin/nexus_window.py @@ -6,6 +6,11 @@ present on Win10/11) -- a real app window with no browser chrome and none of the Edge --app profile cold-start. Blocks until the window is closed; the launcher waits on this process and stops the services when it exits. +The window opens IMMEDIATELY on a loading page and navigates to the app once the +backend answers, rather than waiting up to 40s with nothing on screen and then +appearing. One window the whole time: nothing pops up and vanishes, and there is +no moment where the user is left wondering whether the command did anything. + Falls back to the default browser if pywebview/WebView2 is unavailable, staying alive so the launcher doesn't tear the services down underneath it. """ @@ -15,6 +20,38 @@ import urllib.request URL = "http://localhost:8000" +# Inline so it renders with no server and no asset files - the whole point is +# that it shows before anything is listening. Colours match the app's dark UI so +# the swap to the real page is not a flash of a different-looking window. +LOADING_HTML = """ +NexusOS +
+
+

Loading Nexus core services

+

Starting the memory service and backend...

+
+""" + +FAILED_HTML = LOADING_HTML.replace( + "
", "" +).replace( + "Loading Nexus core services", "Backend did not start" +).replace( + "Starting the memory service and backend...", + "Nothing answered on :8000 after 40s. Check: ncp logs -b" +) + def _wait_for_backend(timeout: float = 40.0) -> bool: """Poll /status until the backend answers, so the window never loads before @@ -37,18 +74,27 @@ def main() -> int: except Exception as e: # pywebview not installed return _browser_fallback(f"pywebview unavailable ({e})") - if not _wait_for_backend(): - print("[nexus] backend not reachable on :8000 after 40s.", file=sys.stderr) - try: - webview.create_window( + window = webview.create_window( "NexusOS", - URL, + html=LOADING_HTML, width=1200, height=800, min_size=(900, 600), ) - webview.start() # blocks until the window is closed + + def _swap_in_app(win): + """Runs once the GUI loop is up, so the spinner is already on screen + while we wait. load_url replaces the loading page in place - there is + never a second window to close.""" + if _wait_for_backend(): + win.load_url(URL) + else: + print("[nexus] backend not reachable on :8000 after 40s.", + file=sys.stderr) + win.load_html(FAILED_HTML) + + webview.start(_swap_in_app, window) # blocks until the window is closed return 0 except Exception as e: # no WebView2 runtime / backend failure return _browser_fallback(f"native window failed ({e})") diff --git a/management/ncp.py b/management/ncp.py index b71f45f..4b5f276 100644 --- a/management/ncp.py +++ b/management/ncp.py @@ -100,8 +100,15 @@ class Service: def _uvicorn(app: str, port: int): + # No --reload. It is a dev-loop flag: uvicorn's reloader runs a supervisor + # that spawns the real server as a CHILD, so every service became two + # processes - and on Windows that child, spawned from a parent with no + # console, got handed a brand new console WINDOW. `ncp web` popped two black + # terminals for what should have been a silent start. launch_nexus.sh still + # passes --reload for the Linux dev loop, where a visible console is the + # point; this launcher is the one users run. return [str(PYTHON), "-m", "uvicorn", app, "--host", "0.0.0.0", - "--port", str(port), "--reload"] + "--port", str(port)] SERVICES = { @@ -153,8 +160,12 @@ def launch(svc: Service) -> None: svc.log_file.write_text("") with open(svc.log_file, "ab") as log: # Detach so the service outlives this process, on both platforms. + # CREATE_NO_WINDOW, not DETACHED_PROCESS: detached means the process has + # NO console, and Windows then gives a console window to any console + # program it starts in turn. CREATE_NO_WINDOW gives it a console that is + # never shown, which its children inherit - so nothing flashes up. kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP - | getattr(subprocess, "DETACHED_PROCESS", 0)} + | getattr(subprocess, "CREATE_NO_WINDOW", 0)} if WINDOWS else {"start_new_session": True}) proc = subprocess.Popen(argv, cwd=str(svc.cwd), stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,