commit aa0af6fd4f32352efe0dc4eaf52d545ee23a0b13 Author: Nolan Kovacs Date: Sat Mar 14 15:52:01 2026 -0400 Initial commit with licensing and auto-update system diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6fb306e --- /dev/null +++ b/.claude/settings.local.json @@ -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)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..331f6cb --- /dev/null +++ b/.gitignore @@ -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 diff --git a/StockScreener.spec b/StockScreener.spec new file mode 100644 index 0000000..aae3669 --- /dev/null +++ b/StockScreener.spec @@ -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, +) diff --git a/activation_dialog.py b/activation_dialog.py new file mode 100644 index 0000000..7146baf --- /dev/null +++ b/activation_dialog.py @@ -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("", 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() diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..9fd6e1e --- /dev/null +++ b/build.bat @@ -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 diff --git a/hwid.py b/hwid.py new file mode 100644 index 0000000..727dfc5 --- /dev/null +++ b/hwid.py @@ -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() diff --git a/launcher.py b/launcher.py new file mode 100644 index 0000000..2684b95 --- /dev/null +++ b/launcher.py @@ -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() diff --git a/license_check.py b/license_check.py new file mode 100644 index 0000000..26624b6 --- /dev/null +++ b/license_check.py @@ -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." + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..79a3ca5 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/supabase_schema.sql b/supabase_schema.sql new file mode 100644 index 0000000..f3364af --- /dev/null +++ b/supabase_schema.sql @@ -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; diff --git a/updater.py b/updater.py new file mode 100644 index 0000000..83308a7 --- /dev/null +++ b/updater.py @@ -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