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

1"""app - the web app in a desktop window (pywebview, else a Chromium --app window, else a browser tab).""" 

2 

3from __future__ import annotations 

4 

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 

15 

16COMMAND = "app" 

17TITLE = "Lunar Toolkit" 

18SIZE = (1440, 900) 

19MIN_SIZE = (900, 600) 

20BACKENDS = ("auto", "webview", "chromium", "browser") 

21 

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) 

38 

39 

40def add_parser(sub: argparse._SubParsersAction) -> None: 

41 from .serve import add_server_args 

42 

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 

54 

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 ) 

70 

71 

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 

86 

87 

88def _has(module: str) -> bool: 

89 try: 

90 return importlib.util.find_spec(module) is not None 

91 except (ImportError, ValueError): 

92 return False 

93 

94 

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 

107 

108 

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 

129 

130 

131INSTALL_HINT = 'pip install "lunar-toolkit[app]"' 

132 

133 

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 

145 

146 

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 

150 

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") 

154 

155 qInstallMessageHandler(handler) 

156 

157 

158def _run_webview(url: str, gui: str) -> None: 

159 import webview 

160 

161 if gui == "qt": 

162 _quiet_qt_exit() 

163 

164 from ..constants import DEFAULT_CACHE_DIR 

165 

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)) 

170 

171 

172def _run_chromium(url: str, binary: str) -> None: 

173 from ..constants import DEFAULT_CACHE_DIR 

174 

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) 

186 

187 

188def run(args: argparse.Namespace) -> int: 

189 from .serve import make_server, print_banner 

190 

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