Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/storageanalyzer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
89 changes: 88 additions & 1 deletion src/storageanalyzer/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

import os
import queue
import shutil
import subprocess
import tempfile
import threading
import webbrowser
Expand Down Expand Up @@ -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 #
# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}")

Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions storageanalyzer.spec
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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"],
Expand Down
36 changes: 36 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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)
34 changes: 34 additions & 0 deletions tests/test_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<html></html>", 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("<html></html>", 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")
Expand Down
Loading