Initial commit of WhisperVoice

This commit is contained in:
Your Name
2026-01-24 17:03:52 +02:00
commit 9ff0e8d108
118 changed files with 6102 additions and 0 deletions

370
bootstrapper.py Normal file
View 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}")