Show update indicator when repo has new commits
This commit is contained in:
@@ -45,6 +45,8 @@ SYSTEM_AUTOSTART_FILE = "/etc/xdg/autostart/sovran-hub.desktop"
|
|||||||
|
|
||||||
DOWNLOADS_DIR = os.path.join(os.path.expanduser("~"), "Downloads")
|
DOWNLOADS_DIR = os.path.join(os.path.expanduser("~"), "Downloads")
|
||||||
|
|
||||||
|
FLAKE_DIR = "/etc/nixos"
|
||||||
|
|
||||||
UPDATE_COMMAND = [
|
UPDATE_COMMAND = [
|
||||||
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes",
|
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes",
|
||||||
"root@localhost",
|
"root@localhost",
|
||||||
@@ -57,6 +59,9 @@ REBOOT_COMMAND = [
|
|||||||
"reboot",
|
"reboot",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# How often to check for updates (seconds) — every 30 minutes
|
||||||
|
UPDATE_CHECK_INTERVAL = 1800
|
||||||
|
|
||||||
|
|
||||||
def get_autostart_enabled() -> bool:
|
def get_autostart_enabled() -> bool:
|
||||||
if os.path.isfile(USER_AUTOSTART_FILE):
|
if os.path.isfile(USER_AUTOSTART_FILE):
|
||||||
@@ -88,6 +93,58 @@ def set_autostart_enabled(enabled: bool):
|
|||||||
f.write("Hidden=true\n")
|
f.write("Hidden=true\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_local_rev():
|
||||||
|
"""Get the local HEAD commit of the flake repo."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", FLAKE_DIR, "rev-parse", "HEAD"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_remote_rev():
|
||||||
|
"""Fetch and get the remote HEAD commit without pulling."""
|
||||||
|
try:
|
||||||
|
# Fetch latest refs from origin
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", FLAKE_DIR, "fetch", "--quiet", "origin"],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
# Get the remote branch HEAD
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", FLAKE_DIR, "rev-parse", "origin/HEAD"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
# Fallback: try origin/main or origin/master
|
||||||
|
for branch in ["origin/main", "origin/master"]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", FLAKE_DIR, "rev-parse", branch],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_for_updates() -> bool:
|
||||||
|
"""Return True if remote has new commits ahead of local."""
|
||||||
|
local = _get_local_rev()
|
||||||
|
remote = _get_remote_rev()
|
||||||
|
if local and remote and local != remote:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class UpdateDialog(Adw.Window):
|
class UpdateDialog(Adw.Window):
|
||||||
"""Modal window that streams the system update output."""
|
"""Modal window that streams the system update output."""
|
||||||
|
|
||||||
@@ -105,12 +162,10 @@ class UpdateDialog(Adw.Window):
|
|||||||
|
|
||||||
header = Adw.HeaderBar()
|
header = Adw.HeaderBar()
|
||||||
|
|
||||||
# ── Close button (disabled during update) ────────────────
|
|
||||||
self._close_btn = Gtk.Button(label="Close", sensitive=False)
|
self._close_btn = Gtk.Button(label="Close", sensitive=False)
|
||||||
self._close_btn.connect("clicked", lambda _b: self.close())
|
self._close_btn.connect("clicked", lambda _b: self.close())
|
||||||
header.pack_end(self._close_btn)
|
header.pack_end(self._close_btn)
|
||||||
|
|
||||||
# ── Reboot button (hidden until update succeeds) ─────────
|
|
||||||
self._reboot_btn = Gtk.Button(
|
self._reboot_btn = Gtk.Button(
|
||||||
label="Reboot",
|
label="Reboot",
|
||||||
css_classes=["destructive-action"],
|
css_classes=["destructive-action"],
|
||||||
@@ -120,7 +175,6 @@ class UpdateDialog(Adw.Window):
|
|||||||
self._reboot_btn.connect("clicked", self._on_reboot_clicked)
|
self._reboot_btn.connect("clicked", self._on_reboot_clicked)
|
||||||
header.pack_end(self._reboot_btn)
|
header.pack_end(self._reboot_btn)
|
||||||
|
|
||||||
# ── Save error report button (hidden until failure) ──────
|
|
||||||
self._save_btn = Gtk.Button(
|
self._save_btn = Gtk.Button(
|
||||||
label="Save Error Report",
|
label="Save Error Report",
|
||||||
css_classes=["warning"],
|
css_classes=["warning"],
|
||||||
@@ -279,6 +333,7 @@ class SovranHubWindow(Adw.ApplicationWindow):
|
|||||||
)
|
)
|
||||||
self._config = config
|
self._config = config
|
||||||
self._tiles = []
|
self._tiles = []
|
||||||
|
self._update_available = False
|
||||||
|
|
||||||
css_path = os.environ.get("SOVRAN_HUB_CSS", "")
|
css_path = os.environ.get("SOVRAN_HUB_CSS", "")
|
||||||
if css_path and os.path.isfile(css_path):
|
if css_path and os.path.isfile(css_path):
|
||||||
@@ -292,14 +347,22 @@ class SovranHubWindow(Adw.ApplicationWindow):
|
|||||||
header = Adw.HeaderBar()
|
header = Adw.HeaderBar()
|
||||||
header.set_title_widget(self._build_title_box())
|
header.set_title_widget(self._build_title_box())
|
||||||
|
|
||||||
# ── Update button (left side, prominent) ─────────────────
|
# ── Update button (left side) ────────────────────────────
|
||||||
update_btn = Gtk.Button(
|
self._update_btn = Gtk.Button(
|
||||||
label="Update System",
|
label="Update System",
|
||||||
css_classes=["suggested-action"],
|
css_classes=["suggested-action"],
|
||||||
tooltip_text="Update Sovran_SystemsOS (flake update + rebuild + flatpak)",
|
tooltip_text="System is up to date",
|
||||||
)
|
)
|
||||||
update_btn.connect("clicked", self._on_update_clicked)
|
self._update_btn.connect("clicked", self._on_update_clicked)
|
||||||
header.pack_start(update_btn)
|
header.pack_start(self._update_btn)
|
||||||
|
|
||||||
|
# ── Update badge (dot indicator, hidden by default) ──────
|
||||||
|
self._badge = Gtk.Label(
|
||||||
|
label=" ●",
|
||||||
|
css_classes=["update-badge"],
|
||||||
|
visible=False,
|
||||||
|
)
|
||||||
|
header.pack_start(self._badge)
|
||||||
|
|
||||||
# ── Refresh button ───────────────────────────────────────
|
# ── Refresh button ───────────────────────────────────────
|
||||||
refresh_btn = Gtk.Button(
|
refresh_btn = Gtk.Button(
|
||||||
@@ -369,6 +432,10 @@ class SovranHubWindow(Adw.ApplicationWindow):
|
|||||||
if interval and interval > 0:
|
if interval and interval > 0:
|
||||||
GLib.timeout_add_seconds(interval, self._auto_refresh)
|
GLib.timeout_add_seconds(interval, self._auto_refresh)
|
||||||
|
|
||||||
|
# ── Kick off first update check, then repeat ─────────────
|
||||||
|
GLib.timeout_add_seconds(5, self._check_for_updates_once)
|
||||||
|
GLib.timeout_add_seconds(UPDATE_CHECK_INTERVAL, self._periodic_update_check)
|
||||||
|
|
||||||
def _build_title_box(self):
|
def _build_title_box(self):
|
||||||
role = self._config.get("role", "server_plus_desktop")
|
role = self._config.get("role", "server_plus_desktop")
|
||||||
role_label = ROLE_LABELS.get(role, role)
|
role_label = ROLE_LABELS.get(role, role)
|
||||||
@@ -449,10 +516,47 @@ class SovranHubWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
GLib.idle_add(self._refresh_all)
|
GLib.idle_add(self._refresh_all)
|
||||||
|
|
||||||
|
# ── Update availability check ────────────────────────────────
|
||||||
|
|
||||||
|
def _check_for_updates_once(self):
|
||||||
|
thread = threading.Thread(target=self._do_update_check, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return False # run once
|
||||||
|
|
||||||
|
def _periodic_update_check(self):
|
||||||
|
thread = threading.Thread(target=self._do_update_check, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return True # keep repeating
|
||||||
|
|
||||||
|
def _do_update_check(self):
|
||||||
|
available = check_for_updates()
|
||||||
|
GLib.idle_add(self._set_update_indicator, available)
|
||||||
|
|
||||||
|
def _set_update_indicator(self, available):
|
||||||
|
self._update_available = available
|
||||||
|
if available:
|
||||||
|
self._update_btn.set_label("Update Available")
|
||||||
|
self._update_btn.set_css_classes(["destructive-action"])
|
||||||
|
self._update_btn.set_tooltip_text("A new version of Sovran_SystemsOS is available!")
|
||||||
|
self._badge.set_visible(True)
|
||||||
|
else:
|
||||||
|
self._update_btn.set_label("Update System")
|
||||||
|
self._update_btn.set_css_classes(["suggested-action"])
|
||||||
|
self._update_btn.set_tooltip_text("System is up to date")
|
||||||
|
self._badge.set_visible(False)
|
||||||
|
|
||||||
|
# ── Callbacks ────────────────────────────────────────────────
|
||||||
|
|
||||||
def _on_update_clicked(self, _btn):
|
def _on_update_clicked(self, _btn):
|
||||||
dialog = UpdateDialog(self)
|
dialog = UpdateDialog(self)
|
||||||
|
dialog.connect("close-request", lambda _w: self._after_update())
|
||||||
dialog.present()
|
dialog.present()
|
||||||
|
|
||||||
|
def _after_update(self):
|
||||||
|
# Re-check after dialog closes
|
||||||
|
GLib.timeout_add_seconds(3, self._check_for_updates_once)
|
||||||
|
return False
|
||||||
|
|
||||||
def _on_autostart_toggled(self, switch, state):
|
def _on_autostart_toggled(self, switch, state):
|
||||||
set_autostart_enabled(state)
|
set_autostart_enabled(state)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -17,3 +17,8 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
}
|
}
|
||||||
|
.update-badge {
|
||||||
|
color: #e01b24;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user