Initial commit with licensing and auto-update system

This commit is contained in:
Nolan Kovacs 2026-03-14 15:52:01 -04:00
commit aa0af6fd4f
11 changed files with 1048 additions and 0 deletions

View File

@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(python --version && pip --version)",
"Bash(python3 --version 2>/dev/null || py --version 2>/dev/null || echo \"no python\")",
"Bash(node --version 2>/dev/null && npm --version 2>/dev/null || echo \"no node\")",
"Bash(where python:*)",
"Bash(where python3:*)",
"Bash(where py:*)",
"Bash(ls /c/Python*)",
"Read(//c/Users/Nolan/AppData/Local/Programs/**)",
"Bash(ls \"/c/Users/Nolan/AppData/Local/Programs/Python\" 2>/dev/null || echo \"not found\"\nls \"/c/Users/Nolan/AppData/Local/Microsoft/WindowsApps/\"python* 2>/dev/null || echo \"not found\")",
"Bash(cd \"C:/Users/Nolan/Desktop/Stock Tool\" && python -c \"import tkinter; import numpy; import pandas; import yfinance; import requests; print\\('All imports OK'\\)\" 2>&1)"
]
}
}

31
.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
# PyInstaller build artifacts
build/
*.spec.bak
# Do NOT ignore dist/ — screener_gui.py and stock_screener.py live there
# but DO ignore the compiled exe (too large for git, distribute via releases)
dist/StockScreener.exe
# Temporary copies created during build (build.bat cleans these, but just in case)
screener_gui.py
stock_screener.py
# Admin/secrets — NEVER commit these
issue_license.py
# OS
.DS_Store
Thumbs.db
desktop.ini
# IDE
.vscode/
.idea/
*.sublime-project
*.sublime-workspace

65
StockScreener.spec Normal file
View File

@ -0,0 +1,65 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
datas = []
binaries = []
hiddenimports = [
'pandas', 'numpy', 'yfinance', 'requests',
'lxml', 'lxml.etree', 'html5lib', 'bs4',
'appdirs', 'platformdirs',
'tkinter', 'tkinter.ttk', 'tkinter.messagebox',
'screener_gui', 'stock_screener',
'matplotlib', 'matplotlib.backends.backend_tkagg',
'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker',
'winreg',
'hwid', 'license_check', 'activation_dialog', 'updater',
]
tmp_ret = collect_all('yfinance')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('matplotlib')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('pandas')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['launcher.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
# Bytecode encryption key (requires tinyaes + PyInstaller 5.x)
# If your PyInstaller version does not support this, remove the key= line.
pyz = PYZ(a.pure, cipher=None) # swap cipher=None for cipher=block_cipher if using --key
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='StockScreener',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

168
activation_dialog.py Normal file
View File

@ -0,0 +1,168 @@
"""
Activation Dialog
=================
Shown on first launch when no license key is stored.
Collects the license key, verifies it against Supabase, and on success
saves it to disk so it won't be asked again.
"""
import tkinter as tk
from tkinter import ttk
import license_check
from hwid import get_hwid
# ---- Colors matching the screener's dark theme ----
_BG = "#1e1e2e"
_PANEL = "#2a2a3e"
_ACCENT = "#4f8ef7"
_TEXT = "#e0e0f0"
_SUBTEXT = "#888899"
_ERROR = "#ff6b6b"
_SUCCESS = "#50fa7b"
_BORDER = "#3a3a5e"
_ENTRY_BG = "#12121f"
def run_activation() -> bool:
"""
Open the activation dialog and block until the user either activates
successfully or closes the window.
Returns True if activated, False if the user cancelled / closed.
"""
activated = [False]
root = tk.Tk()
root.title("Ultimate Investment Tool — Activation")
root.configure(bg=_BG)
root.resizable(False, False)
# Center the window
root.update_idletasks()
w, h = 480, 340
x = (root.winfo_screenwidth() - w) // 2
y = (root.winfo_screenheight() - h) // 2
root.geometry(f"{w}x{h}+{x}+{y}")
# Intercept close button — same as clicking Exit
root.protocol("WM_DELETE_WINDOW", lambda: _on_exit(root, activated))
# ---- Header ----
header = tk.Frame(root, bg=_ACCENT, height=4)
header.pack(fill="x")
tk.Label(
root, text="Ultimate Investment Tool",
font=("Segoe UI", 16, "bold"),
bg=_BG, fg=_TEXT
).pack(pady=(28, 4))
tk.Label(
root, text="Enter your license key to activate this software.",
font=("Segoe UI", 9),
bg=_BG, fg=_SUBTEXT
).pack()
# ---- Key entry ----
entry_frame = tk.Frame(root, bg=_PANEL, bd=0, highlightthickness=1,
highlightbackground=_BORDER)
entry_frame.pack(padx=40, pady=24, fill="x")
key_var = tk.StringVar()
key_entry = tk.Entry(
entry_frame, textvariable=key_var,
font=("Consolas", 11), bg=_ENTRY_BG, fg=_TEXT,
insertbackground=_TEXT, relief="flat",
bd=10, width=36
)
key_entry.pack(fill="x")
key_entry.focus()
# ---- Status label ----
status_var = tk.StringVar(value="")
status_label = tk.Label(
root, textvariable=status_var,
font=("Segoe UI", 9),
bg=_BG, fg=_ERROR, wraplength=400
)
status_label.pack(pady=(0, 8))
# ---- Buttons ----
btn_frame = tk.Frame(root, bg=_BG)
btn_frame.pack(pady=4)
activate_btn = tk.Button(
btn_frame, text="Activate",
font=("Segoe UI", 10, "bold"),
bg=_ACCENT, fg="white",
activebackground="#3a7ae0", activeforeground="white",
relief="flat", cursor="hand2", padx=24, pady=8,
command=lambda: _on_activate(root, key_var, status_var, status_label,
activate_btn, activated)
)
activate_btn.grid(row=0, column=0, padx=8)
exit_btn = tk.Button(
btn_frame, text="Exit",
font=("Segoe UI", 10),
bg=_PANEL, fg=_SUBTEXT,
activebackground=_BORDER, activeforeground=_TEXT,
relief="flat", cursor="hand2", padx=24, pady=8,
command=lambda: _on_exit(root, activated)
)
exit_btn.grid(row=0, column=1, padx=8)
# Allow Enter key to trigger activation
root.bind("<Return>", lambda e: _on_activate(
root, key_var, status_var, status_label, activate_btn, activated
))
# ---- Footer ----
tk.Label(
root, text="Need a license? Contact us to subscribe.",
font=("Segoe UI", 8),
bg=_BG, fg=_SUBTEXT
).pack(side="bottom", pady=14)
root.mainloop()
return activated[0]
def _on_activate(root, key_var, status_var, status_label, activate_btn, activated):
key = key_var.get().strip()
if not key:
status_var.set("Please enter a license key.")
return
# Show checking state
activate_btn.config(state="disabled", text="Checking…")
status_var.set("Contacting license server…")
status_label.config(fg=_SUBTEXT)
root.update()
try:
hwid = get_hwid()
valid, reason = license_check.verify_license(key, hwid)
except Exception as exc:
activate_btn.config(state="normal", text="Activate")
status_var.set(f"Error: {exc}")
status_label.config(fg=_ERROR)
return
if valid:
license_check.save_license_key(key, hwid)
status_var.set("Activated successfully! Launching…")
status_label.config(fg=_SUCCESS)
activated[0] = True
root.after(900, root.destroy)
else:
activate_btn.config(state="normal", text="Activate")
status_var.set(reason)
status_label.config(fg=_ERROR)
def _on_exit(root, activated):
activated[0] = False
root.destroy()

93
build.bat Normal file
View File

@ -0,0 +1,93 @@
@echo off
cd /d "%~dp0"
echo ======================================
echo Ultimate Investment Tool - Build EXE
echo ======================================
echo.
echo Installing dependencies...
python -m pip install pyinstaller tinyaes matplotlib --quiet
if errorlevel 1 (
echo ERROR: pip failed. Make sure Python is installed and on PATH.
pause
exit /b 1
)
echo.
echo Copying source files from dist\ for bundling...
copy /Y dist\screener_gui.py screener_gui.py >nul
copy /Y dist\stock_screener.py stock_screener.py >nul
echo Building executable...
echo.
python -m PyInstaller ^
--onefile ^
--windowed ^
--key "UIT-K3y-2025-Ultr4Inv3st" ^
--name "StockScreener" ^
--hidden-import pandas ^
--hidden-import numpy ^
--hidden-import yfinance ^
--hidden-import requests ^
--hidden-import lxml ^
--hidden-import lxml.etree ^
--hidden-import html5lib ^
--hidden-import bs4 ^
--hidden-import appdirs ^
--hidden-import platformdirs ^
--hidden-import tkinter ^
--hidden-import tkinter.ttk ^
--hidden-import tkinter.messagebox ^
--hidden-import screener_gui ^
--hidden-import stock_screener ^
--hidden-import matplotlib ^
--hidden-import matplotlib.backends.backend_tkagg ^
--hidden-import matplotlib.figure ^
--hidden-import matplotlib.dates ^
--hidden-import matplotlib.ticker ^
--hidden-import winreg ^
--hidden-import hwid ^
--hidden-import license_check ^
--hidden-import activation_dialog ^
--hidden-import updater ^
--collect-all yfinance ^
--collect-all matplotlib ^
launcher.py
if errorlevel 1 (
echo.
echo BUILD FAILED. See errors above.
echo.
echo NOTE: If the error mentions '--key' or 'tinyaes', your PyInstaller version
echo may not support bytecode encryption. Remove the '--key' line above
echo and rebuild. Encryption requires PyInstaller 5.x + tinyaes.
echo.
echo Cleaning up temporary files...
if exist screener_gui.py del screener_gui.py
if exist stock_screener.py del stock_screener.py
pause
exit /b 1
)
echo.
echo Cleaning up temporary source copies from project root...
if exist screener_gui.py del screener_gui.py
if exist stock_screener.py del stock_screener.py
echo.
echo ======================================
echo SUCCESS!
echo.
echo Folder: dist\
echo - StockScreener.exe (distribute this)
echo - screener_gui.py (distribute alongside exe)
echo - stock_screener.py (distribute alongside exe)
echo.
echo DO NOT distribute:
echo - issue_license.py (admin only)
echo - supabase_schema.sql (admin only)
echo - hwid.py / license_check.py / updater.py / activation_dialog.py
echo (these are bundled inside the exe)
echo ======================================
pause

55
hwid.py Normal file
View File

@ -0,0 +1,55 @@
"""
HWID Generator
==============
Generates a stable hardware fingerprint for this machine using:
- Windows MachineGuid (most stable, survives most software changes)
- CPU Processor ID (hardware-level, very stable)
Returns a SHA-256 hex digest. Never changes unless hardware changes.
"""
import hashlib
import subprocess
import winreg
def get_hwid() -> str:
"""Return a stable SHA-256 HWID string for the current machine."""
components = []
# Windows MachineGuid — set once at OS install, very stable
try:
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Cryptography"
)
guid, _ = winreg.QueryValueEx(key, "MachineGuid")
winreg.CloseKey(key)
components.append(f"guid:{guid}")
except Exception:
pass
# CPU Processor ID — hardware-level identifier
try:
result = subprocess.run(
["wmic", "cpu", "get", "ProcessorId", "/value"],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.splitlines():
line = line.strip()
if line.startswith("ProcessorId=") and "=" in line:
cpu_id = line.split("=", 1)[1].strip()
if cpu_id:
components.append(f"cpu:{cpu_id}")
break
except Exception:
pass
if not components:
raise RuntimeError(
"Could not generate HWID. "
"This application requires Windows and access to system information."
)
raw = "|".join(components)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()

157
launcher.py Normal file
View File

@ -0,0 +1,157 @@
"""
Stock Screener Launcher
=======================
Startup sequence:
1. Generate HWID
2. Load stored license key show activation dialog if none found
3. Verify license against Supabase (with 30-min offline grace period)
4. Silently check for and apply updates
5. Load and run screener_gui.py
Source file hygiene
-------------------
On every launch, the launcher scans the parent folder of the exe for stray
copies of screener_gui.py or stock_screener.py and removes them (pulling in
any newer version first so no edits are lost).
"""
import importlib.util
import os
import shutil
import sys
import tkinter as tk
from tkinter import messagebox
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _exe_dir() -> str:
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
_SOURCE_FILES = ("screener_gui.py", "stock_screener.py")
def _sync_source_files(exe_dir: str) -> None:
parent_dir = os.path.dirname(exe_dir)
if parent_dir == exe_dir:
return
for fname in _SOURCE_FILES:
stray = os.path.join(parent_dir, fname)
if not os.path.exists(stray):
continue
live = os.path.join(exe_dir, fname)
if not os.path.exists(live) or os.path.getmtime(stray) > os.path.getmtime(live):
try:
shutil.copy2(stray, live)
except OSError:
pass
try:
os.remove(stray)
except OSError:
pass
def _fatal(title: str, message: str) -> None:
"""Show an error dialog and exit."""
root = tk.Tk()
root.withdraw()
messagebox.showerror(title, message)
root.destroy()
sys.exit(1)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
exe_dir = _exe_dir()
_sync_source_files(exe_dir)
# ------------------------------------------------------------------
# Step 1: HWID
# ------------------------------------------------------------------
try:
from hwid import get_hwid
hwid = get_hwid()
except Exception as exc:
_fatal("Ultimate Investment Tool", f"Could not generate hardware ID:\n\n{exc}")
return # unreachable, but satisfies linters
# ------------------------------------------------------------------
# Step 2: Load or activate license
# ------------------------------------------------------------------
import license_check
license_key = license_check.load_license_key(hwid)
if license_key is None:
# First launch — show activation dialog
import activation_dialog
activated = activation_dialog.run_activation()
if not activated:
sys.exit(0) # user closed the window
# Reload after successful save
license_key = license_check.load_license_key(hwid)
if license_key is None:
_fatal("Ultimate Investment Tool", "Activation failed. Please restart and try again.")
return
# ------------------------------------------------------------------
# Step 3: Verify license on every launch
# ------------------------------------------------------------------
valid, reason = license_check.verify_license(license_key, hwid)
if not valid:
# License invalid — clear stored key so they can re-enter on next launch
license_check.delete_license_key()
_fatal(
"Ultimate Investment Tool — License Error",
f"{reason}\n\nThe application will now close."
)
return
# ------------------------------------------------------------------
# Step 4: Silent update check
# ------------------------------------------------------------------
try:
import updater
updater.check_and_apply_update()
# If an update was applied, updater restarts the process — we never reach here.
except Exception:
pass # never block the app over a failed update check
# ------------------------------------------------------------------
# Step 5: Load screener_gui.py
# ------------------------------------------------------------------
script = os.path.join(exe_dir, "screener_gui.py")
if os.path.exists(script):
if exe_dir not in sys.path:
sys.path.insert(0, exe_dir)
spec = importlib.util.spec_from_file_location("screener_gui_live", script)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
mod.main()
return
# Fallback: frozen version bundled inside the exe
try:
import screener_gui
screener_gui.main()
except Exception as exc:
_fatal(
"Ultimate Investment Tool",
f"Could not start.\n\n{exc}\n\n"
"Make sure screener_gui.py is in the same folder as this exe."
)
if __name__ == "__main__":
main()

161
license_check.py Normal file
View File

@ -0,0 +1,161 @@
"""
License Check
=============
Validates the stored license key against Supabase on every launch.
Falls back to a local cache if the server is unreachable (30-minute grace period).
Storage location: %APPDATA%\StockScreener\
license.dat XOR-encrypted license key (machine-bound)
auth_cache.dat last successful auth result + timestamp
"""
import base64
import hashlib
import json
import os
import time
import requests
# ---------------------------------------------------------------------------
# Supabase config
# ---------------------------------------------------------------------------
_SUPABASE_URL = "https://yeispcpmepjelfbhfkwr.supabase.co"
_SUPABASE_ANON = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InllaXNwY3BtZXBqZWxmYmhma3dyIiwi"
"cm9sZSI6ImFub24iLCJpYXQiOjE3NzM1MDkxOTAsImV4cCI6MjA4OTA4NTE5MH0."
"8kq6HzpwF81_Sx36MXbx0wUqaT9Ul9POw1LzF5vxiOo"
)
# 30 minutes
_GRACE_PERIOD_SECONDS = 30 * 60
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_APP_DATA = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "StockScreener")
_LICENSE_FILE = os.path.join(_APP_DATA, "license.dat")
_CACHE_FILE = os.path.join(_APP_DATA, "auth_cache.dat")
def _ensure_dir() -> None:
os.makedirs(_APP_DATA, exist_ok=True)
# ---------------------------------------------------------------------------
# Encryption helpers (XOR cipher keyed on HWID — machine-binds the stored key)
# ---------------------------------------------------------------------------
def _derive_key(hwid: str) -> bytes:
return hashlib.sha256(hwid.encode("utf-8")).digest()
def _xor(data: bytes, key: bytes) -> bytes:
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
def _encrypt(text: str, hwid: str) -> str:
raw = _xor(text.encode("utf-8"), _derive_key(hwid))
return base64.urlsafe_b64encode(raw).decode("ascii")
def _decrypt(token: str, hwid: str) -> str:
raw = base64.urlsafe_b64decode(token.encode("ascii"))
return _xor(raw, _derive_key(hwid)).decode("utf-8")
# ---------------------------------------------------------------------------
# License key persistence
# ---------------------------------------------------------------------------
def save_license_key(key: str, hwid: str) -> None:
"""Encrypt and persist the license key to disk."""
_ensure_dir()
token = _encrypt(key, hwid)
with open(_LICENSE_FILE, "w", encoding="ascii") as f:
f.write(token)
def load_license_key(hwid: str) -> str | None:
"""Return the stored license key, or None if not found / corrupt."""
if not os.path.exists(_LICENSE_FILE):
return None
try:
with open(_LICENSE_FILE, "r", encoding="ascii") as f:
token = f.read().strip()
return _decrypt(token, hwid)
except Exception:
return None
def delete_license_key() -> None:
"""Remove stored license key (e.g. on deactivation)."""
if os.path.exists(_LICENSE_FILE):
os.remove(_LICENSE_FILE)
if os.path.exists(_CACHE_FILE):
os.remove(_CACHE_FILE)
# ---------------------------------------------------------------------------
# Auth cache
# ---------------------------------------------------------------------------
def _save_cache(valid: bool, reason: str) -> None:
_ensure_dir()
payload = {"valid": valid, "reason": reason, "timestamp": time.time()}
with open(_CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(payload, f)
def _load_cache() -> dict | None:
if not os.path.exists(_CACHE_FILE):
return None
try:
with open(_CACHE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
# ---------------------------------------------------------------------------
# Core verification
# ---------------------------------------------------------------------------
def verify_license(license_key: str, hwid: str) -> tuple[bool, str]:
"""
Returns (valid: bool, reason: str).
Tries the Supabase RPC first. If the server is unreachable, falls back
to the cached result if it is within the 30-minute grace period.
"""
headers = {
"apikey": _SUPABASE_ANON,
"Content-Type": "application/json",
}
try:
resp = requests.post(
f"{_SUPABASE_URL}/rest/v1/rpc/verify_license",
headers=headers,
json={"p_key": license_key, "p_hwid": hwid},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
valid = bool(data.get("valid", False))
reason = data.get("reason", "Unknown error")
_save_cache(valid, reason)
return valid, reason
except requests.RequestException:
cache = _load_cache()
if cache and cache.get("valid"):
age = time.time() - cache.get("timestamp", 0)
if age < _GRACE_PERIOD_SECONDS:
return True, "offline_grace"
return (
False,
"Cannot reach the license server. "
"Please check your internet connection and try again."
)

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
yfinance>=0.2.36
pandas>=2.0.0
numpy>=1.24.0
requests>=2.28.0
lxml>=4.9.0
html5lib>=1.1
beautifulsoup4>=4.12.0
vaderSentiment>=3.3.2

173
supabase_schema.sql Normal file
View File

@ -0,0 +1,173 @@
-- =============================================================================
-- Ultimate Investment Tool — Supabase Schema
-- Paste this entire file into Supabase SQL Editor and click Run.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. TABLES
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS licenses (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
license_key TEXT UNIQUE NOT NULL,
hwid TEXT, -- NULL until first activation
tier TEXT NOT NULL DEFAULT 'monthly', -- 'weekly' | 'monthly' | 'lifetime'
expiry_date TIMESTAMPTZ, -- NULL = lifetime
active BOOLEAN NOT NULL DEFAULT true,
machines_allowed INTEGER NOT NULL DEFAULT 1,
notes TEXT, -- customer name / order ref
created_at TIMESTAMPTZ DEFAULT NOW(),
activated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS app_versions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
version TEXT UNIQUE NOT NULL, -- e.g. '1.0.1'
gui_py_url TEXT, -- Supabase Storage URL for screener_gui.py
screener_py_url TEXT, -- Supabase Storage URL for stock_screener.py
release_notes TEXT,
is_latest BOOLEAN NOT NULL DEFAULT true,
released_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS auth_log (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
license_key TEXT,
hwid TEXT,
result TEXT, -- 'valid' | 'invalid'
reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Seed the initial version row so the updater has something to compare against.
INSERT INTO app_versions (version, is_latest, release_notes)
VALUES ('1.0.0', true, 'Initial release')
ON CONFLICT (version) DO NOTHING;
-- -----------------------------------------------------------------------------
-- 2. ROW LEVEL SECURITY — no direct table access for anon
-- -----------------------------------------------------------------------------
ALTER TABLE licenses ENABLE ROW LEVEL SECURITY;
ALTER TABLE app_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE auth_log ENABLE ROW LEVEL SECURITY;
-- No RLS policies = anon/authenticated cannot SELECT/INSERT/UPDATE/DELETE directly.
-- All access goes through SECURITY DEFINER functions below.
-- -----------------------------------------------------------------------------
-- 3. RPC: verify_license(p_key, p_hwid) → JSON
-- -----------------------------------------------------------------------------
-- Called by the app on every launch with the stored license key + machine HWID.
-- Handles: first-time HWID binding, expiry, disabled keys, HWID mismatch.
-- Returns: { valid, reason, tier, expiry_date }
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION verify_license(p_key TEXT, p_hwid TEXT)
RETURNS JSON
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
lic RECORD;
BEGIN
-- Look up the key
SELECT * INTO lic FROM licenses WHERE license_key = p_key;
IF NOT FOUND THEN
INSERT INTO auth_log (license_key, hwid, result, reason)
VALUES (p_key, p_hwid, 'invalid', 'key_not_found');
RETURN json_build_object(
'valid', false,
'reason', 'License key not found.'
);
END IF;
-- Check disabled
IF NOT lic.active THEN
INSERT INTO auth_log (license_key, hwid, result, reason)
VALUES (p_key, p_hwid, 'invalid', 'key_disabled');
RETURN json_build_object(
'valid', false,
'reason', 'This license has been disabled. Please contact support.'
);
END IF;
-- Check expiry (NULL = lifetime, never expires)
IF lic.expiry_date IS NOT NULL AND lic.expiry_date < NOW() THEN
INSERT INTO auth_log (license_key, hwid, result, reason)
VALUES (p_key, p_hwid, 'invalid', 'expired');
RETURN json_build_object(
'valid', false,
'reason', 'Your subscription has expired. Please renew to continue.'
);
END IF;
-- HWID check
IF lic.hwid IS NULL THEN
-- First activation — bind this machine
UPDATE licenses
SET hwid = p_hwid, activated_at = NOW()
WHERE license_key = p_key;
ELSIF lic.hwid <> p_hwid THEN
INSERT INTO auth_log (license_key, hwid, result, reason)
VALUES (p_key, p_hwid, 'invalid', 'hwid_mismatch');
RETURN json_build_object(
'valid', false,
'reason', 'This license is registered to a different machine. Contact support to transfer.'
);
END IF;
-- All checks passed
INSERT INTO auth_log (license_key, hwid, result, reason)
VALUES (p_key, p_hwid, 'valid', 'ok');
RETURN json_build_object(
'valid', true,
'reason', 'ok',
'tier', lic.tier,
'expiry_date', lic.expiry_date
);
END;
$$;
-- -----------------------------------------------------------------------------
-- 4. RPC: get_latest_version() → JSON
-- -----------------------------------------------------------------------------
-- Called by the updater after a successful license check.
-- Returns: { version, gui_py_url, screener_py_url, release_notes }
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION get_latest_version()
RETURNS JSON
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
ver RECORD;
BEGIN
SELECT * INTO ver
FROM app_versions
WHERE is_latest = true
ORDER BY released_at DESC
LIMIT 1;
IF NOT FOUND THEN
RETURN json_build_object('version', '1.0.0');
END IF;
RETURN json_build_object(
'version', ver.version,
'gui_py_url', ver.gui_py_url,
'screener_py_url', ver.screener_py_url,
'release_notes', ver.release_notes
);
END;
$$;
-- -----------------------------------------------------------------------------
-- 5. GRANTS — allow anon key to call the two RPC functions only
-- -----------------------------------------------------------------------------
GRANT EXECUTE ON FUNCTION verify_license(TEXT, TEXT) TO anon;
GRANT EXECUTE ON FUNCTION get_latest_version() TO anon;

121
updater.py Normal file
View File

@ -0,0 +1,121 @@
"""
Auto-Updater
============
Checks Supabase for a newer version of screener_gui.py and stock_screener.py.
If found, silently downloads and replaces the files in the exe directory,
then restarts the application.
Called after a successful license check on every launch.
"""
import os
import sys
import tempfile
import shutil
import requests
# ---------------------------------------------------------------------------
# Current app version — bump this in lockstep with new Supabase version rows
# ---------------------------------------------------------------------------
APP_VERSION = "1.0.0"
# ---------------------------------------------------------------------------
# Supabase config (same anon key as license_check.py)
# ---------------------------------------------------------------------------
_SUPABASE_URL = "https://yeispcpmepjelfbhfkwr.supabase.co"
_SUPABASE_ANON = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InllaXNwY3BtZXBqZWxmYmhma3dyIiwi"
"cm9sZSI6ImFub24iLCJpYXQiOjE3NzM1MDkxOTAsImV4cCI6MjA4OTA4NTE5MH0."
"8kq6HzpwF81_Sx36MXbx0wUqaT9Ul9POw1LzF5vxiOo"
)
_HEADERS = {
"apikey": _SUPABASE_ANON,
"Content-Type": "application/json",
}
# Files that can be updated without rebuilding the exe
_UPDATABLE_FILES = ("screener_gui.py", "stock_screener.py")
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:
"""Convert '1.2.3' → (1, 2, 3) for comparison."""
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, using a temp file so the write is atomic."""
tmp_dir = tempfile.mkdtemp()
tmp_path = os.path.join(tmp_dir, os.path.basename(dest_path))
try:
with requests.get(url, stream=True, timeout=30) 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 _restart() -> None:
"""Restart the current process cleanly."""
if getattr(sys, "frozen", False):
os.execv(sys.executable, [sys.executable] + sys.argv[1:])
else:
os.execv(sys.executable, [sys.executable] + sys.argv)
def check_and_apply_update() -> None:
"""
Silently check for updates and apply them if available.
Errors are swallowed a failed update check never blocks the app.
"""
try:
resp = requests.post(
f"{_SUPABASE_URL}/rest/v1/rpc/get_latest_version",
headers=_HEADERS,
json={},
timeout=8,
)
resp.raise_for_status()
data = resp.json()
latest = data.get("version", APP_VERSION)
gui_url = data.get("gui_py_url")
scr_url = data.get("screener_py_url")
if _parse_version(latest) <= _parse_version(APP_VERSION):
return # already up to date
exe_dir = _exe_dir()
updated = False
if gui_url:
dest = os.path.join(exe_dir, "screener_gui.py")
_download_file(gui_url, dest)
updated = True
if scr_url:
dest = os.path.join(exe_dir, "stock_screener.py")
_download_file(scr_url, dest)
updated = True
if updated:
# Restart so the fresh .py files are loaded
_restart()
except Exception:
# Never crash the app over a failed update check
pass