256 lines
8.9 KiB
Python
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
|