Files
whisper_voice/bootstrapper.py

444 lines
16 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"
# Use --prefer-binary to avoid building from source on Windows if possible
# Use --no-warn-script-location to reduce noise
# CRITICAL: Force --only-binary for llama-cpp-python to prevent picking new source-only versions
cmd = [
str(self.python_path / "python.exe"), "-m", "pip", "install",
"--prefer-binary",
"--only-binary", "llama-cpp-python",
"--extra-index-url", "https://abetlen.github.io/llama-cpp-python/whl/cpu",
"-r", str(req_file)
]
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # Merge stderr into stdout
text=True,
cwd=str(self.python_path),
creationflags=subprocess.CREATE_NO_WINDOW
)
output_buffer = []
for line in process.stdout:
line_stripped = line.strip()
if self.ui: self.ui.set_detail(line_stripped[:60])
output_buffer.append(line_stripped)
log(line_stripped)
return_code = process.wait()
if return_code != 0:
err_msg = "\n".join(output_buffer[-15:]) # Show last 15 lines
raise RuntimeError(f"Pip install failed (Exit code {return_code}):\n{err_msg}")
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):
"""Check if critical dependencies are importable in the embedded python."""
if not self.is_python_ready(): return False
try:
# Check for core libs that might be missing
# We use a subprocess to check imports in the runtime environment
subprocess.check_call(
[str(self.python_path / "python.exe"), "-c", "import faster_whisper; import llama_cpp; import PySide6"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=str(self.python_path),
creationflags=subprocess.CREATE_NO_WINDOW
)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
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() # We'll do this in the dependency check step now
# Always refresh source to ensure we have the latest bundled code
self.refresh_app_source()
# 2. Check and Install Dependencies
# We do this AFTER refreshing source so we have the latest requirements.txt
if not self.check_dependencies():
log("Dependencies missing or incomplete. Installing...")
self.install_packages()
# 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}")