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
1 change: 1 addition & 0 deletions HeartMuLa.spec
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ hiddenimports += collect_submodules('heartlib')
hiddenimports += collect_submodules('transformers')
hiddenimports += collect_submodules('torch')
hiddenimports += collect_submodules('torchaudio')
hiddenimports += collect_submodules('webview') # pywebview
hiddenimports += ['uvicorn.logging', 'uvicorn.loops', 'uvicorn.loops.auto', 'uvicorn.protocols',
'uvicorn.protocols.http', 'uvicorn.protocols.http.auto', 'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto', 'uvicorn.lifespan', 'uvicorn.lifespan.on',
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ Open http://localhost:5173

## macOS App (Beta)

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

### Download

Expand Down Expand Up @@ -193,9 +193,10 @@ This ensures:
### Features

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

### System Requirements

Expand Down
2 changes: 2 additions & 0 deletions build/macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ build/macos/
- Node.js 18+
- Homebrew (for icon generation tools)

**Note**: The app uses pywebview for native window rendering. This is automatically included in the build.

### Automated Build Script

For the easiest local build experience, use the provided build script:
Expand Down
163 changes: 142 additions & 21 deletions launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,22 @@
import os
import sys
import subprocess
import webbrowser
import time
import shutil
import threading
from pathlib import Path
import socket
import urllib.request
import urllib.error
import atexit

# Single instance lock port
SINGLE_INSTANCE_PORT = 58765

# Global references for cleanup
_server_thread = None
_lock_socket = None
_cleanup_done = False

def setup_environment():
"""Set up the macOS app environment."""
Expand Down Expand Up @@ -58,41 +69,151 @@ def setup_environment():

return app_dir, logs_dir

def check_single_instance():
"""Check if another instance of the app is already running."""
# Try to bind to a port to ensure single instance
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('127.0.0.1', SINGLE_INSTANCE_PORT))
return sock # Keep socket open to maintain lock
except OSError:
# Port is already in use - another instance is running
return None

def cleanup():
"""Cleanup resources on shutdown. Idempotent - safe to call multiple times."""
global _lock_socket, _cleanup_done

# Prevent multiple cleanup calls
if _cleanup_done:
return
_cleanup_done = True

print("\nCleaning up resources...")

# Close the lock socket
if _lock_socket:
try:
_lock_socket.close()
print("✓ Released instance lock")
except (OSError, Exception):
pass

# Note: Server thread is daemon and will be terminated automatically
print("✓ Server shutdown initiated")
print("Goodbye!")

def wait_for_server(url='http://127.0.0.1:8000/health', timeout=30):
"""Wait for the server to be ready by polling the health endpoint."""
print("Waiting for server to start...")
start_time = time.time()
while time.time() - start_time < timeout:
try:
response = urllib.request.urlopen(url, timeout=1)
if response.getcode() == 200:
print("Server is ready!")
return True
except (urllib.error.URLError, ConnectionError, OSError):
# Server not ready yet, wait a bit
time.sleep(0.5)
print(f"Warning: Server did not respond within {timeout} seconds")
return False

def launch_server(app_dir, logs_dir):
"""Launch the FastAPI server."""
global _server_thread

# Import and run the FastAPI app
sys.path.insert(0, str(app_dir))

# Configure uvicorn to run the app
import uvicorn
from backend.app.main import app

# Open browser after a short delay
def open_browser():
time.sleep(3)
webbrowser.open("http://localhost:8000")

browser_thread = threading.Thread(target=open_browser, daemon=True)
browser_thread.start()

# Run the server
print(f"Starting HeartMuLa Studio server...")
print(f"Logs directory: {logs_dir}")
print(f"Opening browser at http://localhost:8000")

uvicorn.run(
app,
host="127.0.0.1",
port=8000,
log_level="info",
access_log=True
)
# Run the server in a thread so pywebview can take control of main thread
def run_server():
print(f"Starting HeartMuLa Studio server...")
print(f"Logs directory: {logs_dir}")

uvicorn.run(
app,
host="127.0.0.1",
port=8000,
log_level="info",
access_log=True
)

_server_thread = threading.Thread(target=run_server, daemon=True)
_server_thread.start()

# Wait for server to be ready
wait_for_server()

# Launch pywebview window
try:
import webview
print("Opening HeartMuLa Studio window...")

# Create window with custom settings - window object not needed
webview.create_window(
'HeartMuLa Studio',
'http://127.0.0.1:8000',
width=1400,
height=900,
resizable=True,
fullscreen=False,
min_size=(800, 600),
background_color='#1a1a1a',
text_select=True,
on_top=False, # Keep in foreground but not always on top
focus=True # Get focus on creation
)

# Register cleanup handler for graceful shutdown
atexit.register(cleanup)

# Start the webview - this blocks until window is closed
# When window closes, this returns and the program continues to exit
webview.start(gui='cocoa') # Explicitly use Cocoa for macOS

# Window has been closed by user
print("\nWindow closed by user")

except ImportError:
print("Warning: pywebview not available, falling back to browser")
import webbrowser
webbrowser.open("http://127.0.0.1:8000")
# Keep the server running
while True:
time.sleep(1)
except Exception as e:
print(f"Error launching window: {e}")
print("Falling back to browser...")
import webbrowser
webbrowser.open("http://127.0.0.1:8000")
# Keep the server running
while True:
time.sleep(1)

def main():
"""Main entry point."""
global _lock_socket

try:
# Check if another instance is already running
_lock_socket = check_single_instance()
if _lock_socket is None:
print("Another instance of HeartMuLa Studio is already running.")
print("Only one instance can be opened at a time.")
sys.exit(0)

app_dir, logs_dir = setup_environment()
launch_server(app_dir, logs_dir)

# If we reach here, the window was closed gracefully
# cleanup() will be called by atexit
sys.exit(0)

except KeyboardInterrupt:
print("\nShutting down HeartMuLa Studio...")
sys.exit(0)
Expand Down
4 changes: 2 additions & 2 deletions requirements_macos.txt
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,5 @@ triton>=2.0.0; platform_machine != "arm64"
# PyInstaller for macOS App Bundle
pyinstaller>=6.0,<7.0

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