diff --git a/Odysseus.spec b/Odysseus.spec new file mode 100644 index 000000000..547460c69 --- /dev/null +++ b/Odysseus.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['launcher.py'], + pathex=[], + binaries=[], + datas=[('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='Odysseus', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['static\\icon.ico'], +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='Odysseus', +) diff --git a/app.py b/app.py index f36673e91..7ccf57f4f 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,7 @@ # app.py — slim orchestrator import mimetypes import os +import sys def register_static_mime_types() -> None: @@ -438,7 +439,7 @@ class _RevalidatingStatic(StaticFiles): return resp -app.mount("/static", _RevalidatingStatic(directory="static"), name="static") +app.mount("/static", _RevalidatingStatic(directory=STATIC_DIR), name="static") # ========= GENERATED IMAGES ========= @app.get("/api/generated-image/{filename}") @@ -1172,3 +1173,12 @@ async def _shutdown_event(): except Exception as e: logger.warning(f"MCP shutdown error: {e}") logger.info("Application shutdown complete") + + +if __name__ == "__main__": + import uvicorn + + bind_host = os.getenv("APP_BIND", "127.0.0.1") + bind_port = int(os.getenv("APP_PORT", "7000")) + + uvicorn.run(app, host=bind_host, port=bind_port, log_level="info") diff --git a/build-windows-portable.ps1 b/build-windows-portable.ps1 new file mode 100644 index 000000000..52f71a191 --- /dev/null +++ b/build-windows-portable.ps1 @@ -0,0 +1,72 @@ +#Requires -Version 5.1 +<# + Build a portable Windows distribution for Odysseus. + + Output layout: + dist\Odysseus\Odysseus.exe + dist\Odysseus\static\... + dist\Odysseus\scripts\... + dist\Odysseus\mcp_servers\... + dist\Odysseus\services\hwfit\data\... + + The app then keeps using its normal filesystem layout when frozen. + + Usage: + powershell -ExecutionPolicy Bypass -File .\build-windows-portable.ps1 +#> + +$ErrorActionPreference = "Stop" +Set-Location -Path $PSScriptRoot + +function Write-Step($msg) { Write-Host ""; Write-Host ("==> " + $msg) -ForegroundColor Cyan } +function Fail($msg) { + Write-Host "" + Write-Host ("ERROR: " + $msg) -ForegroundColor Red + exit 1 +} + +Write-Step "Checking for Python" +$pyExe = $null +if (Test-Path ".\.venv\Scripts\python.exe") { + $pyExe = (Resolve-Path ".\.venv\Scripts\python.exe").Path +} else { + foreach ($c in @("py", "python")) { + $cmd = Get-Command $c -ErrorAction SilentlyContinue + if ($cmd) { $pyExe = $cmd.Source; break } + } + if ($pyExe -like "*WindowsApps*python.exe") { + $pyCmd = Get-Command py -ErrorAction SilentlyContinue + if ($pyCmd) { + $pyExe = $pyCmd.Source + } + } +} +if (-not $pyExe) { + Fail "Python not found on PATH. Install Python 3.11+ first." +} +Write-Host ("Using Python: " + $pyExe) + +Write-Step "Installing build dependencies" +& $pyExe -m pip install --upgrade pip --quiet +& $pyExe -m pip install -r requirements.txt pyinstaller pystray Pillow +if ($LASTEXITCODE -ne 0) { Fail "Dependency install failed." } + +Write-Step "Building portable exe bundle" +Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue + +$dataArgs = @( + "--add-data", "static;static", + "--add-data", "scripts;scripts", + "--add-data", "mcp_servers;mcp_servers", + "--add-data", "services/hwfit/data;services/hwfit/data", + "--add-data", "config;config", + "--add-data", ".env.example;.env.example" +) + +& $pyExe -m PyInstaller --noconfirm --clean --onedir --noconsole --icon=static/icon.ico --name Odysseus @dataArgs launcher.py +if ($LASTEXITCODE -ne 0) { Fail "PyInstaller build failed." } + +Write-Host "" +Write-Host "Build complete." -ForegroundColor Green +Write-Host "Portable app folder: $PSScriptRoot\dist\Odysseus" -ForegroundColor Green +Write-Host "Distribute the whole folder (or zip it) so static assets and scripts stay with the exe." -ForegroundColor Green \ No newline at end of file diff --git a/launch-windows.ps1 b/launch-windows.ps1 index 16938c195..263d95127 100644 --- a/launch-windows.ps1 +++ b/launch-windows.ps1 @@ -105,6 +105,14 @@ if (-not $pyExe) { } } +if ($pyExe -like "*WindowsApps*python.exe") { + $pyCmd = Get-Command py -ErrorAction SilentlyContinue + if ($pyCmd) { + $pyExe = $pyCmd.Source + $pyArgs = @("-3.11") + } +} + if (-not $pyExe) { Fail "Couldn't find Python 3.11+ for Windows setup. Install Python 3.11+ (or open the Python launcher with 'py -3.11') from https://www.python.org/downloads/, then re-run this script." } diff --git a/launcher.py b/launcher.py new file mode 100644 index 000000000..ba158444f --- /dev/null +++ b/launcher.py @@ -0,0 +1,142 @@ +# launcher.py +"""Dedicated entrypoint for the standalone Windows portable launcher. + +Handles: +- Immediate GUI splash screen creation using tkinter. +- Suppressing console stream crashes in windowed GUI mode via NullWriter. +- Spawning system tray icon via pystray and Pillow (lazy-loaded). +- Auto-opening default browser pointing to the running backend. +- Launching the FastAPI server (importing and running app.py). +""" +import os +import sys +import threading +import time +import webbrowser + +# Define a dummy NullWriter to suppress standard stream crashes (isatty etc.) in GUI mode +class NullWriter: + def write(self, text): + pass + def flush(self): + pass + def isatty(self): + return False + +if sys.stdout is None: + sys.stdout = NullWriter() +if sys.stderr is None: + sys.stderr = NullWriter() + + +splash_root = None + +# If running from a frozen PyInstaller bundle, launch the splash screen IMMEDIATELY +if getattr(sys, 'frozen', False): + import tkinter as tk + + def show_splash_instantly(): + global splash_root + try: + splash_root = tk.Tk() + splash_root.title("Odysseus") + splash_root.overrideredirect(True) + splash_root.configure(bg="#1a1c23") + + # Accented borders + splash_root.config(highlightbackground="#e06c75", highlightcolor="#e06c75", highlightthickness=1) + + w, h = 360, 160 + ws = splash_root.winfo_screenwidth() + hs = splash_root.winfo_screenheight() + x = (ws - w) // 2 + y = (hs - h) // 2 + splash_root.geometry(f"{w}x{h}+{x}+{y}") + + tk.Label(splash_root, text="⛵ Odysseus", font=("Segoe UI", 22, "bold"), bg="#1a1c23", fg="#e06c75").pack(pady=(22, 2)) + tk.Label(splash_root, text="Launching background services...", font=("Segoe UI", 10), bg="#1a1c23", fg="#d1d4e0").pack(pady=2) + tk.Label(splash_root, text="Please wait, this will take a few seconds.", font=("Segoe UI", 8, "italic"), bg="#1a1c23", fg="#5c6370").pack(pady=(12, 0)) + + splash_root.attributes("-topmost", True) + splash_root.mainloop() + except Exception: + pass + + # Launch the GUI splash screen immediately on a background thread + threading.Thread(target=show_splash_instantly, daemon=True).start() + + +def create_tray_image(): + # Generate a beautiful 64x64 icon matching Odysseus brand red accent (#e06c75) + from PIL import Image, ImageDraw + image = Image.new('RGBA', (64, 64), (0, 0, 0, 0)) + dc = ImageDraw.Draw(image) + accent_red = (224, 108, 117, 255) + light_red = (224, 108, 117, 150) + + # Draw premium sailing boat + dc.polygon([(32, 10), (32, 45), (12, 45)], fill=accent_red) + dc.polygon([(32, 18), (32, 45), (48, 45)], fill=light_red) + dc.polygon([(8, 48), (56, 48), (44, 56), (20, 56)], fill=accent_red) + return image + + +def on_open_browser(icon, item, url): + webbrowser.open(url) + + +def on_exit(icon, item): + icon.stop() + os._exit(0) + + +def setup_system_tray(url): + try: + import pystray + icon_img = create_tray_image() + menu = ( + pystray.MenuItem('Open Odysseus', lambda icon, item: on_open_browser(icon, item, url), default=True), + pystray.MenuItem('Exit', on_exit) + ) + tray_icon = pystray.Icon( + "Odysseus", + icon_img, + "Odysseus", + menu + ) + tray_icon.run() + except Exception: + pass + + +def open_browser(url): + # Allow uvicorn and app lifecycles to complete warmups + time.sleep(3.5) + + # Safely close the splash screen + try: + global splash_root + if splash_root: + splash_root.after(0, splash_root.destroy) + except Exception: + pass + + webbrowser.open(url) + + +if __name__ == "__main__": + import uvicorn + # Import the FastAPI app from app.py + from app import app + + bind_host = os.getenv("APP_BIND", "127.0.0.1") + bind_port = int(os.getenv("APP_PORT", "7000")) + url = f"http://{bind_host}:{bind_port}" + + if getattr(sys, 'frozen', False): + # Start browser manager thread + threading.Thread(target=open_browser, args=(url,), daemon=True).start() + # Start system tray manager thread + threading.Thread(target=setup_system_tray, args=(url,), daemon=True).start() + + uvicorn.run(app, host=bind_host, port=bind_port, log_level="info") diff --git a/static/icon.ico b/static/icon.ico new file mode 100644 index 000000000..666a7e6ad Binary files /dev/null and b/static/icon.ico differ diff --git a/tests/test_launcher.py b/tests/test_launcher.py new file mode 100644 index 000000000..309ad35a4 --- /dev/null +++ b/tests/test_launcher.py @@ -0,0 +1,62 @@ +# tests/test_launcher.py +import sys +import os +from unittest import mock +import pytest + +from launcher import NullWriter, create_tray_image, on_open_browser, on_exit, open_browser + + +def test_null_writer(): + writer = NullWriter() + # writing and flushing should not raise any exceptions + writer.write("hello") + writer.flush() + assert writer.isatty() is False + + +def test_create_tray_image(): + try: + from PIL import Image + img = create_tray_image() + assert isinstance(img, Image.Image) + assert img.size == (64, 64) + except ImportError: + pytest.skip("Pillow/PIL not installed in test environment") + + +def test_on_open_browser(): + with mock.patch("webbrowser.open") as mock_open: + icon_mock = mock.Mock() + item_mock = mock.Mock() + url = "http://127.0.0.1:7000" + on_open_browser(icon_mock, item_mock, url) + mock_open.assert_called_once_with(url) + + +def test_on_exit(): + with mock.patch("os._exit") as mock_exit: + icon_mock = mock.Mock() + item_mock = mock.Mock() + on_exit(icon_mock, item_mock) + icon_mock.stop.assert_called_once() + mock_exit.assert_called_once_with(0) + + +def test_open_browser(): + with mock.patch("webbrowser.open") as mock_open, \ + mock.patch("time.sleep") as mock_sleep: + + # Test when splash_root is None + with mock.patch("launcher.splash_root", None): + open_browser("http://127.0.0.1:7000") + mock_open.assert_called_once_with("http://127.0.0.1:7000") + mock_sleep.assert_called_once_with(3.5) + + with mock.patch("webbrowser.open") as mock_open, \ + mock.patch("time.sleep") as mock_sleep: + # Test when splash_root is present and gets destroyed + mock_splash = mock.Mock() + with mock.patch("launcher.splash_root", mock_splash): + open_browser("http://127.0.0.1:7000") + mock_splash.after.assert_called_once() diff --git a/tests/test_platform_compat.py b/tests/test_platform_compat.py index d3e42b5ae..ccdbec9fa 100644 --- a/tests/test_platform_compat.py +++ b/tests/test_platform_compat.py @@ -153,6 +153,7 @@ def test_get_wsl_windows_user_profile_prefers_powershell(monkeypatch): def test_get_wsl_windows_user_profile_falls_back_to_users_dir(monkeypatch): + import os monkeypatch.setattr(platform_compat, "is_wsl", lambda: True) def raise_run(*_a, **_k): @@ -166,11 +167,14 @@ def test_get_wsl_windows_user_profile_falls_back_to_users_dir(monkeypatch): ) def fake_isdir(path): - return path in {"/mnt/c/Users", "/mnt/c/Users/alice"} + return os.path.normpath(path) in { + os.path.normpath("/mnt/c/Users"), + os.path.normpath("/mnt/c/Users/alice") + } monkeypatch.setattr(platform_compat.os.path, "isdir", fake_isdir) - assert platform_compat.get_wsl_windows_user_profile() == "/mnt/c/Users/alice" + assert platform_compat.get_wsl_windows_user_profile() == os.path.join("/mnt/c/Users", "alice") def test_get_wsl_windows_user_profile_returns_none_when_nothing_found(monkeypatch):