""" Thor Watcher — System Tray Launcher v0.2.0 Requires: pystray, Pillow, tkinter (stdlib) Run with: pythonw thor_tray.py (no console window) or: python thor_tray.py (with console, for debugging) Put a shortcut to this in shell:startup for auto-start on login. """ import os import sys import json import subprocess import tempfile import threading import urllib.request import urllib.error from datetime import datetime import pystray from PIL import Image, ImageDraw import series4_ingest as watcher # ── Auto-updater ────────────────────────────────────────────────────────────── GITEA_BASE = "https://gitea.serversdown.net" GITEA_USER = "serversdown" GITEA_REPO = "thor-watcher" GITEA_API_URL = "{}/api/v1/repos/{}/{}/releases?limit=1&page=1".format( GITEA_BASE, GITEA_USER, GITEA_REPO ) _CURRENT_VERSION = getattr(watcher, "VERSION", "0.0.0") def _version_tuple(v): """Convert '0.2.0' -> (0, 2, 0) for comparison.""" parts = [] for p in str(v).lstrip("v").split(".")[:3]: try: parts.append(int(p)) except ValueError: parts.append(0) while len(parts) < 3: parts.append(0) return tuple(parts) def _update_log(msg): """Append a timestamped [updater] line to the watcher log.""" try: log_path = os.path.join( os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "", "ThorWatcher", "agent_logs", "thor_watcher.log" ) os.makedirs(os.path.dirname(log_path), exist_ok=True) with open(log_path, "a") as f: f.write("[{}] [updater] {}\n".format( datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg )) except Exception: pass def _check_for_update_gitea(): """Query Gitea API for latest release. Returns (tag, download_url) or (None, None).""" try: req = urllib.request.Request( GITEA_API_URL, headers={"User-Agent": "thor-watcher/{}".format(_CURRENT_VERSION)}, ) with urllib.request.urlopen(req, timeout=8) as resp: releases = json.loads(resp.read().decode("utf-8")) if not releases: return None, None latest = releases[0] tag = latest.get("tag_name", "") if _version_tuple(tag) <= _version_tuple(_CURRENT_VERSION): return None, None assets = latest.get("assets", []) for asset in assets: name = asset.get("name", "").lower() if name.endswith(".exe") and "setup" not in name: return tag, asset.get("browser_download_url") _update_log("Newer release {} found but no valid .exe asset".format(tag)) return tag, None except Exception as e: _update_log("check_for_update (gitea) failed: {}".format(e)) return None, None def _check_for_update_url(base_url): """Query a custom URL server for latest version. Returns (tag, download_url) or (None, None).""" if not base_url: _update_log("update_source=url but update_url is empty — skipping") return None, None try: ver_url = base_url.rstrip("/") + "/api/updates/thor-watcher/version.txt" req = urllib.request.Request( ver_url, headers={"User-Agent": "thor-watcher/{}".format(_CURRENT_VERSION)}, ) with urllib.request.urlopen(req, timeout=8) as resp: tag = resp.read().decode("utf-8").strip() if not tag: return None, None if _version_tuple(tag) <= _version_tuple(_CURRENT_VERSION): return None, None exe_url = base_url.rstrip("/") + "/api/updates/thor-watcher/thor-watcher.exe" return tag, exe_url except Exception as e: _update_log("check_for_update (url mode) failed: {}".format(e)) return None, None def check_for_update(): """ Check for an update using the configured source (gitea, url, or disabled). Reads update_source and update_url from config.json at check time. Returns (tag, download_url) if an update is available, else (None, None). """ try: cfg = _read_config() update_source = str(cfg.get("update_source", "gitea")).strip().lower() update_url = str(cfg.get("update_url", "")).strip() except Exception: update_source = "gitea" update_url = "" if update_source == "disabled": return None, None _update_log("Checking for update (source={}, version={})".format( update_source, _CURRENT_VERSION )) if update_source == "url": return _check_for_update_url(update_url) else: return _check_for_update_gitea() def apply_update(download_url): """ Download new .exe, validate it, write swap .bat, launch it, exit. Backs up old exe to .exe.old before replacing. """ exe_path = os.path.abspath(sys.executable if getattr(sys, "frozen", False) else sys.argv[0]) try: tmp_fd, tmp_path = tempfile.mkstemp(suffix=".exe", prefix="tw_update_") os.close(tmp_fd) _update_log("Downloading update from: {}".format(download_url)) req = urllib.request.Request( download_url, headers={"User-Agent": "thor-watcher/{}".format(_CURRENT_VERSION)}, ) with urllib.request.urlopen(req, timeout=60) as resp: with open(tmp_path, "wb") as f: f.write(resp.read()) # Three-layer validation try: dl_size = os.path.getsize(tmp_path) current_size = os.path.getsize(exe_path) _update_log("Download complete ({} bytes), validating...".format(dl_size)) if dl_size < 100 * 1024: _update_log("Validation failed: too small ({} bytes) — aborting".format(dl_size)) os.remove(tmp_path) return False if current_size > 0 and dl_size < current_size * 0.5: _update_log("Validation failed: suspiciously small ({} vs {} bytes) — aborting".format( dl_size, current_size)) os.remove(tmp_path) return False with open(tmp_path, "rb") as _f: magic = _f.read(2) if magic != b"MZ": _update_log("Validation failed: not a valid Windows exe — aborting") os.remove(tmp_path) return False _update_log("Validation passed ({} bytes, MZ ok)".format(dl_size)) except Exception as e: _update_log("Validation error: {} — aborting".format(e)) try: os.remove(tmp_path) except Exception: pass return False bat_fd, bat_path = tempfile.mkstemp(suffix=".bat", prefix="tw_swap_") os.close(bat_fd) bat_content = ( "@echo off\r\n" "ping 127.0.0.1 -n 4 > nul\r\n" "copy /Y \"{exe}\" \"{exe}.old\"\r\n" "set RETRIES=0\r\n" ":retry\r\n" "copy /Y \"{new}\" \"{exe}\"\r\n" "if errorlevel 1 (\r\n" " set /a RETRIES+=1\r\n" " if %RETRIES% GEQ 5 goto fail\r\n" " ping 127.0.0.1 -n 3 > nul\r\n" " goto retry\r\n" ")\r\n" "start \"\" \"{exe}\"\r\n" "del \"{new}\"\r\n" "del \"%~f0\"\r\n" "exit /b 0\r\n" ":fail\r\n" "del \"{new}\"\r\n" "del \"%~f0\"\r\n" "exit /b 1\r\n" ).format(new=tmp_path, exe=exe_path) with open(bat_path, "w") as f: f.write(bat_content) _update_log("Launching swap bat — exiting for update") subprocess.Popen( ["cmd", "/C", bat_path], creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, ) return True except Exception as e: _update_log("apply_update failed: {}".format(e)) return False # ── Config helpers ──────────────────────────────────────────────────────────── def _read_config(): """Read config.json from the appropriate location and return as dict.""" if getattr(sys, "frozen", False): _appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "" config_dir = os.path.join(_appdata, "ThorWatcher") else: config_dir = os.path.dirname(os.path.abspath(__file__)) or "." config_path = os.path.join(config_dir, "config.json") with open(config_path, "r", encoding="utf-8") as f: return json.load(f) # ── Paths ───────────────────────────────────────────────────────────────────── if getattr(sys, "frozen", False): HERE = os.path.dirname(os.path.abspath(sys.executable)) else: HERE = os.path.dirname(os.path.abspath(__file__)) if getattr(sys, "frozen", False): _appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or HERE CONFIG_DIR = os.path.join(_appdata, "ThorWatcher") os.makedirs(CONFIG_DIR, exist_ok=True) else: CONFIG_DIR = HERE CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json") # ── Icon drawing ────────────────────────────────────────────────────────────── COLORS = { "ok": (60, 200, 80), # green "pending": (230, 180, 0), # amber "missing": (210, 40, 40), # red "error": (160, 40, 200), # purple "starting": (120, 120, 120), # grey } ICON_SIZE = 64 def make_icon(status): """Draw a plain colored circle for the system tray.""" color = COLORS.get(status, COLORS["starting"]) img = Image.new("RGBA", (ICON_SIZE, ICON_SIZE), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) margin = 6 draw.ellipse( [margin, margin, ICON_SIZE - margin, ICON_SIZE - margin], fill=color, ) return img # ── First-run check ─────────────────────────────────────────────────────────── def ensure_config(): """ If config.json is missing, launch the first-run wizard. Returns True if config is ready, False if user cancelled. """ if os.path.exists(CONFIG_PATH): return True from thor_settings_dialog import show_dialog saved = show_dialog(CONFIG_PATH, wizard=True) if not saved: _show_cancel_message() return False return True def _show_cancel_message(): try: import tkinter as tk from tkinter import messagebox root = tk.Tk() root.withdraw() messagebox.showwarning( "Thor Watcher", "No configuration was saved.\nThe application will now exit.", ) root.destroy() except Exception: pass # ── Tray app ────────────────────────────────────────────────────────────────── class WatcherTray: def __init__(self): self.state = {} self.stop_event = threading.Event() self._watcher_thread = None self._icon = None self._menu_lock = threading.Lock() # ── Watcher thread management ───────────────────────────────────────────── def _start_watcher(self): self.stop_event.clear() self._watcher_thread = threading.Thread( target=watcher.run_watcher, args=(self.state, self.stop_event), daemon=True, name="watcher", ) self._watcher_thread.start() def _stop_watcher(self): self.stop_event.set() if self._watcher_thread is not None: self._watcher_thread.join(timeout=10) self._watcher_thread = None def _restart_watcher(self): self._stop_watcher() self.stop_event = threading.Event() self.state["status"] = "starting" self.state["units"] = [] self.state["last_scan"] = None self.state["last_error"] = None self._start_watcher() # ── Menu callbacks ──────────────────────────────────────────────────────── def _open_settings(self, icon, item): def _run(): from thor_settings_dialog import show_dialog saved = show_dialog(CONFIG_PATH, wizard=False) if saved: self._restart_watcher() threading.Thread(target=_run, daemon=True, name="settings-dialog").start() def _open_logs(self, icon, item): log_dir = self.state.get("log_dir") if not log_dir: log_dir = HERE if os.path.exists(log_dir): subprocess.Popen(["explorer", log_dir]) else: parent = os.path.dirname(log_dir) if os.path.exists(parent): subprocess.Popen(["explorer", parent]) else: subprocess.Popen(["explorer", HERE]) def _exit(self, icon, item): self.stop_event.set() icon.stop() # ── Dynamic menu text ───────────────────────────────────────────────────── def _status_text(self): status = self.state.get("status", "starting") last_err = self.state.get("last_error") last_scan = self.state.get("last_scan") api_status = self.state.get("api_status", "disabled") unit_count = len(self.state.get("units", [])) if status == "error": return "Error — {}".format(last_err or "unknown") if status == "starting": return "Starting..." if last_scan is not None: age_secs = int((datetime.now() - last_scan).total_seconds()) age_str = "{}s ago".format(age_secs) if age_secs < 60 else "{}m ago".format(age_secs // 60) else: age_str = "never" if api_status == "ok": api_str = "API OK" elif api_status == "fail": api_str = "API FAIL" else: api_str = "API off" return "Running — {} | {} unit(s) | scan {}".format(api_str, unit_count, age_str) def _tray_status(self): status = self.state.get("status", "starting") if status == "error": return "error" if status == "starting": return "starting" api_status = self.state.get("api_status", "disabled") if api_status == "fail": return "missing" # red — API failing if api_status == "disabled": return "pending" # amber — running but not reporting return "ok" # green — running and API good def _build_menu(self): return pystray.Menu( pystray.MenuItem(lambda item: self._status_text(), None, enabled=False), pystray.Menu.SEPARATOR, pystray.MenuItem("Settings...", self._open_settings), pystray.MenuItem("Open Log Folder", self._open_logs), pystray.Menu.SEPARATOR, pystray.MenuItem("Exit", self._exit), ) # ── Icon/menu update loop ───────────────────────────────────────────────── def _icon_updater(self): """Periodically refresh the tray icon and check for updates.""" last_status = None update_check_counter = 0 # check every ~5 min (30 × 10s ticks) while not self.stop_event.is_set(): icon_status = self._tray_status() if self._icon is not None: with self._menu_lock: self._icon.menu = self._build_menu() if icon_status != last_status: self._icon.icon = make_icon(icon_status) self._icon.title = "Thor Watcher — {}".format(self._status_text()) last_status = icon_status # Terra-View push-triggered update if self.state.get("update_available"): self.state["update_available"] = False self._do_update() return # Periodic update check update_check_counter += 1 if update_check_counter >= 30: update_check_counter = 0 tag, url = check_for_update() if tag and url: self._do_update(url) return self.stop_event.wait(timeout=10) def _do_update(self, download_url=None): """Notify tray then apply update. If url is None, fetch it first.""" if download_url is None: _, download_url = check_for_update() if not download_url: return if self._icon is not None: self._icon.title = "Thor Watcher — Updating..." self._icon.icon = make_icon("starting") success = apply_update(download_url) if success: self.stop_event.set() if self._icon is not None: self._icon.stop() # ── Entry point ─────────────────────────────────────────────────────────── def run(self): self._start_watcher() icon_img = make_icon("starting") self._icon = pystray.Icon( name="thor_watcher", icon=icon_img, title="Thor Watcher — Starting...", menu=self._build_menu(), ) updater = threading.Thread( target=self._icon_updater, daemon=True, name="icon-updater" ) updater.start() self._icon.run() # ── Entry point ─────────────────────────────────────────────────────────────── def main(): if not ensure_config(): sys.exit(0) app = WatcherTray() app.run() if __name__ == "__main__": main()