eve-watcher/update.py
2026-06-14 16:08:21 +00:00

288 lines
11 KiB
Python

#!/usr/bin/env python3
"""Self-installing / self-updating entry point — `python update.py`.
Run it once (the install one-liner does this for you). From then on a Windows
Scheduled Task runs it automatically **at logon and once a day**, so the watchers
stay up to date and running with zero further thought. Each run it:
1. makes config.ini on first run (webhook prefilled; gitignored so your edits
and OCR snip survive every update),
2. `git pull`s the latest watcher code,
3. installs/upgrades deps only when something changed,
4. (re)starts the watchers ONLY if the code changed or they aren't running —
so the daily run never interrupts an active session for nothing,
5. ensures the daily+logon Scheduled Task exists (idempotent).
First time on a new PC (or just paste the install one-liner):
git clone https://git.armoredarmadillo.com/brockdarnold/eve-watcher.git
cd eve-watcher
python update.py
python eve_orehold_watcher.py --snip # one-time GUI step for OCR hold alerts
"""
import configparser
import os
import shutil
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
WIN = os.name == "nt"
TASK = "EveWatcher"
WATCHER_SCRIPTS = ("eve_combat_watcher.py", "eve_chat_watcher.py",
"eve_orehold_watcher.py", "eve_rock_watcher.py",
"eve_local_watcher.py")
def run(cmd, **kw):
print(f" $ {' '.join(cmd)}")
return subprocess.run(cmd, cwd=HERE, **kw)
def ps(script):
"""Run a PowerShell snippet, return (rc, stdout+stderr)."""
r = subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-Command", script], cwd=HERE, capture_output=True, text=True)
return r.returncode, ((r.stdout or "") + (r.stderr or "")).strip()
def pythonw():
p = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
return p if os.path.exists(p) else "pythonw.exe"
def ensure_config():
cfg = os.path.join(HERE, "config.ini")
first = not os.path.exists(cfg)
if first:
shutil.copyfile(os.path.join(HERE, "config.ini.example"), cfg)
print("• created config.ini from example (webhook prefilled).")
else:
print("• config.ini exists — your settings + OCR snip left untouched.")
# heal a UTF-8 BOM left by the old PowerShell installer (breaks configparser)
with open(cfg, "rb") as fh:
raw = fh.read()
if raw.startswith(b"\xef\xbb\xbf"):
with open(cfg, "wb") as fh:
fh.write(raw[3:])
print("• stripped a UTF-8 BOM from config.ini (old-installer leftover).")
return cfg, first
ZIP_URL = "https://git.armoredarmadillo.com/brockdarnold/eve-watcher/archive/main.zip"
def _git(*a):
return subprocess.run(["git", "-C", HERE, *a], capture_output=True, text=True)
def _have_git():
import shutil as _sh
return os.path.isdir(os.path.join(HERE, ".git")) and _sh.which("git") is not None
def update_code():
"""Pull the latest watcher code. Uses git when this is a clone; otherwise
downloads the repo zip and overwrites files (config.ini is never touched).
Returns True if anything changed."""
if _have_git():
before = _git("rev-parse", "HEAD").stdout.strip()
if _git("fetch", "-q", "origin").returncode != 0:
print("! git fetch failed (offline?). Keeping current version.")
return False
_git("reset", "--hard", "-q", "origin/main")
after = _git("rev-parse", "HEAD").stdout.strip()
if before and after and before != after:
print(f" updated: {before[:7]} -> {after[:7]}")
return True
print(" already on latest code.")
return False
return _zip_update()
def _zip_update():
"""git-less update: download the repo zip and write any changed files."""
import io
import urllib.request
import zipfile
try:
req = urllib.request.Request(ZIP_URL, headers={"User-Agent": "eve-watcher-updater"})
data = urllib.request.urlopen(req, timeout=60).read()
z = zipfile.ZipFile(io.BytesIO(data))
except Exception as e:
print(f"• update check failed (offline?): {e}")
return False
changed = 0
for n in z.namelist():
if n.endswith("/"):
continue
rel = n.split("/", 1)[1] if "/" in n else n # strip top 'eve-watcher/'
if not rel or rel == "config.ini": # never clobber local config
continue
dest = os.path.join(HERE, rel.replace("/", os.sep))
new = z.read(n)
old = open(dest, "rb").read() if os.path.exists(dest) else None
if old != new:
os.makedirs(os.path.dirname(dest) or HERE, exist_ok=True)
with open(dest, "wb") as f:
f.write(new)
changed += 1
print(f" code updated (zip): {changed} file(s)." if changed
else " already on latest code (zip).")
return changed > 0
def deps():
req = os.path.join(HERE, "requirements.txt")
if os.path.exists(req):
run([sys.executable, "-m", "pip", "install", "--quiet", "-r", req])
def ensure_tesseract(cfg_path):
"""OCR needs the Tesseract binary. Detect it; if missing, try a silent winget
install (best-effort — may raise a one-time UAC prompt). Writes tesseract_cmd
into config.ini when found. Non-fatal: timer/combat/chat work without it."""
if not WIN:
return
import shutil as _sh
std = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
path = _sh.which("tesseract") or (std if os.path.exists(std) else None)
if not path:
print("• Tesseract not found — installing (one-time, may prompt UAC)...")
try:
subprocess.run(["winget", "install", "--id", "UB-Mannheim.TesseractOCR",
"-e", "--silent", "--accept-package-agreements",
"--accept-source-agreements"],
capture_output=True, text=True, timeout=600)
except Exception as ex:
print(f" (winget install skipped: {ex})")
path = _sh.which("tesseract") or (std if os.path.exists(std) else None)
if not path:
print("• Tesseract still missing — live OCR % off until it's installed "
"(combat/chat/phone alerts still work).")
return
# record the path so pytesseract finds it even if not on PATH
cp = configparser.ConfigParser()
cp.read(cfg_path, encoding="utf-8-sig")
if not cp.has_section("ocr"):
cp.add_section("ocr")
if cp.get("ocr", "tesseract_cmd", fallback="").strip() != path:
cp["ocr"]["tesseract_cmd"] = path
with open(cfg_path, "w") as f:
cp.write(f)
print(f"• Tesseract ready: {path}")
def _kill_scripts(names):
"""Force-stop pythonw processes whose command line names any of `names`."""
if not WIN:
return
pats = " -or ".join(f"$_.CommandLine -like '*{n}*'" for n in names)
ps("Get-CimInstance Win32_Process -Filter \"Name='pythonw.exe'\" | "
f"Where-Object {{ {pats} }} | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force }")
def _start_script(name):
if not WIN:
return
ps(f"Start-Process pythonw -ArgumentList '\"{os.path.join(HERE, name)}\"' "
f"-WorkingDirectory '{HERE}' -WindowStyle Hidden")
def restart_mining_watchers():
"""Restart only the data watchers (not the autoupdate poller) — used by the
poller to bring the watchers onto freshly-pulled code without killing itself."""
_kill_scripts(WATCHER_SCRIPTS)
for n in WATCHER_SCRIPTS:
_start_script(n)
def watchers_running():
if not WIN:
return 0
_, out = ps("(Get-CimInstance Win32_Process -Filter \"Name='pythonw.exe'\" "
"| Where-Object { $_.CommandLine -like '*eve_*' }).Count")
try:
return int(out or "0")
except ValueError:
return 0
def stop_watchers():
# all of ours run as pythonw on an eve_*.py script (watchers + autoupdate poller)
_kill_scripts(("eve_",))
def start_watchers(cfg_path):
if not WIN:
print("• non-Windows — start watchers manually (this is a Windows tool).")
return
stop_watchers()
run(["powershell", "-ExecutionPolicy", "Bypass", "-File",
os.path.join(HERE, "start-all.ps1")])
cp = configparser.ConfigParser()
cp.read(cfg_path, encoding="utf-8-sig")
if cp.get("watcher", "mode", fallback="ocr") == "ocr" and \
not cp.get("ocr", "region", fallback="").strip():
print("\n >> The live hold % reader auto-calibrates: just have your in-game\n"
" Ore Hold window OPEN and it finds the readout itself (no snip).")
def ensure_logon_autostart():
"""Drop a launcher in the Startup folder so this runs at every logon.
No admin needed — most reliable auto-start mechanism on Windows."""
if not WIN:
return False
rc, startup = ps("[Environment]::GetFolderPath('Startup')")
startup = startup.strip()
if rc != 0 or not startup or not os.path.isdir(startup):
print("! couldn't find Startup folder for logon auto-start")
return False
launcher = os.path.join(startup, "EveWatcher.cmd")
body = ('@echo off\r\n'
f'start "" "{pythonw()}" "{os.path.join(HERE, "update.py")}"\r\n')
with open(launcher, "w", newline="") as f:
f.write(body)
print(f"• logon auto-start installed: {launcher}")
return True
def ensure_daily_task():
"""Best-effort daily Scheduled Task (covers PCs left on for days). Uses
schtasks (works for the current user without admin). Idempotent."""
if not WIN:
return
q = subprocess.run(["schtasks", "/query", "/tn", TASK],
capture_output=True, text=True)
if q.returncode == 0:
print(f"• daily task '{TASK}' already set.")
return
tr = f'"{pythonw()}" "{os.path.join(HERE, "update.py")}"'
c = subprocess.run(["schtasks", "/create", "/tn", TASK, "/tr", tr,
"/sc", "DAILY", "/st", "05:00", "/f"],
capture_output=True, text=True)
if c.returncode == 0:
print(f"• daily auto-update task '{TASK}' created (05:00).")
else:
print(f"• daily task not created ({(c.stdout + c.stderr).strip() or 'unknown'});"
" logon auto-start still covers updates.")
def main():
print("=== Eve watcher setup / auto-update ===")
cfg, first = ensure_config()
changed = update_code()
if changed or first:
deps()
ensure_tesseract(cfg)
if changed or first or watchers_running() == 0:
start_watchers(cfg)
else:
print("• up to date and watchers already running — nothing to restart.")
ensure_logon_autostart()
ensure_daily_task()
print("\nDone. Hands-off from here: it updates + keeps itself running automatically.")
if __name__ == "__main__":
main()