Skip to content

Commit d34abe8

Browse files
authored
Merge pull request #6 from audiohacking/copilot/use-pywebview-for-osx-app
Replace browser launch with pywebview native window for macOS app with graceful shutdown
2 parents d81e90d + cd9cff8 commit d34abe8

5 files changed

Lines changed: 150 additions & 25 deletions

File tree

HeartMuLa.spec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ hiddenimports += collect_submodules('heartlib')
3838
hiddenimports += collect_submodules('transformers')
3939
hiddenimports += collect_submodules('torch')
4040
hiddenimports += collect_submodules('torchaudio')
41+
hiddenimports += collect_submodules('webview') # pywebview
4142
hiddenimports += ['uvicorn.logging', 'uvicorn.loops', 'uvicorn.loops.auto', 'uvicorn.protocols',
4243
'uvicorn.protocols.http', 'uvicorn.protocols.http.auto', 'uvicorn.protocols.websockets',
4344
'uvicorn.protocols.websockets.auto', 'uvicorn.lifespan', 'uvicorn.lifespan.on',

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ Open http://localhost:5173
154154

155155
## macOS App (Beta)
156156

157-
HeartMuLa Studio is available as a standalone macOS application optimized for Apple Metal GPUs.
157+
HeartMuLa Studio is available as a standalone macOS application with a native app window, optimized for Apple Metal GPUs.
158158

159159
### Download
160160

@@ -193,9 +193,10 @@ This ensures:
193193
### Features
194194

195195
- **Standalone App**: No Python or Node.js installation required
196+
- **Native Window**: Uses pywebview for a native macOS app experience (single instance only)
196197
- **Apple Metal GPU**: Optimized for M1/M2/M3 and Intel Macs with Metal support
197198
- **Auto-Download**: Models are automatically downloaded on first launch (~5GB)
198-
- **Native macOS**: Code-signed and packaged with PyInstaller
199+
- **Code-Signed**: Packaged with PyInstaller and ad-hoc code signing
199200

200201
### System Requirements
201202

build/macos/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ build/macos/
2222
- Node.js 18+
2323
- Homebrew (for icon generation tools)
2424

25+
**Note**: The app uses pywebview for native window rendering. This is automatically included in the build.
26+
2527
### Automated Build Script
2628

2729
For the easiest local build experience, use the provided build script:

launcher.py

Lines changed: 142 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,22 @@
88
import os
99
import sys
1010
import subprocess
11-
import webbrowser
1211
import time
1312
import shutil
1413
import threading
1514
from pathlib import Path
15+
import socket
16+
import urllib.request
17+
import urllib.error
18+
import atexit
19+
20+
# Single instance lock port
21+
SINGLE_INSTANCE_PORT = 58765
22+
23+
# Global references for cleanup
24+
_server_thread = None
25+
_lock_socket = None
26+
_cleanup_done = False
1627

1728
def setup_environment():
1829
"""Set up the macOS app environment."""
@@ -58,41 +69,151 @@ def setup_environment():
5869

5970
return app_dir, logs_dir
6071

72+
def check_single_instance():
73+
"""Check if another instance of the app is already running."""
74+
# Try to bind to a port to ensure single instance
75+
try:
76+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
77+
sock.bind(('127.0.0.1', SINGLE_INSTANCE_PORT))
78+
return sock # Keep socket open to maintain lock
79+
except OSError:
80+
# Port is already in use - another instance is running
81+
return None
82+
83+
def cleanup():
84+
"""Cleanup resources on shutdown. Idempotent - safe to call multiple times."""
85+
global _lock_socket, _cleanup_done
86+
87+
# Prevent multiple cleanup calls
88+
if _cleanup_done:
89+
return
90+
_cleanup_done = True
91+
92+
print("\nCleaning up resources...")
93+
94+
# Close the lock socket
95+
if _lock_socket:
96+
try:
97+
_lock_socket.close()
98+
print("✓ Released instance lock")
99+
except (OSError, Exception):
100+
pass
101+
102+
# Note: Server thread is daemon and will be terminated automatically
103+
print("✓ Server shutdown initiated")
104+
print("Goodbye!")
105+
106+
def wait_for_server(url='http://127.0.0.1:8000/health', timeout=30):
107+
"""Wait for the server to be ready by polling the health endpoint."""
108+
print("Waiting for server to start...")
109+
start_time = time.time()
110+
while time.time() - start_time < timeout:
111+
try:
112+
response = urllib.request.urlopen(url, timeout=1)
113+
if response.getcode() == 200:
114+
print("Server is ready!")
115+
return True
116+
except (urllib.error.URLError, ConnectionError, OSError):
117+
# Server not ready yet, wait a bit
118+
time.sleep(0.5)
119+
print(f"Warning: Server did not respond within {timeout} seconds")
120+
return False
121+
61122
def launch_server(app_dir, logs_dir):
62123
"""Launch the FastAPI server."""
124+
global _server_thread
125+
63126
# Import and run the FastAPI app
64127
sys.path.insert(0, str(app_dir))
65128

66129
# Configure uvicorn to run the app
67130
import uvicorn
68131
from backend.app.main import app
69132

70-
# Open browser after a short delay
71-
def open_browser():
72-
time.sleep(3)
73-
webbrowser.open("http://localhost:8000")
74-
75-
browser_thread = threading.Thread(target=open_browser, daemon=True)
76-
browser_thread.start()
77-
78-
# Run the server
79-
print(f"Starting HeartMuLa Studio server...")
80-
print(f"Logs directory: {logs_dir}")
81-
print(f"Opening browser at http://localhost:8000")
82-
83-
uvicorn.run(
84-
app,
85-
host="127.0.0.1",
86-
port=8000,
87-
log_level="info",
88-
access_log=True
89-
)
133+
# Run the server in a thread so pywebview can take control of main thread
134+
def run_server():
135+
print(f"Starting HeartMuLa Studio server...")
136+
print(f"Logs directory: {logs_dir}")
137+
138+
uvicorn.run(
139+
app,
140+
host="127.0.0.1",
141+
port=8000,
142+
log_level="info",
143+
access_log=True
144+
)
145+
146+
_server_thread = threading.Thread(target=run_server, daemon=True)
147+
_server_thread.start()
148+
149+
# Wait for server to be ready
150+
wait_for_server()
151+
152+
# Launch pywebview window
153+
try:
154+
import webview
155+
print("Opening HeartMuLa Studio window...")
156+
157+
# Create window with custom settings - window object not needed
158+
webview.create_window(
159+
'HeartMuLa Studio',
160+
'http://127.0.0.1:8000',
161+
width=1400,
162+
height=900,
163+
resizable=True,
164+
fullscreen=False,
165+
min_size=(800, 600),
166+
background_color='#1a1a1a',
167+
text_select=True,
168+
on_top=False, # Keep in foreground but not always on top
169+
focus=True # Get focus on creation
170+
)
171+
172+
# Register cleanup handler for graceful shutdown
173+
atexit.register(cleanup)
174+
175+
# Start the webview - this blocks until window is closed
176+
# When window closes, this returns and the program continues to exit
177+
webview.start(gui='cocoa') # Explicitly use Cocoa for macOS
178+
179+
# Window has been closed by user
180+
print("\nWindow closed by user")
181+
182+
except ImportError:
183+
print("Warning: pywebview not available, falling back to browser")
184+
import webbrowser
185+
webbrowser.open("http://127.0.0.1:8000")
186+
# Keep the server running
187+
while True:
188+
time.sleep(1)
189+
except Exception as e:
190+
print(f"Error launching window: {e}")
191+
print("Falling back to browser...")
192+
import webbrowser
193+
webbrowser.open("http://127.0.0.1:8000")
194+
# Keep the server running
195+
while True:
196+
time.sleep(1)
90197

91198
def main():
92199
"""Main entry point."""
200+
global _lock_socket
201+
93202
try:
203+
# Check if another instance is already running
204+
_lock_socket = check_single_instance()
205+
if _lock_socket is None:
206+
print("Another instance of HeartMuLa Studio is already running.")
207+
print("Only one instance can be opened at a time.")
208+
sys.exit(0)
209+
94210
app_dir, logs_dir = setup_environment()
95211
launch_server(app_dir, logs_dir)
212+
213+
# If we reach here, the window was closed gracefully
214+
# cleanup() will be called by atexit
215+
sys.exit(0)
216+
96217
except KeyboardInterrupt:
97218
print("\nShutting down HeartMuLa Studio...")
98219
sys.exit(0)

requirements_macos.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,5 @@ triton>=2.0.0; platform_machine != "arm64"
4545
# PyInstaller for macOS App Bundle
4646
pyinstaller>=6.0,<7.0
4747

48-
# PyWebView for native macOS UI (optional, for future native UI - not currently used)
49-
# pywebview>=4.0
48+
# PyWebView for native macOS UI
49+
pywebview>=4.0

0 commit comments

Comments
 (0)