""" 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