fix(ncp web): no stray consoles, and show a loading window while services start
The two black terminals had two causes: uvicorn --reload (a dev flag whose reloader spawns the server as a child, and Windows gives a console window to a child of a console-less parent), and DETACHED_PROCESS in launch(), which is exactly the console-less condition that triggers it. Drops --reload here (launch_nexus.sh keeps it for the Linux dev loop) and uses CREATE_NO_WINDOW. The window now opens immediately on an inline 'Loading Nexus core services' page and navigates to the app once /status answers, instead of waiting 40s offscreen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+52
-6
@@ -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
|
Edge --app profile cold-start. Blocks until the window is closed; the launcher
|
||||||
waits on this process and stops the services when it exits.
|
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
|
Falls back to the default browser if pywebview/WebView2 is unavailable, staying
|
||||||
alive so the launcher doesn't tear the services down underneath it.
|
alive so the launcher doesn't tear the services down underneath it.
|
||||||
"""
|
"""
|
||||||
@@ -15,6 +20,38 @@ import urllib.request
|
|||||||
|
|
||||||
URL = "http://localhost:8000"
|
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 = """<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8"><title>NexusOS</title><style>
|
||||||
|
html,body{height:100%;margin:0}
|
||||||
|
body{background:#14161a;color:#e6e8ec;display:flex;align-items:center;
|
||||||
|
justify-content:center;font-family:Segoe UI,system-ui,sans-serif}
|
||||||
|
.box{text-align:center}
|
||||||
|
.ring{width:44px;height:44px;margin:0 auto 22px;border-radius:50%;
|
||||||
|
border:3px solid #2a2f38;border-top-color:#4c8dff;
|
||||||
|
animation:spin 1s linear infinite}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
h1{font-size:17px;font-weight:600;margin:0 0 6px}
|
||||||
|
p{font-size:13px;color:#8b93a1;margin:0}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="box">
|
||||||
|
<div class="ring"></div>
|
||||||
|
<h1>Loading Nexus core services</h1>
|
||||||
|
<p>Starting the memory service and backend...</p>
|
||||||
|
</div>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
FAILED_HTML = LOADING_HTML.replace(
|
||||||
|
"<div class=\"ring\"></div>", ""
|
||||||
|
).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:
|
def _wait_for_backend(timeout: float = 40.0) -> bool:
|
||||||
"""Poll /status until the backend answers, so the window never loads before
|
"""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
|
except Exception as e: # pywebview not installed
|
||||||
return _browser_fallback(f"pywebview unavailable ({e})")
|
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:
|
try:
|
||||||
webview.create_window(
|
window = webview.create_window(
|
||||||
"NexusOS",
|
"NexusOS",
|
||||||
URL,
|
html=LOADING_HTML,
|
||||||
width=1200,
|
width=1200,
|
||||||
height=800,
|
height=800,
|
||||||
min_size=(900, 600),
|
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
|
return 0
|
||||||
except Exception as e: # no WebView2 runtime / backend failure
|
except Exception as e: # no WebView2 runtime / backend failure
|
||||||
return _browser_fallback(f"native window failed ({e})")
|
return _browser_fallback(f"native window failed ({e})")
|
||||||
|
|||||||
+13
-2
@@ -100,8 +100,15 @@ class Service:
|
|||||||
|
|
||||||
|
|
||||||
def _uvicorn(app: str, port: int):
|
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",
|
return [str(PYTHON), "-m", "uvicorn", app, "--host", "0.0.0.0",
|
||||||
"--port", str(port), "--reload"]
|
"--port", str(port)]
|
||||||
|
|
||||||
|
|
||||||
SERVICES = {
|
SERVICES = {
|
||||||
@@ -153,8 +160,12 @@ def launch(svc: Service) -> None:
|
|||||||
svc.log_file.write_text("")
|
svc.log_file.write_text("")
|
||||||
with open(svc.log_file, "ab") as log:
|
with open(svc.log_file, "ab") as log:
|
||||||
# Detach so the service outlives this process, on both platforms.
|
# 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
|
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})
|
if WINDOWS else {"start_new_session": True})
|
||||||
proc = subprocess.Popen(argv, cwd=str(svc.cwd), stdout=log,
|
proc = subprocess.Popen(argv, cwd=str(svc.cwd), stdout=log,
|
||||||
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user