Files
whisper_voice/bootstrapper.py
Your Name 4b84a27a67 v1.0.1 Feature Update and Polish
Full Changelog:

[New Features]
- Added Native Translation Mode:
  - Whisper model now fully supports Translating any language to English
  - Added 'task' and 'language' parameters to Transcriber core
- Dual Hotkey Support:
  - Added separate Global Hotkeys for Transcribe (default F8) and Translate (default F10)
  - Both hotkeys are fully customizable in Settings
  - Engine dynamically switches modes based on which key is pressed

[UI/UX Improvements]
- Settings Window:
  - Widened Hotkey Input fields (240px) to accommodate long combinations
  - Added Pretty-Printing for hotkey sequences (e.g. 'ctrl+f9' display as 'Ctrl + F9')
  - Replaced Country Code dropdown with Full Language Names (99+ languages)
  - Made Language Dropdown scrollable (max height 300px) to prevent screen overflow
  - Removed redundant 'Task' selector (replaced by dedicated hotkeys)
- System Tray:
  - Tooltip now displays both Transcribe and Translate hotkeys
  - Tooltip hotkeys are formatted readably

[Core & Performance]
- Bootstrapper:
  - Implemented Smart Incremental Sync
  - Now checks filesize and content hash before copying files
  - Drastically reduces startup time for subsequent runs
  - Preserves user settings.json during updates
- Backend:
  - Fixed HotkeyManager to support dynamic configuration keys
  - Fixed Language Lock: selecting a language now correctly forces the model to use it
  - Refactored bridge/main connection for language list handling
2026-01-24 18:29:10 +02:00

395 lines
13 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"
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 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}")