From c6b715fe591e6515af7d5598f3fed3a6fbaf6088 Mon Sep 17 00:00:00 2001 From: Cameron Crow Date: Fri, 26 Jun 2026 14:18:40 -0500 Subject: [PATCH] StorageAnalyzer: make the exe GUI-first with a chromeless in-window report The single exe now opens the desktop GUI when launched with no arguments and runs the CLI when given a path or any flag (cli.main dispatches to gui.main on empty argv). It stays a console-subsystem build so the CLI can still print; gui._maybe_hide_console() hides the console via ctypes ONLY when the process exclusively owns it (GetConsoleProcessList == 1, i.e. a double-click) so a shared terminal is never hidden. After a GUI scan completes, the interactive HTML report opens automatically in a chromeless desktop window via Edge (or Chrome) `--app=file://...` launched with stdlib subprocess (find_app_browser / app_window_argv / open_report_window), falling back to webbrowser.open when neither browser is found -- preserving the zero-runtime-dependency / single-exe identity. storageanalyzer.spec lists tkinter in hiddenimports so the GUI is always bundled. Tests: tests/test_cli.py pins the no-args/--gui/path dispatch; tests/test_gui.py adds chromeless-argv + report-window fallback coverage. Full suite green (48). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/storageanalyzer/cli.py | 10 +++++ src/storageanalyzer/gui.py | 89 +++++++++++++++++++++++++++++++++++++- storageanalyzer.spec | 12 ++++- tests/test_cli.py | 36 +++++++++++++++ tests/test_gui.py | 34 +++++++++++++++ 5 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 tests/test_cli.py diff --git a/src/storageanalyzer/cli.py b/src/storageanalyzer/cli.py index 73d0282..673bf11 100644 --- a/src/storageanalyzer/cli.py +++ b/src/storageanalyzer/cli.py @@ -132,6 +132,16 @@ def _print_summary( def main(argv: list[str] | None = None) -> int: + if argv is None: + argv = sys.argv[1:] + + # No arguments at all -> open the desktop GUI (double-click / Start menu). + # Pass a path or any flag to use the command-line scanner instead. + if not argv: + from .gui import main as gui_main + + return gui_main() + args = _build_parser().parse_args(argv) if args.gui: diff --git a/src/storageanalyzer/gui.py b/src/storageanalyzer/gui.py index 89ae734..972f215 100644 --- a/src/storageanalyzer/gui.py +++ b/src/storageanalyzer/gui.py @@ -20,6 +20,8 @@ import os import queue +import shutil +import subprocess import tempfile import threading import webbrowser @@ -72,6 +74,88 @@ def file_row(entry: dict[str, Any]) -> tuple[str, str]: return (format_size(entry["size"]), entry["path"]) +# --------------------------------------------------------------------------- # +# Showing the report "in the app". # +# # +# The report is a self-contained JavaScript web app, which Tk cannot render. # +# Rather than a normal browser tab we launch a Chromium browser (Edge first, # +# then Chrome) in `--app` mode: a clean, borderless window with no tabs or # +# address bar -- as close to "inside the app" as we get without taking a # +# dependency. If no such browser is found we fall back to the default browser. # +# All stdlib (subprocess / shutil), so the zero-dependency promise holds. # +# --------------------------------------------------------------------------- # + + +def find_app_browser() -> Optional[str]: + """Path to a Chromium browser that supports ``--app`` mode (Edge, then + Chrome), or ``None`` if neither is found.""" + if os.name != "nt": + for name in ("microsoft-edge", "google-chrome", "chromium", "chrome"): + found = shutil.which(name) + if found: + return found + return None + # Windows: probe the standard install locations, Edge before Chrome. + relatives = [ + ("Microsoft", "Edge", "Application", "msedge.exe"), + ("Google", "Chrome", "Application", "chrome.exe"), + ] + for env in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"): + base = os.environ.get(env) + if not base: + continue + for parts in relatives: + candidate = os.path.join(base, *parts) + if os.path.isfile(candidate): + return candidate + return None + + +def app_window_argv( + browser_path: str, uri: str, size: tuple[int, int] = (1200, 800) +) -> list[str]: + """Build the argv that opens *uri* as a chromeless ``--app`` window.""" + width, height = size + return [browser_path, f"--app={uri}", f"--window-size={width},{height}"] + + +def open_report_window(html_path: os.PathLike[str] | str) -> None: + """Open an HTML report in a chromeless app window; fall back to the default + browser if no Chromium browser is available.""" + uri = Path(html_path).resolve().as_uri() + browser = find_app_browser() + if browser: + try: + subprocess.Popen(app_window_argv(browser, uri)) + return + except OSError: + pass # fall through to the default browser + webbrowser.open(uri) + + +def _maybe_hide_console() -> None: + """When the exe was double-clicked we exclusively own a console window; + hide it so the GUI behaves like a real desktop app. No-op when launched from + a shared terminal (so the user's shell is never hidden) or off Windows. + Pure stdlib via ctypes.""" + if os.name != "nt": + return + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + hwnd = kernel32.GetConsoleWindow() + if not hwnd: + return + # GetConsoleProcessList reports how many processes share this console; + # 1 means it is ours alone (a double-click), so it is safe to hide. + buf = (ctypes.c_uint * 1)() + if kernel32.GetConsoleProcessList(buf, 1) == 1: + ctypes.windll.user32.ShowWindow(hwnd, 0) # SW_HIDE + except Exception: + pass + + # --------------------------------------------------------------------------- # # Tk application # # --------------------------------------------------------------------------- # @@ -314,6 +398,8 @@ def _poll(self) -> None: ) self.btn_open.config(state="normal") self.btn_save.config(state="normal") + # Pop the interactive report open as soon as the scan finishes. + self._open_report() else: from tkinter import messagebox @@ -387,7 +473,7 @@ def _open_report(self) -> None: try: tmp = Path(tempfile.gettempdir()) / default_report_path().name write_report(self._report_data, tmp) - webbrowser.open(tmp.resolve().as_uri()) + open_report_window(tmp) except Exception as exc: messagebox.showerror("StorageAnalyzer", f"Could not open report:\n{exc}") @@ -412,6 +498,7 @@ def _save_report(self) -> None: def main(argv: Optional[list[str]] = None) -> int: """Launch the GUI. Returns a process exit code.""" + _maybe_hide_console() try: import tkinter as tk except ImportError: # pragma: no cover - tkinter missing from a stripped build diff --git a/storageanalyzer.spec b/storageanalyzer.spec index ccd6484..0d4a3c4 100644 --- a/storageanalyzer.spec +++ b/storageanalyzer.spec @@ -1,4 +1,7 @@ -# PyInstaller build spec -- produces a polished one-file Windows console exe. +# PyInstaller build spec -- produces a polished one-file Windows exe that opens +# the desktop GUI when launched with no arguments and runs the CLI when given +# any. It stays a console subsystem exe so the CLI can print; the GUI hides that +# console itself when it owns it (see gui._maybe_hide_console). # # Build: pyinstaller storageanalyzer.spec --noconfirm # Or just run build-exe.ps1, which (re)builds the native extension first. @@ -88,7 +91,12 @@ _icon = _icon if Path(_icon).is_file() else None # pure-Python walker. _pyd = glob.glob("src/storageanalyzer/_native_walker*.pyd") binaries = [(p, "storageanalyzer") for p in _pyd] -hiddenimports = ["storageanalyzer._native_walker"] if _pyd else [] +# tkinter is imported lazily inside gui.py, and the GUI is now the default +# action when the exe is launched with no arguments -- list it explicitly so it +# is always bundled, native walker present or not. +hiddenimports = ["tkinter"] +if _pyd: + hiddenimports.append("storageanalyzer._native_walker") a = Analysis( ["scripts/sa_entry.py"], diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..092c424 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,36 @@ +"""Tests for the CLI entry point's dispatch. + +The exe/console command opens the desktop GUI when launched with no arguments +and runs the command-line scanner when given any. These tests pin that routing +without standing up Tk or running a real scan. +""" + +from storageanalyzer import cli, gui + + +def test_no_args_launches_gui(monkeypatch): + calls = [] + monkeypatch.setattr(gui, "main", lambda *a, **k: (calls.append("gui"), 0)[1]) + assert cli.main([]) == 0 + assert calls == ["gui"] + + +def test_gui_flag_launches_gui(monkeypatch): + calls = [] + monkeypatch.setattr(gui, "main", lambda *a, **k: (calls.append("gui"), 0)[1]) + assert cli.main(["--gui"]) == 0 + assert calls == ["gui"] + + +def test_path_arg_routes_to_cli(monkeypatch, tmp_path): + # A path argument must go to the scanner, not the GUI. A bogus path takes the + # CLI validation path (exit 2), which proves we did not open the window. + monkeypatch.setattr( + gui, "main", lambda *a, **k: pytest_fail("GUI launched for a path arg") + ) + missing = tmp_path / "does-not-exist" + assert cli.main([str(missing)]) == 2 + + +def pytest_fail(msg): # tiny helper so the lambda above stays readable + raise AssertionError(msg) diff --git a/tests/test_gui.py b/tests/test_gui.py index 14e8155..c67276f 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -67,6 +67,40 @@ def test_dir_and_file_rows_format_sizes(): assert gui.file_row(data["largest_files"][0]) == ("150 B", r"C:\demo\a\big.bin") +def test_app_window_argv_builds_chromeless_command(): + argv = gui.app_window_argv( + r"C:\Edge\msedge.exe", "file:///C:/r.html", size=(1000, 700) + ) + assert argv == [ + r"C:\Edge\msedge.exe", + "--app=file:///C:/r.html", + "--window-size=1000,700", + ] + + +def test_open_report_window_uses_app_window_when_browser_found(monkeypatch, tmp_path): + html = tmp_path / "report.html" + html.write_text("", encoding="utf-8") + monkeypatch.setattr(gui, "find_app_browser", lambda: r"C:\Edge\msedge.exe") + recorded = {} + monkeypatch.setattr( + gui.subprocess, "Popen", lambda argv, *a, **k: recorded.update(argv=argv) + ) + gui.open_report_window(html) + assert recorded["argv"][0] == r"C:\Edge\msedge.exe" + assert recorded["argv"][1].startswith("--app=file:") + + +def test_open_report_window_falls_back_to_default_browser(monkeypatch, tmp_path): + html = tmp_path / "report.html" + html.write_text("", encoding="utf-8") + monkeypatch.setattr(gui, "find_app_browser", lambda: None) + opened = {} + monkeypatch.setattr(gui.webbrowser, "open", lambda uri: opened.update(uri=uri)) + gui.open_report_window(html) + assert opened["uri"].startswith("file:") + + def test_app_constructs_when_display_available(): """Smoke test: build the window and verify it renders results. Skips headless.""" tk = pytest.importorskip("tkinter")