Ported from the private repo via bin/publish.sh, plus a manual catch-up on files that had drifted out of sync before today: - launch_nexus.ps1: health-check based restart decisions instead of a bare port-listen check (a wedged leftover process squatting a port used to look "already running" and block the real service from starting), a script-path quoting fix for Start-Process, hidden console via a wscript.exe wrapper (bin/launch_nexus_hidden.vbs), and a taskbar/window icon for the native app window. - Sidebar: slim icon+text nav rows instead of bulky bordered buttons, tighter spacing throughout. - Settings: full-width layout, a Vite dev-server Start/Stop toggle (synapse/frontend_manager.py + /frontend/* endpoints), and the Linux-only Icon Branding section now gated on the new /status `platform` field instead of always rendering. - Chatbot: a Think toggle next to the model picker, so extended thinking can be flipped without leaving the chat page. - management/ncp.py: faster start/stop polling (0.25s steps instead of 1s), Vite no longer blocks `ncp start` on Linux and is skipped outright on Windows. Note: the private repo also has a Mail (IMAP/SMTP) feature; it's intentionally not included here, so the Mail-only pieces of main.py, App.jsx, and requirements-windows.txt were left out of this port.
164 lines
6.2 KiB
Python
164 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Open the NexusOS UI in a native window.
|
|
|
|
Uses pywebview, which renders via the WebView2 runtime on Windows (already
|
|
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.
|
|
The loading page's status line is updated live (memory -> backend -> ready) via
|
|
evaluate_js, instead of sitting on one static sentence for the whole wait - the
|
|
point is a user watching the window can tell what stage it's stuck on, without
|
|
needing to go read a terminal or a log file to find out.
|
|
|
|
Falls back to the default browser if pywebview/WebView2 is unavailable, staying
|
|
alive so the launcher doesn't tear the services down underneath it.
|
|
"""
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
URL = "http://localhost:8000"
|
|
MEMORY_URL = "http://localhost:8001/"
|
|
ICON_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "NexusOS.ico")
|
|
|
|
# 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. The
|
|
# <p id> is the hook _set_status() rewrites as boot moves through its stages.
|
|
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>Starting NexusOS</h1>
|
|
<p id="status">Starting memory service...</p>
|
|
</div>
|
|
</body></html>"""
|
|
|
|
FAILED_HTML = LOADING_HTML.replace(
|
|
"<div class=\"ring\"></div>", ""
|
|
).replace(
|
|
"Starting NexusOS", "Backend did not start"
|
|
).replace(
|
|
'id="status">Starting memory service...',
|
|
'id="status">Nothing answered on :8000 after 40s. Check: ncp logs -b',
|
|
)
|
|
|
|
|
|
def _set_status(win, text: str) -> None:
|
|
# Best-effort: the window can be mid-teardown (user closed it while this
|
|
# background thread was still polling), and evaluate_js on a dead window
|
|
# raises - that's not a reason to kill the polling loop.
|
|
try:
|
|
win.evaluate_js(f"document.getElementById('status').textContent = {text!r}")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _http_ok(url: str, timeout: float = 2.0) -> bool:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=timeout) as r:
|
|
return r.status == 200
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _wait_for_backend(win, timeout: float = 40.0) -> bool:
|
|
"""Poll memory, then the backend, updating the on-screen status as each
|
|
comes up - so the window never loads before the server is up (which shows
|
|
a localhost error the webview won't retry), and never sits on one sentence
|
|
while actually moving through two separate services."""
|
|
status_url = URL.rstrip("/") + "/status"
|
|
deadline = time.time() + timeout
|
|
|
|
memory_ready = _http_ok(MEMORY_URL)
|
|
if memory_ready:
|
|
_set_status(win, "Starting backend...")
|
|
|
|
while time.time() < deadline:
|
|
if not memory_ready:
|
|
memory_ready = _http_ok(MEMORY_URL)
|
|
if memory_ready:
|
|
_set_status(win, "Starting backend...")
|
|
|
|
try:
|
|
# 5s, not 2: /status probes Ollama, and a wedged Ollama made it
|
|
# slower than a 2s ceiling - the backend was up and answering 200
|
|
# while this loop timed out on every attempt and declared it dead.
|
|
# The server side is fixed too; this is the margin.
|
|
with urllib.request.urlopen(status_url, timeout=5) as r:
|
|
if r.status == 200:
|
|
_set_status(win, "Opening Nexus...")
|
|
return True
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.5)
|
|
return False
|
|
|
|
|
|
def main() -> int:
|
|
try:
|
|
import webview
|
|
except Exception as e: # pywebview not installed
|
|
return _browser_fallback(f"pywebview unavailable ({e})")
|
|
|
|
try:
|
|
window = webview.create_window(
|
|
"NexusOS",
|
|
html=LOADING_HTML,
|
|
width=1200,
|
|
height=800,
|
|
min_size=(900, 600),
|
|
)
|
|
|
|
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):
|
|
win.load_url(URL)
|
|
else:
|
|
print("[nexus] backend not reachable on :8000 after 40s.",
|
|
file=sys.stderr)
|
|
win.load_html(FAILED_HTML)
|
|
|
|
icon = ICON_PATH if os.path.isfile(ICON_PATH) else None
|
|
webview.start(_swap_in_app, window, icon=icon) # 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})")
|
|
|
|
|
|
def _browser_fallback(reason: str) -> int:
|
|
import webbrowser
|
|
|
|
print(f"[nexus] {reason}; opening default browser instead.", file=sys.stderr)
|
|
webbrowser.open(URL)
|
|
# Stay alive so the launcher keeps the services up. The user stops NexusOS
|
|
# by closing the launcher (or the browser tab, then the services idle out).
|
|
try:
|
|
while True:
|
|
time.sleep(3600)
|
|
except KeyboardInterrupt:
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|