Coverage for lunar_toolkit/cli/app.py: 65%
143 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 02:58 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 02:58 +0000
1"""app - the web app in a desktop window (pywebview, else a Chromium --app window, else a browser tab)."""
3from __future__ import annotations
5import argparse
6import importlib.util
7import os
8import shutil
9import subprocess
10import sys
11import threading
12import webbrowser
13from pathlib import Path
14from typing import Any, cast
16COMMAND = "app"
17TITLE = "Lunar Toolkit"
18SIZE = (1440, 900)
19MIN_SIZE = (900, 600)
20BACKENDS = ("auto", "webview", "chromium", "browser")
22_CHROMIUM_NAMES = (
23 "google-chrome",
24 "google-chrome-stable",
25 "chromium",
26 "chromium-browser",
27 "microsoft-edge",
28 "microsoft-edge-stable",
29 "brave-browser",
30)
31_MAC_APPS = ("Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser")
32_WIN_PATHS = (
33 ("Google", "Chrome", "Application", "chrome.exe"),
34 ("Microsoft", "Edge", "Application", "msedge.exe"),
35 ("Chromium", "Application", "chrome.exe"),
36 ("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
37)
40def add_parser(sub: argparse._SubParsersAction) -> None:
41 from .serve import add_server_args
43 p = sub.add_parser(
44 COMMAND,
45 help="Open the Lunar Toolkit web app in a desktop window.",
46 formatter_class=argparse.RawDescriptionHelpFormatter,
47 epilog="""
48Examples
49--------
50 lunar app
51 lunar app --images ~/lunar_runs
52 lunar app --host 0.0.0.0 --port 8080 # window here, read-only page for the network
53 lunar app --backend browser # a normal browser tab instead
55The server runs on this machine (a free port on 127.0.0.1 by default) and stops when
56the window closes. Window backends, first available wins (--backend auto):
57 webview pywebview: Qt WebEngine on Linux (pip install "lunar-toolkit[app]",
58 or PyGObject + WebKitGTK), Edge WebView2 on Windows, WebKit on macOS
59 chromium Chrome / Chromium / Edge / Brave in --app mode (own profile in the cache dir)
60 browser the default browser (Ctrl-C stops the server)
61""",
62 )
63 add_server_args(p, "127.0.0.1", 0)
64 p.add_argument(
65 "--backend",
66 choices=BACKENDS,
67 default="auto",
68 help="Window backend (default: auto, in the order above).",
69 )
72def webview_gui(platform: str | None = None) -> str | None:
73 """The pywebview GUI to use here (``qt``, ``gtk``, ``cocoa``, ``edgechromium``), or None."""
74 platform = platform or sys.platform
75 if not _has("webview"):
76 return None
77 if platform == "darwin":
78 return "cocoa" if _has("WebKit") else None
79 if platform == "win32":
80 return "edgechromium" if _has("clr") else None
81 if _has("qtpy") and (_has("PyQt6.QtWebEngineWidgets") or _has("PySide6.QtWebEngineWidgets")):
82 return "qt"
83 if _has("gi") and _gtk_webkit(): 83 ↛ 85line 83 didn't jump to line 85 because the condition on line 83 was always true
84 return "gtk"
85 return None
88def _has(module: str) -> bool:
89 try:
90 return importlib.util.find_spec(module) is not None
91 except (ImportError, ValueError):
92 return False
95def _gtk_webkit() -> bool:
96 try:
97 gi = importlib.import_module("gi")
98 for version in ("4.1", "4.0"):
99 try:
100 gi.require_version("WebKit2", version)
101 return True
102 except ValueError:
103 continue
104 except ImportError:
105 pass
106 return False
109def chromium_binary(platform: str | None = None) -> str | None:
110 """A Chrome / Chromium / Edge / Brave executable, or None."""
111 platform = platform or sys.platform
112 if platform == "darwin":
113 for app in _MAC_APPS:
114 p = Path("/Applications") / f"{app}.app" / "Contents" / "MacOS" / app
115 if p.is_file(): 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 return str(p)
117 elif platform == "win32":
118 roots = [os.environ.get(v) for v in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA")]
119 for root in filter(None, roots): 119 ↛ 124line 119 didn't jump to line 124 because the loop on line 119 didn't complete
120 for parts in _WIN_PATHS: 120 ↛ 119line 120 didn't jump to line 119 because the loop on line 120 didn't complete
121 p = Path(root, *parts)
122 if p.is_file():
123 return str(p)
124 for name in _CHROMIUM_NAMES:
125 found = shutil.which(name)
126 if found:
127 return found
128 return None
131INSTALL_HINT = 'pip install "lunar-toolkit[app]"'
134def choose_backend(requested: str = "auto", platform: str | None = None) -> tuple[str, str | None]:
135 """``(backend, detail)``: the pywebview GUI name, the Chromium binary, or None for the browser."""
136 if requested in ("auto", "webview"):
137 gui = webview_gui(platform)
138 if gui:
139 return "webview", gui
140 if requested in ("auto", "chromium"):
141 binary = chromium_binary(platform)
142 if binary:
143 return "chromium", binary
144 return "browser", None
147def _quiet_qt_exit() -> None:
148 """Drop Qt WebEngine's harmless "Release of profile requested" warning on window close."""
149 from qtpy.QtCore import qInstallMessageHandler
151 def handler(mode: Any, context: Any, message: str | None) -> None:
152 if message and "WebEnginePage still not deleted" not in message:
153 sys.stderr.write(message + "\n")
155 qInstallMessageHandler(handler)
158def _run_webview(url: str, gui: str) -> None:
159 import webview
161 if gui == "qt":
162 _quiet_qt_exit()
164 from ..constants import DEFAULT_CACHE_DIR
166 webview.create_window(TITLE, url, width=SIZE[0], height=SIZE[1], min_size=MIN_SIZE)
167 storage = DEFAULT_CACHE_DIR / "app-profile" / "webview"
168 storage.mkdir(parents=True, exist_ok=True)
169 webview.start(gui=cast(Any, gui), private_mode=False, storage_path=str(storage))
172def _run_chromium(url: str, binary: str) -> None:
173 from ..constants import DEFAULT_CACHE_DIR
175 profile = DEFAULT_CACHE_DIR / "app-profile"
176 profile.mkdir(parents=True, exist_ok=True)
177 cmd = [
178 binary,
179 f"--app={url}",
180 f"--user-data-dir={profile}",
181 f"--window-size={SIZE[0]},{SIZE[1]}",
182 "--no-first-run",
183 "--no-default-browser-check",
184 ]
185 subprocess.run(cmd, check=False)
188def run(args: argparse.Namespace) -> int:
189 from .serve import make_server, print_banner
191 backend, detail = choose_backend(args.backend)
192 if args.backend == "webview" and backend != "webview": 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true
193 print(
194 f"No native window toolkit for pywebview here; {INSTALL_HINT}, or use --backend chromium / browser."
195 )
196 return 1
197 try:
198 server, cfg = make_server(args)
199 except (ValueError, OSError) as exc:
200 print(exc)
201 return 1
202 url = print_banner(server, cfg, args.host, TITLE)
203 thread = threading.Thread(target=server.serve_forever, name="lunar-server", daemon=True)
204 thread.start()
205 if backend != "webview" and args.backend == "auto": 205 ↛ 207line 205 didn't jump to line 207 because the condition on line 205 was always true
206 print(f" note : no native window toolkit; {INSTALL_HINT} for one")
207 try:
208 if backend == "webview" and detail: 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true
209 print(f" window : pywebview ({detail})")
210 try:
211 _run_webview(url, detail)
212 return 0
213 except Exception as exc: # no display, broken GUI toolkit, ...
214 print(f" pywebview failed ({exc}); trying a Chromium window")
215 backend, detail = choose_backend("chromium")
216 if backend == "chromium" and detail: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true
217 print(f" window : {Path(detail).name} --app")
218 _run_chromium(url, detail)
219 return 0
220 print(" window : no Chrome / Chromium / Edge either; opening a browser tab")
221 print(" Ctrl-C to stop.\n")
222 webbrowser.open(url)
223 while thread.is_alive():
224 thread.join(0.5)
225 except KeyboardInterrupt:
226 print("\nStopped.")
227 finally:
228 server.close()
229 return 0