Initial commit of WhisperVoice
This commit is contained in:
370
bootstrapper.py
Normal file
370
bootstrapper.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
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}")
|
||||
Reference in New Issue
Block a user