Migrate app from Supabase/GitHub to Verimund API
This commit is contained in:
parent
c36d4c2f03
commit
92c1750e1b
|
|
@ -116,6 +116,13 @@ def get_fundamentals_cache(payload=Depends(verify_jwt), db=Depends(get_db)):
|
||||||
return {"data": cur.fetchall()}
|
return {"data": cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/cache/sector")
|
||||||
|
def get_sector_stats(payload=Depends(verify_jwt), db=Depends(get_db)):
|
||||||
|
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute("SELECT * FROM sector_stats ORDER BY sector")
|
||||||
|
return {"data": cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/update")
|
@app.get("/update")
|
||||||
def check_update(payload=Depends(verify_jwt), db=Depends(get_db)):
|
def check_update(payload=Depends(verify_jwt), db=Depends(get_db)):
|
||||||
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
119
license_check.py
119
license_check.py
|
|
@ -1,16 +1,17 @@
|
||||||
"""
|
"""
|
||||||
License Check
|
License Check
|
||||||
=============
|
=============
|
||||||
Validates the stored license key against Supabase on every launch.
|
Validates the stored license key against the Verimund API on every launch.
|
||||||
Falls back to a local cache if the server is unreachable (30-minute grace period).
|
Falls back to a local cache if the server is unreachable (30-minute grace period).
|
||||||
|
|
||||||
Storage location: %APPDATA%\\StockScreener\\
|
Storage location: %APPDATA%\\StockScreener\\
|
||||||
license.dat — XOR-encrypted license key (machine-bound)
|
license.dat — XOR-encrypted license key (machine-bound)
|
||||||
auth_cache.dat — last successful auth result + timestamp
|
auth_cache.dat — last successful auth result + timestamp + JWT token
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac as _hmac_module
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
@ -18,15 +19,13 @@ import time
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Supabase config
|
# API config
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_SUPABASE_URL = "https://yeispcpmepjelfbhfkwr.supabase.co"
|
_API_URL = "https://api.verimundsolutions.com"
|
||||||
_SUPABASE_ANON = (
|
|
||||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
|
# HMAC secret — must match HMAC_SECRET in /srv/api/.env on the server.
|
||||||
"eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InllaXNwY3BtZXBqZWxmYmhma3dyIiwi"
|
# This value is obfuscated by PyArmor before distribution.
|
||||||
"cm9sZSI6ImFub24iLCJpYXQiOjE3NzM1MDkxOTAsImV4cCI6MjA4OTA4NTE5MH0."
|
_HMAC_SECRET = "fa1fac57c9cc9742ac66542a17b032ccdfb2211b72c81fccd1617f17ce03ac7f"
|
||||||
"8kq6HzpwF81_Sx36MXbx0wUqaT9Ul9POw1LzF5vxiOo"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 30 minutes
|
# 30 minutes
|
||||||
_GRACE_PERIOD_SECONDS = 30 * 60
|
_GRACE_PERIOD_SECONDS = 30 * 60
|
||||||
|
|
@ -101,21 +100,19 @@ def delete_license_key() -> None:
|
||||||
# Auth cache
|
# Auth cache
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _save_cache(valid: bool, reason: str, channel: str = "stable") -> None:
|
def _save_cache(valid: bool, reason: str, channel: str = "stable", jwt_token: str = "") -> None:
|
||||||
_ensure_dir()
|
_ensure_dir()
|
||||||
payload = {"valid": valid, "reason": reason, "channel": channel, "timestamp": time.time()}
|
payload = {
|
||||||
|
"valid": valid,
|
||||||
|
"reason": reason,
|
||||||
|
"channel": channel,
|
||||||
|
"jwt_token": jwt_token,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
with open(_CACHE_FILE, "w", encoding="utf-8") as f:
|
with open(_CACHE_FILE, "w", encoding="utf-8") as f:
|
||||||
json.dump(payload, f)
|
json.dump(payload, f)
|
||||||
|
|
||||||
|
|
||||||
def get_channel() -> str:
|
|
||||||
"""Return the update channel ('stable' or 'beta') from the last auth cache."""
|
|
||||||
cache = _load_cache()
|
|
||||||
if cache:
|
|
||||||
return cache.get("channel", "stable")
|
|
||||||
return "stable"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_cache() -> dict | None:
|
def _load_cache() -> dict | None:
|
||||||
if not os.path.exists(_CACHE_FILE):
|
if not os.path.exists(_CACHE_FILE):
|
||||||
return None
|
return None
|
||||||
|
|
@ -126,6 +123,43 @@ def _load_cache() -> dict | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel() -> str:
|
||||||
|
"""Return the update channel ('stable' or 'beta') from the last auth cache."""
|
||||||
|
cache = _load_cache()
|
||||||
|
if cache:
|
||||||
|
return cache.get("channel", "stable")
|
||||||
|
return "stable"
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt() -> str:
|
||||||
|
"""Return the stored JWT token, or empty string if not available."""
|
||||||
|
cache = _load_cache()
|
||||||
|
if cache:
|
||||||
|
return cache.get("jwt_token", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_headers() -> dict:
|
||||||
|
"""Return headers for authenticated API requests."""
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {get_jwt()}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HMAC request signing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _sign_request(license_key: str, timestamp: str) -> str:
|
||||||
|
"""Generate HMAC-SHA256 signature for an auth request."""
|
||||||
|
return _hmac_module.new(
|
||||||
|
_HMAC_SECRET.encode(),
|
||||||
|
f"{license_key}{timestamp}".encode(),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Core verification
|
# Core verification
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -134,31 +168,41 @@ def verify_license(license_key: str, hwid: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Returns (valid: bool, reason: str).
|
Returns (valid: bool, reason: str).
|
||||||
|
|
||||||
Tries the Supabase RPC first. If the server is unreachable, falls back
|
Calls POST /auth on the Verimund API with a signed request.
|
||||||
to the cached result if it is within the 30-minute grace period.
|
Falls back to cached result within the 30-minute grace period if offline.
|
||||||
"""
|
"""
|
||||||
headers = {
|
|
||||||
"apikey": _SUPABASE_ANON,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
signature = _sign_request(license_key, timestamp)
|
||||||
|
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{_SUPABASE_URL}/rest/v1/rpc/verify_license",
|
f"{_API_URL}/auth",
|
||||||
headers=headers,
|
json={
|
||||||
json={"p_key": license_key, "p_hwid": hwid},
|
"license_key": license_key,
|
||||||
|
"hwid": hwid,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"signature": signature,
|
||||||
|
},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
valid = bool(data.get("valid", False))
|
token = data.get("token", "")
|
||||||
reason = data.get("reason", "Unknown error")
|
|
||||||
channel = data.get("channel", "stable")
|
channel = data.get("channel", "stable")
|
||||||
_save_cache(valid, reason, channel)
|
_save_cache(True, "ok", channel, token)
|
||||||
return valid, reason
|
return True, "ok"
|
||||||
|
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
try:
|
||||||
|
detail = e.response.json().get("detail", str(e))
|
||||||
|
except Exception:
|
||||||
|
detail = str(e)
|
||||||
|
_save_cache(False, detail, "stable", "")
|
||||||
|
return False, detail
|
||||||
|
|
||||||
except requests.exceptions.SSLError as e:
|
except requests.exceptions.SSLError as e:
|
||||||
return False, f"SSL error contacting license server: {e}"
|
return False, f"SSL error contacting license server: {e}"
|
||||||
|
|
||||||
except requests.exceptions.ConnectionError as e:
|
except requests.exceptions.ConnectionError as e:
|
||||||
cache = _load_cache()
|
cache = _load_cache()
|
||||||
if cache and cache.get("valid"):
|
if cache and cache.get("valid"):
|
||||||
|
|
@ -166,8 +210,7 @@ def verify_license(license_key: str, hwid: str) -> tuple[bool, str]:
|
||||||
if age < _GRACE_PERIOD_SECONDS:
|
if age < _GRACE_PERIOD_SECONDS:
|
||||||
return True, "offline_grace"
|
return True, "offline_grace"
|
||||||
return False, f"Cannot connect to license server. Check your internet connection.\n\n({e})"
|
return False, f"Cannot connect to license server. Check your internet connection.\n\n({e})"
|
||||||
except requests.exceptions.HTTPError as e:
|
|
||||||
return False, f"License server error: {e}"
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
cache = _load_cache()
|
cache = _load_cache()
|
||||||
if cache and cache.get("valid"):
|
if cache and cache.get("valid"):
|
||||||
|
|
|
||||||
219
updater.py
219
updater.py
|
|
@ -1,11 +1,9 @@
|
||||||
"""
|
"""
|
||||||
Auto-Updater
|
Auto-Updater
|
||||||
============
|
============
|
||||||
On every launch (after a successful license check), checks Supabase for a
|
On every launch (after a successful license check), checks the Verimund API
|
||||||
newer version. If found:
|
for a newer version. If found, downloads the new exe via the API and
|
||||||
- Downloads new screener_gui.py and stock_screener.py silently
|
self-replaces via a helper batch script.
|
||||||
- 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.
|
Called after a successful license check on every launch.
|
||||||
"""
|
"""
|
||||||
|
|
@ -21,31 +19,7 @@ import requests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Current app version — kept in sync by push_update.py before each build.
|
# Current app version — kept in sync by push_update.py before each build.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
APP_VERSION = "1.3.1"
|
APP_VERSION = "1.3.6"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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:
|
def _exe_dir() -> str:
|
||||||
|
|
@ -61,49 +35,59 @@ def _parse_version(v: str) -> tuple:
|
||||||
return (0, 0, 0)
|
return (0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
def _download_file(url: str, dest_path: str) -> None:
|
def _log_update_error(msg: str) -> None:
|
||||||
"""Download url → dest_path atomically via a temp file."""
|
"""Log update errors silently — never let this crash the app."""
|
||||||
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:
|
try:
|
||||||
with requests.get(url, stream=True, timeout=60, headers=dl_headers) as r:
|
from license_check import get_api_headers, _API_URL
|
||||||
r.raise_for_status()
|
from hwid import get_hwid
|
||||||
with open(tmp_path, "wb") as f:
|
hwid = get_hwid()
|
||||||
for chunk in r.iter_content(chunk_size=65536):
|
import license_check
|
||||||
f.write(chunk)
|
channel = license_check.get_channel()
|
||||||
shutil.move(tmp_path, dest_path)
|
requests.post(
|
||||||
finally:
|
f"{_API_URL}/log",
|
||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
headers=get_api_headers(),
|
||||||
|
json={
|
||||||
|
"hwid": hwid,
|
||||||
|
"app_version": APP_VERSION,
|
||||||
|
"channel": channel,
|
||||||
|
"message": msg,
|
||||||
|
},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _self_replace_exe(exe_url: str) -> None:
|
def _self_replace_exe(jwt_token: str, channel: str) -> None:
|
||||||
"""
|
"""
|
||||||
Download the new exe to %TEMP%, then launch a helper batch script that
|
Download the new exe from the API, then launch a helper batch script that
|
||||||
waits for this process to exit, moves the file into place, and restarts
|
waits for this process to exit, moves the file into place, and restarts.
|
||||||
the app. Nothing is left in the application directory. Never returns.
|
Never returns.
|
||||||
"""
|
"""
|
||||||
if not getattr(sys, "frozen", False):
|
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
|
return
|
||||||
|
|
||||||
# Batch script in %TEMP% — deletes itself and the pending file when done
|
from license_check import _API_URL
|
||||||
|
|
||||||
|
current_exe = os.path.abspath(sys.executable)
|
||||||
|
tmp_dir = tempfile.gettempdir()
|
||||||
|
pending_exe = os.path.join(tmp_dir, "StockScreener_pending.exe")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with requests.post(
|
||||||
|
f"{_API_URL}/download",
|
||||||
|
headers={"Authorization": f"Bearer {jwt_token}", "Content-Type": "application/json"},
|
||||||
|
stream=True,
|
||||||
|
timeout=120,
|
||||||
|
) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
with open(pending_exe, "wb") as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=65536):
|
||||||
|
f.write(chunk)
|
||||||
|
except Exception as exc:
|
||||||
|
_log_update_error(f"_self_replace_exe download failed: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
helper = os.path.join(tmp_dir, "uit_update.bat")
|
helper = os.path.join(tmp_dir, "uit_update.bat")
|
||||||
with open(helper, "w") as f:
|
with open(helper, "w") as f:
|
||||||
f.write(
|
f.write(
|
||||||
|
|
@ -116,7 +100,6 @@ def _self_replace_exe(exe_url: str) -> None:
|
||||||
f"del \"%~f0\"\n"
|
f"del \"%~f0\"\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Launch the helper detached, then exit so the running exe is unlocked
|
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
["cmd", "/c", helper],
|
["cmd", "/c", helper],
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS,
|
creationflags=subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS,
|
||||||
|
|
@ -127,16 +110,14 @@ def _self_replace_exe(exe_url: str) -> None:
|
||||||
|
|
||||||
def get_release_notes() -> tuple:
|
def get_release_notes() -> tuple:
|
||||||
"""
|
"""
|
||||||
Returns (version, release_notes) for the current channel from Supabase.
|
Returns (version, release_notes) for the current channel from the API.
|
||||||
Falls back gracefully on any error.
|
Falls back gracefully on any error.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import license_check
|
from license_check import get_api_headers, get_jwt, _API_URL
|
||||||
channel = license_check.get_channel()
|
resp = requests.get(
|
||||||
resp = requests.post(
|
f"{_API_URL}/update",
|
||||||
f"{_SUPABASE_URL}/rest/v1/rpc/get_latest_version",
|
headers=get_api_headers(),
|
||||||
headers=_HEADERS,
|
|
||||||
json={"p_channel": channel},
|
|
||||||
timeout=8,
|
timeout=8,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
@ -148,108 +129,34 @@ def get_release_notes() -> tuple:
|
||||||
return APP_VERSION, "Unable to fetch release notes."
|
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:
|
def check_and_apply_update() -> None:
|
||||||
"""
|
"""
|
||||||
Silently check for updates and apply them. Errors are swallowed so a
|
Silently check for updates and apply them. Errors are swallowed so a
|
||||||
failed update check never blocks the app from launching.
|
failed update check never blocks the app from launching.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import license_check
|
from license_check import get_api_headers, get_jwt, get_channel, _API_URL
|
||||||
channel = license_check.get_channel()
|
|
||||||
|
|
||||||
# Use the RPC — it runs with elevated DB permissions (SECURITY DEFINER)
|
resp = requests.get(
|
||||||
# so the anon key can read the table through it even with RLS enabled.
|
f"{_API_URL}/update",
|
||||||
resp = requests.post(
|
headers=get_api_headers(),
|
||||||
f"{_SUPABASE_URL}/rest/v1/rpc/get_latest_version",
|
|
||||||
headers=_HEADERS,
|
|
||||||
json={"p_channel": channel},
|
|
||||||
timeout=8,
|
timeout=8,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
latest = data.get("version", APP_VERSION)
|
latest = data.get("version", APP_VERSION)
|
||||||
|
channel = data.get("channel", "stable")
|
||||||
|
|
||||||
if _parse_version(latest) <= _parse_version(APP_VERSION):
|
if _parse_version(latest) <= _parse_version(APP_VERSION):
|
||||||
return # already up to date
|
return # already up to date
|
||||||
|
|
||||||
# Always resolve the download URL via the GitHub API — direct
|
jwt_token = get_jwt()
|
||||||
# github.com/releases/download URLs return 404 for private repos
|
if not jwt_token:
|
||||||
# when accessed with a token outside of a browser session.
|
_log_update_error("No JWT token available for update download")
|
||||||
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
|
return
|
||||||
|
|
||||||
# Self-replace exe if a new one is available (exits this process)
|
_self_replace_exe(jwt_token, channel)
|
||||||
_self_replace_exe(exe_url)
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log_update_error(f"check_and_apply_update failed: {exc}")
|
_log_update_error(f"check_and_apply_update failed: {exc}")
|
||||||
# never crash the app over a failed update check
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue