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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div align="center">

<img src="assets/icon.svg" width="112" alt="Glint icon">
<img src="src/assets/icon.svg" width="112" alt="Glint icon">

# Glint

Expand Down Expand Up @@ -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/).

Expand Down
14 changes: 0 additions & 14 deletions glasshub-updater/updater-main.py

This file was deleted.

6 changes: 0 additions & 6 deletions glasshub-updater/updater.py

This file was deleted.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'"]
Expand Down
2 changes: 1 addition & 1 deletion scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
File renamed without changes
6 changes: 4 additions & 2 deletions src/ui/hud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
26 changes: 21 additions & 5 deletions src/ui/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)


Expand Down
53 changes: 38 additions & 15 deletions src/ui/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -41,26 +43,45 @@ 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"<string>{arg}</string>" for arg in args)
return (
'<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict>'
"<key>Label</key><string>dev.zford.glint</string>"
f"<key>ProgramArguments</key><array>{entries}</array>"
"<key>RunAtLoad</key><true/></dict></plist>"
)
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:
if not enabled:
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'<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict>'
f"<key>Label</key><string>dev.zford.glint</string><key>ProgramArguments</key><array>"
f"<string>{executable}</string><string>-m</string><string>src</string></array>"
f"<key>RunAtLoad</key><true/></dict></plist>"
)
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)

Expand All @@ -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()
31 changes: 31 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from unittest.mock import Mock, patch

from src.core.sensors import SensorReader
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions tests/test_ui.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import os
from types import SimpleNamespace
from unittest.mock import Mock

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

Expand All @@ -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"<string>{spaced}</string>" in TrayManager._startup_content()
Loading