""" 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): """ Smartly updates app source files by only copying changed files. Preserves user settings and reduces disk I/O. """ if self.ui: self.ui.set_status("Checking for updates...") try: # 1. Ensure destination exists if not self.app_path.exists(): self.app_path.mkdir(parents=True, exist_ok=True) # 2. Walk source and sync # source_path is the temporary bundled folder # app_path is the persistent runtime folder changes_made = 0 for src_dir, dirs, files in os.walk(self.source_path): # Determine relative path from source root rel_path = Path(src_dir).relative_to(self.source_path) dst_dir = self.app_path / rel_path # Ensure directory exists if not dst_dir.exists(): dst_dir.mkdir(parents=True, exist_ok=True) for file in files: # Skip ignored files if file in ['__pycache__', '.git', 'settings.json'] or file.endswith('.pyc'): continue src_file = Path(src_dir) / file dst_file = dst_dir / file # Check if update needed should_copy = False if not dst_file.exists(): should_copy = True else: # Compare size first (fast) if src_file.stat().st_size != dst_file.stat().st_size: should_copy = True else: # Compare content (slower but accurate) # Only read if size matches to verify diff if src_file.read_bytes() != dst_file.read_bytes(): should_copy = True if should_copy: shutil.copy2(src_file, dst_file) changes_made += 1 if self.ui: self.ui.set_detail(f"Updated: {file}") # 3. Cleanup logic (Optional: remove files in dest that are not in source) # For now, we only add/update to prevent deleting generated user files (logs, etc) if changes_made > 0: log(f"Update complete. {changes_made} files changed.") else: log("App is up to date.") return True except Exception as e: log(f"Error refreshing app source: {e}") # Fallback to nuclear option if sync fails completely? # No, 'smart_sync' failing might mean permissions, nuclear wouldn't help. 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 check_dependencies(self): """Quick check if critical dependencies are installed.""" return True # Deprecated logic placeholder def setup_and_run(self): """Full setup/update and run flow.""" try: # 1. Ensure basics if not self.is_python_ready(): self.download_python() self._fix_pth_file() # Ensure pth is fixed immediately after download 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: if self.ui: import tkinter.messagebox as mb mb.showerror("Setup Error", f"Installation failed: {e}") # Improved error visibility log(f"Fatal error: {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}")