Feat: Update settings tab implemented.
Auto-updates now configurable (URL, source (gitea or private server), log activity for auto updates. fix: Update now hardened to prevent installation of corrupt or incorrect .exe files. (security to be hardened in the future)
This commit is contained in:
156
series3_tray.py
156
series3_tray.py
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Series 3 Watcher — System Tray Launcher v1.4.2
|
||||
Series 3 Watcher — System Tray Launcher v1.4.3
|
||||
Requires: pystray, Pillow, tkinter (stdlib)
|
||||
|
||||
Run with: pythonw series3_tray.py (no console window)
|
||||
@@ -16,6 +16,7 @@ import sys
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import configparser
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
@@ -52,11 +53,24 @@ def _version_tuple(v):
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def check_for_update():
|
||||
"""
|
||||
Query Gitea for the latest release.
|
||||
Returns (tag, download_url) if an update is available, else (None, None).
|
||||
"""
|
||||
def _update_log(msg):
|
||||
"""Append a timestamped line to the watcher log for update events."""
|
||||
try:
|
||||
log_path = os.path.join(
|
||||
os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "",
|
||||
"Series3Watcher", "agent_logs", "series3_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)."""
|
||||
import json as _json
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
@@ -71,21 +85,78 @@ def check_for_update():
|
||||
tag = latest.get("tag_name", "")
|
||||
if _version_tuple(tag) <= _version_tuple(_CURRENT_VERSION):
|
||||
return None, None
|
||||
# Find the .exe asset
|
||||
assets = latest.get("assets", [])
|
||||
for asset in assets:
|
||||
name = asset.get("name", "")
|
||||
if name.lower().endswith(".exe"):
|
||||
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:
|
||||
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/series3-watcher/version.txt"
|
||||
req = urllib.request.Request(
|
||||
ver_url,
|
||||
headers={"User-Agent": "series3-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/series3-watcher/series3-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.ini at check time.
|
||||
Returns (tag, download_url) if an update is available, else (None, None).
|
||||
Returns (None, None) immediately if UPDATE_SOURCE = disabled.
|
||||
"""
|
||||
try:
|
||||
cp = configparser.ConfigParser(inline_comment_prefixes=(";", "#"))
|
||||
cp.optionxform = str
|
||||
cp.read(CONFIG_PATH, encoding="utf-8")
|
||||
section = cp["agent"] if cp.has_section("agent") else {}
|
||||
update_source = section.get("UPDATE_SOURCE", "gitea").strip().lower()
|
||||
update_url = section.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 to a temp file, write a swap .bat, launch it, exit.
|
||||
The bat waits for us to exit, then swaps the files and relaunches.
|
||||
Download new .exe to a temp file, validate it, write a swap .bat, launch it, exit.
|
||||
The bat backs up the old exe, retries the copy up to 5 times if locked, then relaunches.
|
||||
The .exe.old backup is left in place as a rollback copy.
|
||||
"""
|
||||
exe_path = os.path.abspath(sys.executable if getattr(sys, "frozen", False) else sys.argv[0])
|
||||
|
||||
@@ -93,6 +164,8 @@ def apply_update(download_url):
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".exe", prefix="s3w_update_")
|
||||
os.close(tmp_fd)
|
||||
|
||||
_update_log("Downloading update from: {}".format(download_url))
|
||||
|
||||
req = urllib.request.Request(
|
||||
download_url,
|
||||
headers={"User-Agent": "series3-watcher/{}".format(_CURRENT_VERSION)},
|
||||
@@ -101,26 +174,79 @@ def apply_update(download_url):
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(resp.read())
|
||||
|
||||
# Three-layer validation before touching the live exe
|
||||
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 ({} bytes vs current {} 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 (bad magic bytes) — 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="s3w_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:
|
||||
except Exception as e:
|
||||
_update_log("apply_update failed: {}".format(e))
|
||||
return False
|
||||
|
||||
|
||||
@@ -352,7 +478,7 @@ class WatcherTray:
|
||||
self._do_update()
|
||||
return # exit loop; swap bat will relaunch
|
||||
|
||||
# Periodic Gitea update check (every ~5 min)
|
||||
# Periodic update check (every ~5 min)
|
||||
update_check_counter += 1
|
||||
if update_check_counter >= 30:
|
||||
update_check_counter = 0
|
||||
@@ -379,7 +505,7 @@ class WatcherTray:
|
||||
self.stop_event.set()
|
||||
if self._icon is not None:
|
||||
self._icon.stop()
|
||||
# If update failed, just keep running silently
|
||||
# If update failed, keep running silently — error is in the log
|
||||
|
||||
# --- Entry point ---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user