Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ Download pre-built executables from [releases page](https://github.com/thawn/ttm
- If Windows SmartScreen blocks it: Click "More info" → "Run anyway" ([details](https://support.microsoft.com/en-us/windows/what-is-smartscreen-and-how-can-it-help-protect-me-1c9a874a-6826-be5e-45b1-67fa445a74c8))
- **macOS**: Right-click `ttmp32gme.app` → "Open" (first time only to bypass Gatekeeper)
- If blocked: System Settings → Privacy & Security → scroll down → "Open Anyway" ([details](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unidentified-developer-mh40616/mac))
- A status window will appear with:
- Server URL and status
- "Open Browser" button to launch the web interface
- "Show Logs" button to view server logs
- "Stop Server" button to shut down the application
- Close the status window to shut down the server
4. Open your browser to `http://localhost:10020`

The executable includes all necessary dependencies (tttool, ffmpeg) except Chrome/Chromium which should be installed separately.
Expand Down
351 changes: 351 additions & 0 deletions src/ttmp32gme/gui_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
"""GUI status window for macOS application."""

import logging
import platform
import sys
import threading
from typing import TYPE_CHECKING, Any, Callable

if TYPE_CHECKING:
from flask import Flask

logger = logging.getLogger(__name__)

# Check if tkinter is available (it won't be in Docker/headless environments)
_tkinter_available = True
tk: Any = None
ttk: Any = None
try:
import tkinter as tk # noqa: F811
from tkinter import ttk # noqa: F811
except ImportError:
_tkinter_available = False
logger.debug("tkinter not available - GUI will be disabled")


class ServerStatusWindow:
"""A simple status window for displaying server information and shutdown control.

This window is primarily used on macOS where .app bundles don't show console windows.
It provides users with:
- Server status information (URL, port)
- A button to open the browser
- A button to stop the server
- Automatic shutdown when the window is closed
"""

def __init__(self, host: str, port: int, shutdown_callback: Callable[[], None]):
"""Initialize the status window.

Args:
host: Server host address
port: Server port number
shutdown_callback: Function to call when server should be shut down
"""
if not _tkinter_available:
raise RuntimeError("tkinter is not available - GUI cannot be used")

self.host = host
self.port = port
self.shutdown_callback = shutdown_callback
self.root: Any = None
self.is_running = False
self.logs_window: Any = None
self.logs_text: Any = None

def create_window(self) -> None:
"""Create and configure the tkinter window."""
self.root = tk.Tk() # type: ignore[union-attr]
self.root.title("ttmp32gme Server") # type: ignore[union-attr]
self.root.geometry("400x250") # type: ignore[union-attr]
self.root.resizable(False, False) # type: ignore[union-attr]

# Handle window close event
self.root.protocol("WM_DELETE_WINDOW", self.on_close) # type: ignore[union-attr]

# Create main frame with padding
main_frame = ttk.Frame(self.root, padding="20") # type: ignore[union-attr]
main_frame.grid(row=0, column=0, sticky="wens")

# Title label
title_label = ttk.Label( # type: ignore[union-attr]
main_frame, text="TipToi MP3 GME Converter", font=("Helvetica", 16, "bold")
)
title_label.grid(row=0, column=0, columnspan=2, pady=(0, 20))

# Server status section
status_label = ttk.Label( # type: ignore[union-attr]
main_frame, text="Server is running", font=("Helvetica", 12)
)
status_label.grid(row=1, column=0, columnspan=2, pady=(0, 10))

# URL display
url = f"http://{self.host}:{self.port}/"
url_frame = ttk.Frame(main_frame) # type: ignore[union-attr]
url_frame.grid(row=2, column=0, columnspan=2, pady=(0, 20))

ttk.Label(url_frame, text="URL:").grid(row=0, column=0, padx=(0, 5)) # type: ignore[union-attr]
url_entry = ttk.Entry(url_frame, width=30) # type: ignore[union-attr]
url_entry.insert(0, url)
url_entry.config(state="readonly")
url_entry.grid(row=0, column=1)

# Buttons frame
button_frame = ttk.Frame(main_frame) # type: ignore[union-attr]
button_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0))

# Open Browser button
open_button = ttk.Button( # type: ignore[union-attr]
button_frame, text="Open Browser", command=self.open_browser
)
open_button.grid(row=0, column=0, padx=(0, 5))

# Show Logs button
logs_button = ttk.Button(button_frame, text="Show Logs", command=self.show_logs) # type: ignore[union-attr]
logs_button.grid(row=0, column=1, padx=(0, 5))

# Stop Server button
stop_button = ttk.Button( # type: ignore[union-attr]
button_frame, text="Stop Server", command=self.on_close
)
stop_button.grid(row=0, column=2)

# Info label at the bottom
info_label = ttk.Label( # type: ignore[union-attr]
main_frame,
text="Close this window to stop the server",
font=("Helvetica", 9),
foreground="gray",
)
info_label.grid(row=4, column=0, columnspan=2, pady=(20, 0))

def open_browser(self) -> None:
"""Open the default web browser to the application URL."""
from ttmp32gme.build.file_handler import open_browser

open_browser(self.host, self.port)

def show_logs(self) -> None:
"""Open a window showing server logs."""
if self.logs_window and tk.Toplevel.winfo_exists(self.logs_window): # type: ignore[union-attr]
# Window already exists, just raise it
self.logs_window.lift() # type: ignore[union-attr]
self.logs_window.focus_force() # type: ignore[union-attr]
return

# Create logs window
self.logs_window = tk.Toplevel(self.root) # type: ignore[union-attr]
self.logs_window.title("Server Logs") # type: ignore[union-attr]
self.logs_window.geometry("700x500") # type: ignore[union-attr]

# Create frame for logs
logs_frame = ttk.Frame(self.logs_window, padding="10") # type: ignore[union-attr]
logs_frame.grid(row=0, column=0, sticky="nsew")
self.logs_window.grid_rowconfigure(0, weight=1) # type: ignore[union-attr]
self.logs_window.grid_columnconfigure(0, weight=1) # type: ignore[union-attr]

# Create scrolled text widget for logs
logs_text_frame = ttk.Frame(logs_frame) # type: ignore[union-attr]
logs_text_frame.grid(row=0, column=0, sticky="nsew")
logs_frame.grid_rowconfigure(0, weight=1)
logs_frame.grid_columnconfigure(0, weight=1)

# Text widget with scrollbar
scrollbar = ttk.Scrollbar(logs_text_frame) # type: ignore[union-attr]
scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # type: ignore[union-attr]

self.logs_text = tk.Text( # type: ignore[union-attr]
logs_text_frame,
wrap=tk.WORD, # type: ignore[union-attr]
yscrollcommand=scrollbar.set,
font=("Courier", 10),
bg="white",
fg="black",
)
self.logs_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # type: ignore[union-attr]
scrollbar.config(command=self.logs_text.yview) # type: ignore[union-attr]

# Buttons frame at bottom
button_frame = ttk.Frame(logs_frame) # type: ignore[union-attr]
button_frame.grid(row=1, column=0, pady=(10, 0))

# Refresh button
refresh_button = ttk.Button( # type: ignore[union-attr]
button_frame, text="Refresh", command=self.refresh_logs
)
refresh_button.pack(side=tk.LEFT, padx=(0, 5)) # type: ignore[union-attr]

# Clear button
clear_button = ttk.Button( # type: ignore[union-attr]
button_frame, text="Clear", command=self.clear_logs_display
)
clear_button.pack(side=tk.LEFT, padx=(0, 5)) # type: ignore[union-attr]

# Close button
close_button = ttk.Button( # type: ignore[union-attr]
button_frame, text="Close", command=self.logs_window.destroy # type: ignore[union-attr]
)
close_button.pack(side=tk.LEFT) # type: ignore[union-attr]

# Load initial logs
self.refresh_logs()

def refresh_logs(self) -> None:
"""Refresh the logs display with latest logs from the server."""
if not self.logs_text:
return

try:
# Import here to avoid circular imports
import json
import urllib.request

# Fetch logs from the server
url = f"http://{self.host}:{self.port}/logs?lines=500"
with urllib.request.urlopen(url, timeout=5) as response:
data = json.loads(response.read().decode())
if data.get("success"):
logs = data.get("logs", [])

# Clear current content
self.logs_text.delete("1.0", tk.END)

# Insert logs
if logs:
self.logs_text.insert("1.0", "\n".join(logs))
# Scroll to bottom
self.logs_text.see(tk.END)
else:
self.logs_text.insert("1.0", "No logs available yet.")
except Exception as e:
if self.logs_text:
self.logs_text.delete("1.0", tk.END)
self.logs_text.insert("1.0", f"Error fetching logs: {e}")

def clear_logs_display(self) -> None:
"""Clear the logs display."""
if self.logs_text:
self.logs_text.delete("1.0", tk.END)
self.logs_text.insert("1.0", "Logs cleared. Click Refresh to reload.")

def on_close(self) -> None:
"""Handle window close event and shut down the server."""
logger.info("Shutting down server from GUI...")
self.is_running = False
if self.root:
self.root.quit()
self.root.destroy()
# Call the shutdown callback to stop the server
self.shutdown_callback()

def run(self) -> None:
"""Start the GUI main loop."""
self.is_running = True
self.create_window()
if self.root:
self.root.mainloop()


def should_use_gui() -> bool:
"""Determine if the GUI window should be used.

Returns True if:
- Running on macOS
- Running from PyInstaller bundle
- Not in development mode
- tkinter is available

Returns:
True if GUI should be used, False otherwise
"""
return (
_tkinter_available
and platform.system() == "Darwin"
and getattr(sys, "frozen", False)
)


def run_server_with_gui(app: "Flask", host: str, port: int) -> None:
"""Run the Flask server in a background thread with GUI control.

Args:
app: Flask application instance
host: Server host address
port: Server port number
"""
try:
from waitress import serve # type: ignore

logger.info(f"Starting server on {host}:{port} in background thread")

# Run waitress server (blocking call)
# The daemon thread will be terminated when the main process exits
serve(
app,
host=host,
port=port,
threads=8,
channel_timeout=120,
connection_limit=100,
)
except Exception as e:
logger.error(f"Server error: {e}")
finally:
logger.info("Server thread stopped")


def start_gui_server(
app: "Flask", host: str, port: int, auto_open_browser: bool = True
) -> None:
"""Start the server with a GUI control window.

This is the main entry point for running the application with GUI support.

Args:
app: Flask application instance
host: Server host address
port: Server port number
auto_open_browser: Whether to automatically open the browser on start
"""

def shutdown_callback():
"""Callback to shut down the server."""
logger.info("Shutdown callback triggered")
# Exit the process to ensure clean shutdown
import os

os._exit(0)

# Start server in background thread
server_thread = threading.Thread(
target=run_server_with_gui, args=(app, host, port), daemon=True
)
server_thread.start()

# Wait for server to start with health check
import time
import urllib.request

max_retries = 10
for i in range(max_retries):
try:
urllib.request.urlopen(f"http://{host}:{port}/", timeout=1)
logger.info("Server ready")
break
except Exception:
if i == max_retries - 1:
logger.warning("Server may not be ready yet, continuing anyway")
time.sleep(0.3)

# Create and run GUI window
status_window = ServerStatusWindow(host, port, shutdown_callback)

# Auto-open browser if requested
if auto_open_browser:
status_window.open_browser()

# Run GUI (blocking call)
status_window.run()

# Clean exit after GUI closes
logger.info("Application shutdown complete")
Loading