diff --git a/README.md b/README.md
index 068bab6..7904049 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-

+

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