Stock-Tool/updater.py

256 lines
8.9 KiB
Python

"""
Auto-Updater
============
On every launch (after a successful license check), checks Supabase for a
newer version. If found:
- Downloads new screener_gui.py and stock_screener.py silently
- If a new exe is also available, self-replaces via a helper batch script
and restarts. The user sees nothing — the app just comes back up updated.
Called after a successful license check on every launch.
"""
import os
import shutil
import subprocess
import sys
import tempfile
import requests
# ---------------------------------------------------------------------------
# Current app version — kept in sync by push_update.py before each build.
# ---------------------------------------------------------------------------
APP_VERSION = "1.3.1"
# ---------------------------------------------------------------------------
# GitHub token — fine-grained PAT with Contents:read on the release repo.
# Required to download assets from a private GitHub repository.
# Generate at: GitHub → Settings → Developer settings → Fine-grained tokens
# ---------------------------------------------------------------------------
_GITHUB_TOKEN = "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"
_GITHUB_REPO = "nolankovacs/ultimate-investment-tool"
# ---------------------------------------------------------------------------
# Supabase config
# ---------------------------------------------------------------------------
_SUPABASE_URL = "https://yeispcpmepjelfbhfkwr.supabase.co"
_SUPABASE_ANON = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InllaXNwY3BtZXBqZWxmYmhma3dyIiwi"
"cm9sZSI6ImFub24iLCJpYXQiOjE3NzM1MDkxOTAsImV4cCI6MjA4OTA4NTE5MH0."
"8kq6HzpwF81_Sx36MXbx0wUqaT9Ul9POw1LzF5vxiOo"
)
_HEADERS = {
"apikey": _SUPABASE_ANON,
"Content-Type": "application/json",
}
def _exe_dir() -> str:
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
def _parse_version(v: str) -> tuple:
try:
return tuple(int(x) for x in v.strip().split("."))
except Exception:
return (0, 0, 0)
def _download_file(url: str, dest_path: str) -> None:
"""Download url → dest_path atomically via a temp file."""
dl_headers = {}
if _GITHUB_TOKEN and "github.com" in url:
dl_headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}"
# GitHub API asset endpoint requires this header to stream the binary
if "api.github.com" in url and "/releases/assets/" in url:
dl_headers["Accept"] = "application/octet-stream"
tmp_dir = tempfile.mkdtemp()
tmp_path = os.path.join(tmp_dir, os.path.basename(dest_path))
try:
with requests.get(url, stream=True, timeout=60, headers=dl_headers) as r:
r.raise_for_status()
with open(tmp_path, "wb") as f:
for chunk in r.iter_content(chunk_size=65536):
f.write(chunk)
shutil.move(tmp_path, dest_path)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _self_replace_exe(exe_url: str) -> None:
"""
Download the new exe to %TEMP%, then launch a helper batch script that
waits for this process to exit, moves the file into place, and restarts
the app. Nothing is left in the application directory. Never returns.
"""
if not getattr(sys, "frozen", False):
return # only meaningful in a frozen exe
current_exe = os.path.abspath(sys.executable)
tmp_dir = tempfile.gettempdir()
pending_exe = os.path.join(tmp_dir, "StockScreener_pending.exe")
# Download to %TEMP% — nothing lands next to the running exe
try:
_download_file(exe_url, pending_exe)
except Exception as exc:
_log_update_error(f"_download_file failed for {exe_url}: {exc}")
return
# Batch script in %TEMP% — deletes itself and the pending file when done
helper = os.path.join(tmp_dir, "uit_update.bat")
with open(helper, "w") as f:
f.write(
f"@echo off\n"
f":retry\n"
f"timeout /t 1 /nobreak >nul\n"
f"move /Y \"{pending_exe}\" \"{current_exe}\"\n"
f"if errorlevel 1 goto retry\n"
f"start \"\" \"{current_exe}\"\n"
f"del \"%~f0\"\n"
)
# Launch the helper detached, then exit so the running exe is unlocked
subprocess.Popen(
["cmd", "/c", helper],
creationflags=subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS,
close_fds=True,
)
sys.exit(0)
def get_release_notes() -> tuple:
"""
Returns (version, release_notes) for the current channel from Supabase.
Falls back gracefully on any error.
"""
try:
import license_check
channel = license_check.get_channel()
resp = requests.post(
f"{_SUPABASE_URL}/rest/v1/rpc/get_latest_version",
headers=_HEADERS,
json={"p_channel": channel},
timeout=8,
)
resp.raise_for_status()
data = resp.json()
version = data.get("version", APP_VERSION)
notes = data.get("release_notes") or "No release notes available."
return version, notes
except Exception:
return APP_VERSION, "Unable to fetch release notes."
def _log_update_error(msg: str) -> None:
"""Upload update events/errors to Supabase update_logs table."""
# Remote upload only — no local file created
try:
hwid = "unknown"
channel = "unknown"
try:
from hwid import get_hwid
hwid = get_hwid()
except Exception:
pass
try:
import license_check
channel = license_check.get_channel()
except Exception:
pass
requests.post(
f"{_SUPABASE_URL}/rest/v1/update_logs",
headers={
**_HEADERS,
"Content-Type": "application/json",
"Prefer": "return=minimal",
},
json={
"hwid": hwid,
"app_version": APP_VERSION,
"channel": channel,
"message": msg,
},
timeout=5,
)
except Exception:
pass # never let logging break the app
def _get_github_exe_url(version: str, channel: str) -> str | None:
"""
Query the GitHub Releases API to find the StockScreener.exe asset URL
for the given version and channel. Used as a fallback when Supabase
doesn't return exe_url.
"""
try:
tag = f"v{version}-{channel}" if channel != "stable" else f"v{version}"
resp = requests.get(
f"https://api.github.com/repos/{_GITHUB_REPO}/releases/tags/{tag}",
headers={
"Authorization": f"Bearer {_GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
},
timeout=10,
)
resp.raise_for_status()
for asset in resp.json().get("assets", []):
if asset["name"] == "StockScreener.exe":
return asset["url"] # API asset URL — requires octet-stream header
_log_update_error(f"GitHub release {tag} found but no StockScreener.exe asset")
return None
except Exception as exc:
_log_update_error(f"_get_github_exe_url({version}, {channel}) failed: {exc}")
return None
def check_and_apply_update() -> None:
"""
Silently check for updates and apply them. Errors are swallowed so a
failed update check never blocks the app from launching.
"""
try:
import license_check
channel = license_check.get_channel()
# Use the RPC — it runs with elevated DB permissions (SECURITY DEFINER)
# so the anon key can read the table through it even with RLS enabled.
resp = requests.post(
f"{_SUPABASE_URL}/rest/v1/rpc/get_latest_version",
headers=_HEADERS,
json={"p_channel": channel},
timeout=8,
)
resp.raise_for_status()
data = resp.json()
latest = data.get("version", APP_VERSION)
if _parse_version(latest) <= _parse_version(APP_VERSION):
return # already up to date
# Always resolve the download URL via the GitHub API — direct
# github.com/releases/download URLs return 404 for private repos
# when accessed with a token outside of a browser session.
exe_url = _get_github_exe_url(latest, channel)
if not exe_url:
_log_update_error(
f"Update available ({APP_VERSION}{latest}) but could not "
f"locate the asset on GitHub for channel={channel}"
)
return
# Self-replace exe if a new one is available (exits this process)
_self_replace_exe(exe_url)
except Exception as exc:
_log_update_error(f"check_and_apply_update failed: {exc}")
# never crash the app over a failed update check