Skip to content

Commit aa2ff16

Browse files
authored
Merge pull request #162 from thawn/copilot/fix-app-shutdown-macos
Add GUI status window with logs viewer for macOS app shutdown
2 parents 20e5d48 + 89b068b commit aa2ff16

File tree

4 files changed

+374
-6
lines changed

4 files changed

+374
-6
lines changed

docs/installation.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ Download pre-built executables from [releases page](https://github.com/thawn/ttm
2020
- 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))
2121
- **macOS**: Right-click `ttmp32gme.app` → "Open" (first time only to bypass Gatekeeper)
2222
- 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))
23+
- A status window will appear with:
24+
- Server URL and status
25+
- "Open Browser" button to launch the web interface
26+
- "Show Logs" button to view server logs
27+
- "Stop Server" button to shut down the application
28+
- Close the status window to shut down the server
2329
4. Open your browser to `http://localhost:10020`
2430

2531
The executable includes all necessary dependencies (tttool, ffmpeg) except Chrome/Chromium which should be installed separately.

src/ttmp32gme/gui_handler.py

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
"""GUI status window for macOS application."""
2+
3+
import logging
4+
import platform
5+
import sys
6+
import threading
7+
from typing import TYPE_CHECKING, Any, Callable
8+
9+
if TYPE_CHECKING:
10+
from flask import Flask
11+
12+
logger = logging.getLogger(__name__)
13+
14+
# Check if tkinter is available (it won't be in Docker/headless environments)
15+
_tkinter_available = True
16+
tk: Any = None
17+
ttk: Any = None
18+
try:
19+
import tkinter as tk # noqa: F811
20+
from tkinter import ttk # noqa: F811
21+
except ImportError:
22+
_tkinter_available = False
23+
logger.debug("tkinter not available - GUI will be disabled")
24+
25+
26+
class ServerStatusWindow:
27+
"""A simple status window for displaying server information and shutdown control.
28+
29+
This window is primarily used on macOS where .app bundles don't show console windows.
30+
It provides users with:
31+
- Server status information (URL, port)
32+
- A button to open the browser
33+
- A button to stop the server
34+
- Automatic shutdown when the window is closed
35+
"""
36+
37+
def __init__(self, host: str, port: int, shutdown_callback: Callable[[], None]):
38+
"""Initialize the status window.
39+
40+
Args:
41+
host: Server host address
42+
port: Server port number
43+
shutdown_callback: Function to call when server should be shut down
44+
"""
45+
if not _tkinter_available:
46+
raise RuntimeError("tkinter is not available - GUI cannot be used")
47+
48+
self.host = host
49+
self.port = port
50+
self.shutdown_callback = shutdown_callback
51+
self.root: Any = None
52+
self.is_running = False
53+
self.logs_window: Any = None
54+
self.logs_text: Any = None
55+
56+
def create_window(self) -> None:
57+
"""Create and configure the tkinter window."""
58+
self.root = tk.Tk() # type: ignore[union-attr]
59+
self.root.title("ttmp32gme Server") # type: ignore[union-attr]
60+
self.root.geometry("400x250") # type: ignore[union-attr]
61+
self.root.resizable(False, False) # type: ignore[union-attr]
62+
63+
# Handle window close event
64+
self.root.protocol("WM_DELETE_WINDOW", self.on_close) # type: ignore[union-attr]
65+
66+
# Create main frame with padding
67+
main_frame = ttk.Frame(self.root, padding="20") # type: ignore[union-attr]
68+
main_frame.grid(row=0, column=0, sticky="wens")
69+
70+
# Title label
71+
title_label = ttk.Label( # type: ignore[union-attr]
72+
main_frame, text="TipToi MP3 GME Converter", font=("Helvetica", 16, "bold")
73+
)
74+
title_label.grid(row=0, column=0, columnspan=2, pady=(0, 20))
75+
76+
# Server status section
77+
status_label = ttk.Label( # type: ignore[union-attr]
78+
main_frame, text="Server is running", font=("Helvetica", 12)
79+
)
80+
status_label.grid(row=1, column=0, columnspan=2, pady=(0, 10))
81+
82+
# URL display
83+
url = f"http://{self.host}:{self.port}/"
84+
url_frame = ttk.Frame(main_frame) # type: ignore[union-attr]
85+
url_frame.grid(row=2, column=0, columnspan=2, pady=(0, 20))
86+
87+
ttk.Label(url_frame, text="URL:").grid(row=0, column=0, padx=(0, 5)) # type: ignore[union-attr]
88+
url_entry = ttk.Entry(url_frame, width=30) # type: ignore[union-attr]
89+
url_entry.insert(0, url)
90+
url_entry.config(state="readonly")
91+
url_entry.grid(row=0, column=1)
92+
93+
# Buttons frame
94+
button_frame = ttk.Frame(main_frame) # type: ignore[union-attr]
95+
button_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0))
96+
97+
# Open Browser button
98+
open_button = ttk.Button( # type: ignore[union-attr]
99+
button_frame, text="Open Browser", command=self.open_browser
100+
)
101+
open_button.grid(row=0, column=0, padx=(0, 5))
102+
103+
# Show Logs button
104+
logs_button = ttk.Button(button_frame, text="Show Logs", command=self.show_logs) # type: ignore[union-attr]
105+
logs_button.grid(row=0, column=1, padx=(0, 5))
106+
107+
# Stop Server button
108+
stop_button = ttk.Button( # type: ignore[union-attr]
109+
button_frame, text="Stop Server", command=self.on_close
110+
)
111+
stop_button.grid(row=0, column=2)
112+
113+
# Info label at the bottom
114+
info_label = ttk.Label( # type: ignore[union-attr]
115+
main_frame,
116+
text="Close this window to stop the server",
117+
font=("Helvetica", 9),
118+
foreground="gray",
119+
)
120+
info_label.grid(row=4, column=0, columnspan=2, pady=(20, 0))
121+
122+
def open_browser(self) -> None:
123+
"""Open the default web browser to the application URL."""
124+
from ttmp32gme.build.file_handler import open_browser
125+
126+
open_browser(self.host, self.port)
127+
128+
def show_logs(self) -> None:
129+
"""Open a window showing server logs."""
130+
if self.logs_window and tk.Toplevel.winfo_exists(self.logs_window): # type: ignore[union-attr]
131+
# Window already exists, just raise it
132+
self.logs_window.lift() # type: ignore[union-attr]
133+
self.logs_window.focus_force() # type: ignore[union-attr]
134+
return
135+
136+
# Create logs window
137+
self.logs_window = tk.Toplevel(self.root) # type: ignore[union-attr]
138+
self.logs_window.title("Server Logs") # type: ignore[union-attr]
139+
self.logs_window.geometry("700x500") # type: ignore[union-attr]
140+
141+
# Create frame for logs
142+
logs_frame = ttk.Frame(self.logs_window, padding="10") # type: ignore[union-attr]
143+
logs_frame.grid(row=0, column=0, sticky="nsew")
144+
self.logs_window.grid_rowconfigure(0, weight=1) # type: ignore[union-attr]
145+
self.logs_window.grid_columnconfigure(0, weight=1) # type: ignore[union-attr]
146+
147+
# Create scrolled text widget for logs
148+
logs_text_frame = ttk.Frame(logs_frame) # type: ignore[union-attr]
149+
logs_text_frame.grid(row=0, column=0, sticky="nsew")
150+
logs_frame.grid_rowconfigure(0, weight=1)
151+
logs_frame.grid_columnconfigure(0, weight=1)
152+
153+
# Text widget with scrollbar
154+
scrollbar = ttk.Scrollbar(logs_text_frame) # type: ignore[union-attr]
155+
scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # type: ignore[union-attr]
156+
157+
self.logs_text = tk.Text( # type: ignore[union-attr]
158+
logs_text_frame,
159+
wrap=tk.WORD, # type: ignore[union-attr]
160+
yscrollcommand=scrollbar.set,
161+
font=("Courier", 10),
162+
bg="white",
163+
fg="black",
164+
)
165+
self.logs_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # type: ignore[union-attr]
166+
scrollbar.config(command=self.logs_text.yview) # type: ignore[union-attr]
167+
168+
# Buttons frame at bottom
169+
button_frame = ttk.Frame(logs_frame) # type: ignore[union-attr]
170+
button_frame.grid(row=1, column=0, pady=(10, 0))
171+
172+
# Refresh button
173+
refresh_button = ttk.Button( # type: ignore[union-attr]
174+
button_frame, text="Refresh", command=self.refresh_logs
175+
)
176+
refresh_button.pack(side=tk.LEFT, padx=(0, 5)) # type: ignore[union-attr]
177+
178+
# Clear button
179+
clear_button = ttk.Button( # type: ignore[union-attr]
180+
button_frame, text="Clear", command=self.clear_logs_display
181+
)
182+
clear_button.pack(side=tk.LEFT, padx=(0, 5)) # type: ignore[union-attr]
183+
184+
# Close button
185+
close_button = ttk.Button( # type: ignore[union-attr]
186+
button_frame, text="Close", command=self.logs_window.destroy # type: ignore[union-attr]
187+
)
188+
close_button.pack(side=tk.LEFT) # type: ignore[union-attr]
189+
190+
# Load initial logs
191+
self.refresh_logs()
192+
193+
def refresh_logs(self) -> None:
194+
"""Refresh the logs display with latest logs from the server."""
195+
if not self.logs_text:
196+
return
197+
198+
try:
199+
# Import here to avoid circular imports
200+
import json
201+
import urllib.request
202+
203+
# Fetch logs from the server
204+
url = f"http://{self.host}:{self.port}/logs?lines=500"
205+
with urllib.request.urlopen(url, timeout=5) as response:
206+
data = json.loads(response.read().decode())
207+
if data.get("success"):
208+
logs = data.get("logs", [])
209+
210+
# Clear current content
211+
self.logs_text.delete("1.0", tk.END)
212+
213+
# Insert logs
214+
if logs:
215+
self.logs_text.insert("1.0", "\n".join(logs))
216+
# Scroll to bottom
217+
self.logs_text.see(tk.END)
218+
else:
219+
self.logs_text.insert("1.0", "No logs available yet.")
220+
except Exception as e:
221+
if self.logs_text:
222+
self.logs_text.delete("1.0", tk.END)
223+
self.logs_text.insert("1.0", f"Error fetching logs: {e}")
224+
225+
def clear_logs_display(self) -> None:
226+
"""Clear the logs display."""
227+
if self.logs_text:
228+
self.logs_text.delete("1.0", tk.END)
229+
self.logs_text.insert("1.0", "Logs cleared. Click Refresh to reload.")
230+
231+
def on_close(self) -> None:
232+
"""Handle window close event and shut down the server."""
233+
logger.info("Shutting down server from GUI...")
234+
self.is_running = False
235+
if self.root:
236+
self.root.quit()
237+
self.root.destroy()
238+
# Call the shutdown callback to stop the server
239+
self.shutdown_callback()
240+
241+
def run(self) -> None:
242+
"""Start the GUI main loop."""
243+
self.is_running = True
244+
self.create_window()
245+
if self.root:
246+
self.root.mainloop()
247+
248+
249+
def should_use_gui() -> bool:
250+
"""Determine if the GUI window should be used.
251+
252+
Returns True if:
253+
- Running on macOS
254+
- Running from PyInstaller bundle
255+
- Not in development mode
256+
- tkinter is available
257+
258+
Returns:
259+
True if GUI should be used, False otherwise
260+
"""
261+
return (
262+
_tkinter_available
263+
and platform.system() == "Darwin"
264+
and getattr(sys, "frozen", False)
265+
)
266+
267+
268+
def run_server_with_gui(app: "Flask", host: str, port: int) -> None:
269+
"""Run the Flask server in a background thread with GUI control.
270+
271+
Args:
272+
app: Flask application instance
273+
host: Server host address
274+
port: Server port number
275+
"""
276+
try:
277+
from waitress import serve # type: ignore
278+
279+
logger.info(f"Starting server on {host}:{port} in background thread")
280+
281+
# Run waitress server (blocking call)
282+
# The daemon thread will be terminated when the main process exits
283+
serve(
284+
app,
285+
host=host,
286+
port=port,
287+
threads=8,
288+
channel_timeout=120,
289+
connection_limit=100,
290+
)
291+
except Exception as e:
292+
logger.error(f"Server error: {e}")
293+
finally:
294+
logger.info("Server thread stopped")
295+
296+
297+
def start_gui_server(
298+
app: "Flask", host: str, port: int, auto_open_browser: bool = True
299+
) -> None:
300+
"""Start the server with a GUI control window.
301+
302+
This is the main entry point for running the application with GUI support.
303+
304+
Args:
305+
app: Flask application instance
306+
host: Server host address
307+
port: Server port number
308+
auto_open_browser: Whether to automatically open the browser on start
309+
"""
310+
311+
def shutdown_callback():
312+
"""Callback to shut down the server."""
313+
logger.info("Shutdown callback triggered")
314+
# Exit the process to ensure clean shutdown
315+
import os
316+
317+
os._exit(0)
318+
319+
# Start server in background thread
320+
server_thread = threading.Thread(
321+
target=run_server_with_gui, args=(app, host, port), daemon=True
322+
)
323+
server_thread.start()
324+
325+
# Wait for server to start with health check
326+
import time
327+
import urllib.request
328+
329+
max_retries = 10
330+
for i in range(max_retries):
331+
try:
332+
urllib.request.urlopen(f"http://{host}:{port}/", timeout=1)
333+
logger.info("Server ready")
334+
break
335+
except Exception:
336+
if i == max_retries - 1:
337+
logger.warning("Server may not be ready yet, continuing anyway")
338+
time.sleep(0.3)
339+
340+
# Create and run GUI window
341+
status_window = ServerStatusWindow(host, port, shutdown_callback)
342+
343+
# Auto-open browser if requested
344+
if auto_open_browser:
345+
status_window.open_browser()
346+
347+
# Run GUI (blocking call)
348+
status_window.run()
349+
350+
# Clean exit after GUI closes
351+
logger.info("Application shutdown complete")

0 commit comments

Comments
 (0)