diff --git a/README.md b/README.md index 068bab6..7904049 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-Glint icon +Glint icon # Glint @@ -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 17a04bb..0000000 --- a/glasshub-updater/updater-main.py +++ /dev/null @@ -1,14 +0,0 @@ -"""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. -""" - - -def main() -> int: - print("Update Glint through the package source used to install it.") - 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 -""" 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/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/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/src/ui/tray.py b/src/ui/tray.py index 059355d..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) @@ -41,6 +43,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 +81,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) @@ -74,5 +95,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_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) diff --git a/tests/test_ui.py b/tests/test_ui.py index 055b2b9..c559abe 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,62 @@ 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() + + +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()