From e6baa38945e41ae6704306034e02fddea64ec2bc Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Sat, 22 Aug 2026 10:59:51 +1000 Subject: [PATCH 1/6] fix: fall back to defaults on malformed widget layouts - wrong-shape layout JSON no longer escapes load_layout as TypeError - widget entries with non-numeric geometry or non-string disk are dropped - regression tests cover both malformed-layout paths --- src/ui/layout.py | 26 +++++++++++++++++++++----- tests/test_core.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/ui/layout.py b/src/ui/layout.py index ef9e25f..cf893b7 100644 --- a/src/ui/layout.py +++ b/src/ui/layout.py @@ -10,6 +10,8 @@ from src.core.settings_storage import config_dir from src.ui.widgets import WIDGET_TYPES, BaseWidget +GEOMETRY_FIELDS = ("x", "y", "width", "height") + DEFAULT_LAYOUT: dict[str, Any] = { "schema_version": 1, "width": 280, @@ -30,17 +32,31 @@ def layout_path(name: str = "default") -> Path: return config_dir() / f"{safe_name}_layout.json" +def _is_number(value: object) -> bool: + # bool is an int subclass but is never valid geometry. + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _valid_widget(definition: object) -> bool: + """Accept only widget entries whose geometry fields will not crash QRectF.""" + if not isinstance(definition, dict) or definition.get("type") not in WIDGET_TYPES: + return False + for field in GEOMETRY_FIELDS: + if field in definition and not _is_number(definition[field]): + return False + disk = definition.get("disk") + return disk is None or isinstance(disk, str) + + def load_layout(name: str = "default", path: Path | None = None) -> dict[str, Any]: target = path or layout_path(name) try: data = json.loads(target.read_text(encoding="utf-8")) if not isinstance(data, dict) or not isinstance(data.get("widgets"), list): - raise TypeError - data["widgets"] = [ - item for item in data["widgets"] if isinstance(item, dict) and item.get("type") in WIDGET_TYPES - ] + raise TypeError # caught below so wrong shapes fall back instead of escaping + data["widgets"] = [item for item in data["widgets"] if _valid_widget(item)] return data - except (OSError, json.JSONDecodeError, ValueError): + except (OSError, json.JSONDecodeError, ValueError, TypeError): # TypeError: malformed shape above return deepcopy(DEFAULT_LAYOUT) diff --git a/tests/test_core.py b/tests/test_core.py index c6f9d81..a1e8c3c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,3 +1,4 @@ +import json from unittest.mock import Mock, patch from src.core.sensors import SensorReader @@ -29,6 +30,36 @@ def test_layout_round_trip(tmp_path): assert [item["type"] for item in loaded["widgets"]] == [widget.widget_type for widget in widgets] +def test_wrong_shape_layout_falls_back_to_defaults(tmp_path): + # Regression: a valid-JSON layout with a non-list "widgets" used to raise + # an uncaught TypeError instead of falling back. + target = tmp_path / "layout.json" + target.write_text('{"widgets": 3}', encoding="utf-8") + assert load_layout(path=target) == load_layout(path=tmp_path / "missing.json") + + +def test_malformed_widget_entries_are_dropped(tmp_path): + # Regression: non-numeric geometry crashed QRectF during instantiation. + target = tmp_path / "layout.json" + target.write_text( + json.dumps( + { + "width": 280, + "height": 290, + "widgets": [ + {"type": "cpu", "x": "abc", "y": 30, "width": 236, "height": 38}, + {"type": "ram", "x": 22, "y": True, "width": 236, "height": 38}, + {"type": "disk", "x": 22, "y": 72, "width": 236, "height": 38, "disk": 7}, + {"type": "network", "x": 22, "y": 244, "width": 236, "height": 28}, + ], + } + ), + encoding="utf-8", + ) + widgets = create_widgets(load_layout(path=target)) + assert [widget.widget_type for widget in widgets] == ["network"] + + def test_network_throughput_is_delta_per_second(): first = Mock(bytes_sent=100, bytes_recv=200) second = Mock(bytes_sent=1124, bytes_recv=2248) From d8b03f941984b9c9c6d9bdf2ce0899d51189042a Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Sat, 22 Aug 2026 11:03:05 +1000 Subject: [PATCH 2/6] fix: save layout on every exit path - GlassHUD._quit becomes public shutdown() used by both exit routes - tray Exit now saves the layout instead of quitting silently - regression tests cover shutdown persistence and tray routing --- src/ui/hud.py | 6 ++++-- src/ui/tray.py | 4 +++- tests/test_ui.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/ui/hud.py b/src/ui/hud.py index 74f73f4..8d81085 100644 --- a/src/ui/hud.py +++ b/src/ui/hud.py @@ -100,7 +100,7 @@ def mousePressEvent(self, event) -> None: menu = QMenu(self) menu.addAction("Settings", self.open_settings) menu.addSeparator() - menu.addAction("Exit", self._quit) + menu.addAction("Exit", self.shutdown) menu.exec(event.globalPosition().toPoint()) def mouseMoveEvent(self, event) -> None: @@ -112,6 +112,8 @@ def mouseReleaseEvent(self, event) -> None: self.settings["window"] = {"x": self.x(), "y": self.y()} save_settings(self.settings) - def _quit(self) -> None: + def shutdown(self) -> None: + # Single exit path so the layout is saved whether the user exits from + # the HUD context menu or the tray menu. save_layout(self.widgets, self.width(), self.height(), self.settings["layout"]) QApplication.instance().quit() diff --git a/src/ui/tray.py b/src/ui/tray.py index 059355d..df6cb43 100644 --- a/src/ui/tray.py +++ b/src/ui/tray.py @@ -74,5 +74,7 @@ def on_activated(self, reason) -> None: self.show_hud() def exit_app(self) -> None: + # Route through the HUD's shutdown so the layout is saved exactly as + # with the HUD context menu's Exit action. self.tray.hide() - self.app.quit() + self.hud.shutdown() diff --git a/tests/test_ui.py b/tests/test_ui.py index 055b2b9..3744206 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,4 +1,6 @@ import os +from types import SimpleNamespace +from unittest.mock import Mock os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -15,3 +17,35 @@ def test_settings_is_an_independent_window(): assert window.isWindow() window.close() app.processEvents() + + +def _make_hud(monkeypatch): + """Build a real GlassHUD with storage redirected away from the user config.""" + import src.ui.hud as hud_module + + monkeypatch.setattr(hud_module, "load_settings", lambda: dict(DEFAULT_SETTINGS)) + monkeypatch.setattr(hud_module, "save_settings", lambda settings: settings) + monkeypatch.setattr(hud_module, "load_layout", lambda *a, **k: {"width": 280, "height": 290, "widgets": []}) + saved_layouts = [] + monkeypatch.setattr(hud_module, "save_layout", lambda *a: saved_layouts.append(a)) + return hud_module.GlassHUD(), saved_layouts + + +def test_hud_shutdown_saves_layout(monkeypatch): + app = QApplication.instance() or QApplication([]) + monkeypatch.setattr("src.ui.hud.QApplication.instance", staticmethod(lambda: Mock())) + hud, saved = _make_hud(monkeypatch) + hud.shutdown() + assert len(saved) == 1 # Regression: every exit path must persist the layout. + app.processEvents() + + +def test_tray_exit_routes_through_hud_shutdown(monkeypatch): + from src.ui.tray import TrayManager + + app = QApplication.instance() or QApplication([]) + hud = SimpleNamespace(open_settings=lambda: None, shutdown=Mock()) + tray = TrayManager(app, hud) + tray.exit_app() + hud.shutdown.assert_called_once() # Regression: tray Exit used to quit without saving. + app.processEvents() From a92063fafbb874080d917535d937bf46489d75d6 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Sat, 22 Aug 2026 11:05:05 +1000 Subject: [PATCH 3/6] fix: correct autostart entries for frozen builds and spaced paths - frozen builds launch the bundle binary without -m src - Windows/Linux entries quote executable paths containing spaces - macOS plist builds ProgramArguments from the shared argument list --- src/ui/tray.py | 45 ++++++++++++++++++++++++++++++++------------- tests/test_ui.py | 27 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/ui/tray.py b/src/ui/tray.py index df6cb43..929e192 100644 --- a/src/ui/tray.py +++ b/src/ui/tray.py @@ -41,6 +41,37 @@ def startup_path() -> Path: / "autostart/glint.desktop" ) + @staticmethod + def _launch_arguments() -> list[str]: + # Frozen builds run their bundled binary directly; source checkouts + # must go through the interpreter with -m src. + if getattr(sys, "frozen", False): + return [sys.executable] + return [sys.executable, "-m", "src"] + + @staticmethod + def _startup_content() -> str: + args = list(TrayManager._launch_arguments()) + system = platform.system() + + def shell_quote(value: str) -> str: + # Only paths can contain spaces; flags like -m never need quoting. + return f'"{value}"' if " " in value else value + + if system == "Windows": + # @start swallows the first quoted token as a window title. + return '@start "" ' + " ".join(shell_quote(arg) for arg in args) + "\n" + if system == "Darwin": + entries = "".join(f"{arg}" for arg in args) + return ( + '' + "Labeldev.zford.glint" + f"ProgramArguments{entries}" + "RunAtLoad" + ) + exec_value = " ".join(shell_quote(arg) for arg in args) # Exec requires quoting for spaces. + return f"[Desktop Entry]\nType=Application\nName=Glint\nExec={exec_value}\nX-GNOME-Autostart-enabled=true\n" + def toggle_startup(self, enabled: bool) -> None: path = self.startup_path() try: @@ -48,19 +79,7 @@ def toggle_startup(self, enabled: bool) -> None: path.unlink(missing_ok=True) return path.parent.mkdir(parents=True, exist_ok=True) - executable = Path(sys.executable) - if platform.system() == "Windows": - content = f'@start "" "{executable}" -m src\n' - elif platform.system() == "Darwin": - content = ( - f'' - f"Labeldev.zford.glintProgramArguments" - f"{executable}-msrc" - f"RunAtLoad" - ) - else: - content = f"[Desktop Entry]\nType=Application\nName=Glint\nExec={executable} -m src\nX-GNOME-Autostart-enabled=true\n" - path.write_text(content, encoding="utf-8") + path.write_text(self._startup_content(), encoding="utf-8") except OSError: self.startup_action.setChecked(not enabled) diff --git a/tests/test_ui.py b/tests/test_ui.py index 3744206..c559abe 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -49,3 +49,30 @@ def test_tray_exit_routes_through_hud_shutdown(monkeypatch): tray.exit_app() hud.shutdown.assert_called_once() # Regression: tray Exit used to quit without saving. app.processEvents() + + +def test_startup_launch_arguments_frozen_vs_source(monkeypatch): + import sys + + from src.ui.tray import TrayManager + + monkeypatch.setattr(sys, "frozen", True, raising=False) + assert TrayManager._launch_arguments() == [sys.executable] # Frozen builds must not pass -m src. + monkeypatch.delattr(sys, "frozen", raising=False) + assert TrayManager._launch_arguments() == [sys.executable, "-m", "src"] + + +def test_autostart_content_quotes_executable_paths(monkeypatch): + from src.ui.tray import TrayManager + + spaced = "C:\\Program Files\\Glint.exe" + monkeypatch.setattr(TrayManager, "_launch_arguments", staticmethod(lambda: [spaced])) + + monkeypatch.setattr("src.ui.tray.platform.system", lambda: "Windows") + assert TrayManager._startup_content() == f'@start "" "{spaced}"\n' + + monkeypatch.setattr("src.ui.tray.platform.system", lambda: "Linux") + assert f'Exec="{spaced}"' in TrayManager._startup_content() + + monkeypatch.setattr("src.ui.tray.platform.system", lambda: "Darwin") + assert f"{spaced}" in TrayManager._startup_content() From c5534af79c80587af61cac939c0f5ba1a0bfcf02 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Sat, 22 Aug 2026 11:08:38 +1000 Subject: [PATCH 4/6] fix: ship tray icon inside the src package - move assets/icon.svg to src/assets so wheels include it - resolve the icon package-relative in TrayManager - point PyInstaller add-data at the packaged location --- README.md | 2 +- pyproject.toml | 2 +- scripts/release.py | 2 +- {assets => src/assets}/icon.svg | 0 src/ui/tray.py | 4 +++- 5 files changed, 6 insertions(+), 4 deletions(-) rename {assets => src/assets}/icon.svg (100%) diff --git a/README.md b/README.md index 068bab6..85dac52 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-Glint icon +Glint icon # Glint diff --git a/pyproject.toml b/pyproject.toml index 6ab3f26..49b2e7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ where = ["."] include = ["src*"] [tool.setuptools.package-data] -src = ["themes.json"] +src = ["themes.json", "assets/*.svg"] [project.optional-dependencies] dev = ["pytest>=8", "ruff>=0.6", "tomli>=2; python_version < '3.11'"] diff --git a/scripts/release.py b/scripts/release.py index b996b19..0a8f50b 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -55,7 +55,7 @@ def build(asset_name: str, archive: str) -> Path: "--add-data", f"{ROOT / 'src' / 'themes.json'}:src", "--add-data", - f"{ROOT / 'assets'}:assets", + f"{ROOT / 'src' / 'assets'}:src/assets", str(ROOT / "main.py"), ] subprocess.run(command, cwd=ROOT, check=True) diff --git a/assets/icon.svg b/src/assets/icon.svg similarity index 100% rename from assets/icon.svg rename to src/assets/icon.svg diff --git a/src/ui/tray.py b/src/ui/tray.py index 929e192..898bf7f 100644 --- a/src/ui/tray.py +++ b/src/ui/tray.py @@ -13,7 +13,9 @@ class TrayManager: def __init__(self, app, hud) -> None: self.app, self.hud = app, hud - icon = Path(__file__).parents[2] / "assets" / "icon.svg" + # Resolved inside the package so source checkouts, wheels, and frozen + # bundles all find it without install-specific path logic. + icon = Path(__file__).parents[1] / "assets" / "icon.svg" self.tray = QSystemTrayIcon(QIcon(str(icon)), app) menu = QMenu() menu.addAction("Show Glint", self.show_hud) From 9181ef7e3ea86a799d078d61b15305d79bc6a263 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Sat, 22 Aug 2026 11:10:25 +1000 Subject: [PATCH 5/6] docs: align updater placeholder with GitHub Releases distribution --- glasshub-updater/updater-main.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/glasshub-updater/updater-main.py b/glasshub-updater/updater-main.py index 17a04bb..7e7d21d 100644 --- a/glasshub-updater/updater-main.py +++ b/glasshub-updater/updater-main.py @@ -1,12 +1,13 @@ """Legacy updater placeholder. -The unreleased updater was removed from packaging for 1.0 because installing -updates is now delegated to each platform's package manager. +The unreleased updater was removed from packaging for 1.0 because Glint is +distributed exclusively through GitHub Releases; users download new versions +manually from https://github.com/ZFordDev/Glint/releases/latest. """ def main() -> int: - print("Update Glint through the package source used to install it.") + print("Download new Glint versions manually from GitHub Releases.") return 0 From 23d3044dbc8ecba9637f8d8bd6b7423886cd5371 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Sat, 22 Aug 2026 11:19:07 +1000 Subject: [PATCH 6/6] chore: remove legacy glasshub-updater prototype - delete the unsupported placeholder package - README now states updates come from GitHub Releases --- README.md | 2 +- glasshub-updater/updater-main.py | 15 --------------- glasshub-updater/updater.py | 6 ------ 3 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 glasshub-updater/updater-main.py delete mode 100644 glasshub-updater/updater.py diff --git a/README.md b/README.md index 85dac52..7904049 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Glint has no accounts, analytics, telemetry, advertising, or cloud service. Syst ## Project status -Glint v1.0.0 is the first stable, cross-platform release. Distribution is intentionally minimal and GitHub-exclusive: there are no Microsoft Store, Mac App Store, Snap Store, or other store packages, and the legacy `glasshub-updater` prototype is not a supported update path. +Glint v1.0.0 is the first stable, cross-platform release. Distribution is intentionally minimal and GitHub-exclusive: there are no Microsoft Store, Mac App Store, Snap Store, or other store packages, and there is no in-application updater; new versions are downloaded manually from GitHub Releases. Releases are built by GitHub Actions from matching `v*` tags. The workflow verifies formatting, lint, tests, and version consistency before creating native archives and SHA-256 checksums. Maintainer details are documented in [architecture and maintenance notes](https://docs.zford.dev/zforddev/glint/maintenance/). diff --git a/glasshub-updater/updater-main.py b/glasshub-updater/updater-main.py deleted file mode 100644 index 7e7d21d..0000000 --- a/glasshub-updater/updater-main.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Legacy updater placeholder. - -The unreleased updater was removed from packaging for 1.0 because Glint is -distributed exclusively through GitHub Releases; users download new versions -manually from https://github.com/ZFordDev/Glint/releases/latest. -""" - - -def main() -> int: - print("Download new Glint versions manually from GitHub Releases.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/glasshub-updater/updater.py b/glasshub-updater/updater.py deleted file mode 100644 index 3bfee26..0000000 --- a/glasshub-updater/updater.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -updater.py ----------- - -the update logic as a seprate run time -"""