371 lines
12 KiB
Python
371 lines
12 KiB
Python
"""
|
|
WhisperVoice Bootstrapper
|
|
=========================
|
|
|
|
Lightweight launcher that downloads Python + dependencies on first run.
|
|
This keeps the initial .exe small (~15-20MB) while downloading the full
|
|
runtime (~2-3GB) on first launch.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
import zipfile
|
|
import shutil
|
|
import threading
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
# Use tkinter for the progress UI (built into Python, no extra deps)
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
|
|
# Configuration
|
|
PYTHON_URL = "https://www.python.org/ftp/python/3.11.9/python-3.11.9-embed-amd64.zip"
|
|
PYTHON_VERSION = "3.11.9"
|
|
GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
|
|
|
|
|
|
def get_base_path():
|
|
"""Get the directory where the .exe is located."""
|
|
if getattr(sys, 'frozen', False):
|
|
return Path(sys.executable).parent
|
|
return Path(__file__).parent
|
|
|
|
def log(msg):
|
|
"""Safe logging that won't crash if stdout/stderr is None."""
|
|
try:
|
|
if sys.stdout:
|
|
print(msg)
|
|
sys.stdout.flush()
|
|
except:
|
|
pass
|
|
|
|
class BootstrapperUI:
|
|
"""Simple Tkinter UI for download progress."""
|
|
|
|
def __init__(self):
|
|
self.root = tk.Tk()
|
|
self.root.title("WhisperVoice Setup")
|
|
self.root.geometry("500x220")
|
|
self.root.resizable(False, False)
|
|
self.root.configure(bg="#1a1a2e")
|
|
|
|
# Center on screen
|
|
self.root.update_idletasks()
|
|
x = (self.root.winfo_screenwidth() - 500) // 2
|
|
y = (self.root.winfo_screenheight() - 220) // 2
|
|
self.root.geometry(f"+{x}+{y}")
|
|
|
|
# Set Window Icon
|
|
try:
|
|
if getattr(sys, 'frozen', False):
|
|
icon_path = os.path.join(sys._MEIPASS, "app_source", "assets", "icon.ico")
|
|
else:
|
|
icon_path = os.path.join(os.path.dirname(__file__), "assets", "icon.ico")
|
|
|
|
if os.path.exists(icon_path):
|
|
self.root.iconbitmap(icon_path)
|
|
except:
|
|
pass
|
|
|
|
# Title
|
|
self.title_label = tk.Label(
|
|
self.root,
|
|
text="🎤 WhisperVoice Setup",
|
|
font=("Segoe UI", 18, "bold"),
|
|
fg="#00f2ff",
|
|
bg="#1a1a2e"
|
|
)
|
|
self.title_label.pack(pady=(20, 10))
|
|
|
|
# Status
|
|
self.status_label = tk.Label(
|
|
self.root,
|
|
text="Initializing...",
|
|
font=("Segoe UI", 11),
|
|
fg="#ffffff",
|
|
bg="#1a1a2e"
|
|
)
|
|
self.status_label.pack(pady=5)
|
|
|
|
# Progress bar
|
|
style = ttk.Style()
|
|
style.theme_use('clam')
|
|
style.configure(
|
|
"Custom.Horizontal.TProgressbar",
|
|
background="#00f2ff",
|
|
troughcolor="#0d0d10",
|
|
borderwidth=0,
|
|
lightcolor="#00f2ff",
|
|
darkcolor="#00f2ff"
|
|
)
|
|
|
|
self.progress = ttk.Progressbar(
|
|
self.root,
|
|
style="Custom.Horizontal.TProgressbar",
|
|
length=400,
|
|
mode='determinate'
|
|
)
|
|
self.progress.pack(pady=15)
|
|
|
|
# Detail label
|
|
self.detail_label = tk.Label(
|
|
self.root,
|
|
text="",
|
|
font=("Segoe UI", 9),
|
|
fg="#888888",
|
|
bg="#1a1a2e"
|
|
)
|
|
self.detail_label.pack(pady=5)
|
|
|
|
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
|
|
self.cancelled = False
|
|
|
|
def on_close(self):
|
|
self.cancelled = True
|
|
self.root.destroy()
|
|
sys.exit(0)
|
|
|
|
def set_status(self, text):
|
|
log(f"[STATUS] {text}")
|
|
self.status_label.config(text=text)
|
|
self.root.update()
|
|
|
|
def set_detail(self, text):
|
|
self.detail_label.config(text=text)
|
|
self.root.update()
|
|
|
|
def set_progress(self, value):
|
|
self.progress['value'] = value
|
|
self.root.update()
|
|
|
|
def run_in_thread(self, func):
|
|
"""Run a function in a background thread."""
|
|
thread = threading.Thread(target=func, daemon=True)
|
|
thread.start()
|
|
return thread
|
|
|
|
|
|
class Bootstrapper:
|
|
"""Main bootstrapper logic."""
|
|
|
|
def __init__(self, ui=None):
|
|
self.ui = ui
|
|
self.base_path = get_base_path()
|
|
self.runtime_path = self.base_path / "runtime"
|
|
self.python_path = self.runtime_path / "python"
|
|
self.app_path = self.runtime_path / "app"
|
|
|
|
if getattr(sys, 'frozen', False):
|
|
self.source_path = Path(sys._MEIPASS) / "app_source"
|
|
else:
|
|
self.source_path = self.base_path
|
|
|
|
def is_python_ready(self):
|
|
"""Check if Python runtime exists."""
|
|
return (self.python_path / "python.exe").exists()
|
|
|
|
def download_file(self, url, dest_path, desc="Downloading"):
|
|
"""Download a file with progress."""
|
|
if self.ui: self.ui.set_status(desc)
|
|
|
|
def progress_hook(count, block_size, total_size):
|
|
if self.ui and total_size > 0:
|
|
percent = min(100, (count * block_size * 100) // total_size)
|
|
self.ui.set_progress(percent)
|
|
mb_done = (count * block_size) / (1024 * 1024)
|
|
mb_total = total_size / (1024 * 1024)
|
|
self.ui.set_detail(f"{mb_done:.1f} / {mb_total:.1f} MB")
|
|
|
|
urllib.request.urlretrieve(url, dest_path, progress_hook)
|
|
|
|
def download_python(self):
|
|
"""Download and extract Python embeddable."""
|
|
if self.ui: self.ui.set_status("Downloading Python...")
|
|
|
|
self.runtime_path.mkdir(parents=True, exist_ok=True)
|
|
zip_path = self.runtime_path / "python.zip"
|
|
self.download_file(PYTHON_URL, zip_path, "Downloading Python runtime...")
|
|
|
|
if self.ui:
|
|
self.ui.set_status("Extracting Python...")
|
|
self.ui.set_progress(0)
|
|
|
|
self.python_path.mkdir(parents=True, exist_ok=True)
|
|
with zipfile.ZipFile(zip_path, 'r') as zf:
|
|
total = len(zf.namelist())
|
|
for i, name in enumerate(zf.namelist()):
|
|
zf.extract(name, self.python_path)
|
|
if self.ui: self.ui.set_progress((i * 100) // total)
|
|
|
|
zip_path.unlink()
|
|
self._fix_pth_file()
|
|
|
|
def _fix_pth_file(self):
|
|
"""CRITICAL: Configure ._pth file to support imports from our app folder."""
|
|
# Find the _pth file in the python folder
|
|
pth_files = list(self.python_path.glob("*._pth"))
|
|
if not pth_files: return
|
|
|
|
pth_file = pth_files[0]
|
|
|
|
# Standard names in embeddable: python311.zip, ., import site
|
|
content = [
|
|
"python311.zip", # Hardcoded for now but works for 3.11
|
|
".",
|
|
"../app", # Include app folder in search path!
|
|
"Lib/site-packages",
|
|
"import site" # Enable site-packages
|
|
]
|
|
pth_file.write_text("\n".join(content) + "\n")
|
|
|
|
# Ensure site-packages exists
|
|
(self.python_path / "Lib" / "site-packages").mkdir(parents=True, exist_ok=True)
|
|
|
|
def install_pip(self):
|
|
"""Install pip into embedded Python."""
|
|
if self.ui: self.ui.set_status("Installing pip...")
|
|
get_pip_path = self.runtime_path / "get-pip.py"
|
|
self.download_file(GET_PIP_URL, get_pip_path, "Downloading pip installer...")
|
|
|
|
subprocess.run(
|
|
[str(self.python_path / "python.exe"), str(get_pip_path), "--no-warn-script-location"],
|
|
cwd=str(self.python_path),
|
|
capture_output=True,
|
|
creationflags=subprocess.CREATE_NO_WINDOW
|
|
)
|
|
get_pip_path.unlink()
|
|
|
|
def install_packages(self):
|
|
"""Install required packages via pip."""
|
|
if self.ui:
|
|
self.ui.set_status("Installing packages...")
|
|
self.ui.set_progress(10)
|
|
|
|
req_file = self.source_path / "requirements.txt"
|
|
|
|
process = subprocess.Popen(
|
|
[str(self.python_path / "python.exe"), "-m", "pip", "install", "-r", str(req_file)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
cwd=str(self.python_path),
|
|
creationflags=subprocess.CREATE_NO_WINDOW
|
|
)
|
|
|
|
for line in process.stdout:
|
|
if self.ui: self.ui.set_detail(line.strip()[:60])
|
|
process.wait()
|
|
|
|
def refresh_app_source(self):
|
|
"""Refresh app source files. Skips if already exists to save time."""
|
|
# Optimization: If app/main.py exists, skip update to improve startup speed.
|
|
# The user can delete the 'runtime' folder to force an update.
|
|
if (self.app_path / "main.py").exists():
|
|
log("App already exists. Skipping update.")
|
|
return True
|
|
|
|
if self.ui: self.ui.set_status("Updating app files...")
|
|
|
|
try:
|
|
# Preserve settings.json if it exists
|
|
settings_path = self.app_path / "settings.json"
|
|
temp_settings = None
|
|
if settings_path.exists():
|
|
try:
|
|
temp_settings = settings_path.read_bytes()
|
|
except:
|
|
log("Failed to backup settings.json, it involves risk of data loss.")
|
|
|
|
if self.app_path.exists():
|
|
shutil.rmtree(self.app_path, ignore_errors=True)
|
|
|
|
shutil.copytree(
|
|
self.source_path,
|
|
self.app_path,
|
|
ignore=shutil.ignore_patterns(
|
|
'__pycache__', '*.pyc', '.git', 'venv',
|
|
'build', 'dist', '*.egg-info', 'runtime'
|
|
)
|
|
)
|
|
|
|
# Restore settings.json
|
|
if temp_settings:
|
|
try:
|
|
settings_path.write_bytes(temp_settings)
|
|
log("Restored settings.json")
|
|
except:
|
|
log("Failed to restore settings.json")
|
|
|
|
return True
|
|
except Exception as e:
|
|
log(f"Error refreshing app source: {e}")
|
|
return False
|
|
|
|
def run_app(self):
|
|
"""Launch the main application and exit."""
|
|
python_exe = self.python_path / "python.exe"
|
|
main_py = self.app_path / "main.py"
|
|
|
|
env = os.environ.copy()
|
|
env["PYTHONPATH"] = str(self.app_path)
|
|
|
|
try:
|
|
subprocess.Popen(
|
|
[str(python_exe), str(main_py)],
|
|
cwd=str(self.app_path),
|
|
env=env,
|
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
messagebox.showerror("WhisperVoice Error", f"Failed to launch app: {e}")
|
|
return False
|
|
|
|
def setup_and_run(self):
|
|
"""Full setup/update and run flow."""
|
|
try:
|
|
if not self.is_python_ready():
|
|
self.download_python()
|
|
self.install_pip()
|
|
self.install_packages()
|
|
|
|
# Always refresh source to ensure we have the latest bundled code
|
|
self.refresh_app_source()
|
|
|
|
# Launch
|
|
if self.run_app():
|
|
if self.ui: self.ui.root.quit()
|
|
except Exception as e:
|
|
messagebox.showerror("Setup Error", f"Installation failed: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
|
|
def main():
|
|
base_path = get_base_path()
|
|
python_exe = base_path / "runtime" / "python" / "python.exe"
|
|
main_py = base_path / "runtime" / "app" / "main.py"
|
|
|
|
# Fast path: if already set up and not frozen (development), just launch
|
|
if not getattr(sys, 'frozen', False) and python_exe.exists() and main_py.exists():
|
|
boot = Bootstrapper()
|
|
boot.run_app()
|
|
return
|
|
|
|
# Normal path: Show UI for possible setup/update
|
|
ui = BootstrapperUI()
|
|
boot = Bootstrapper(ui)
|
|
|
|
threading.Thread(target=boot.setup_and_run, daemon=True).start()
|
|
ui.root.mainloop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except Exception as e:
|
|
import tkinter.messagebox as mb
|
|
mb.showerror("Fatal Error", f"Bootstrapper crashed: {e}")
|