Initial commit with licensing and auto-update system
This commit is contained in:
commit
aa0af6fd4f
|
|
@ -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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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."
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue