444 lines
16 KiB
Python
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}")
|