Skip to content

Commit 191e601

Browse files
Ecursoragent
authored andcommitted
macOS app: PyInstaller bundle, Application Support paths, loading UI, fixes
- Build: align local_build.sh with CI, verify deps, icon generation (Pillow) - PyInstaller: bundle groovy, gradio, torch; hook torch bin/ and datas - App paths: app_paths.py for App Support/logs; use in core, rvc, tabs - Launcher: single instance, loading page then Gradio redirect, TeeOutput.isatty() - Prerequisites: offline-safe; restart in bundle shows manual-restart message - Console tab: gr.Info + single output for clear; gr.Timer for auto-refresh Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3e15548 commit 191e601

26 files changed

Lines changed: 289 additions & 124 deletions

.github/workflows/build-macos-release.yml

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,24 @@ jobs:
5151
- name: Install Python dependencies
5252
run: |
5353
python -m pip install --upgrade pip
54-
pip install -r requirements_macos.txt
55-
# Verify critical dependencies for PyInstaller
56-
pip show webrtcvad-wheels || echo "Warning: webrtcvad-wheels not installed"
57-
pip show pyinstaller || echo "Warning: pyinstaller not installed"
54+
python -m pip install -r requirements_macos.txt
55+
# Verify critical dependencies (same checks as local_build.sh; fail early on missing packages)
56+
MISSING=""
57+
for pkg in webrtcvad-wheels pyinstaller torch gradio pywebview; do
58+
if ! python -m pip show "$pkg" &>/dev/null; then
59+
MISSING="${MISSING} ${pkg}"
60+
fi
61+
done
62+
if [ -n "$MISSING" ]; then
63+
echo "::error::Missing package(s):$MISSING"
64+
exit 1
65+
fi
66+
echo "Critical packages present (webrtcvad-wheels, pyinstaller, torch, gradio, pywebview)"
5867
5968
- name: Generate app icon
6069
run: |
6170
chmod +x build/macos/generate_icon.sh
62-
./build/macos/generate_icon.sh
71+
PYTHON=python ./build/macos/generate_icon.sh
6372
6473
- name: Check build/macos assets (icon, codesign, DMG README)
6574
run: |

Applio.spec

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ app_name = 'Applio'
1313
datas = []
1414
datas += collect_data_files('gradio')
1515
datas += collect_data_files('gradio_client')
16+
# Gradio reads .py source files at runtime (e.g. component_meta.create_or_modify_pyi);
17+
# include full package tree so Path(__file__).parent / "*.py" exists in the bundle
18+
try:
19+
import gradio as _gr
20+
datas += [(os.path.dirname(_gr.__file__), 'gradio')]
21+
except ImportError:
22+
pass
23+
datas += collect_data_files('groovy') # gradio dependency; needs version.txt at runtime
24+
# Torch uses inspect.getsource() at import; include package tree so source is available
25+
try:
26+
import torch as _torch
27+
datas += [(os.path.dirname(_torch.__file__), 'torch')]
28+
except ImportError:
29+
pass
1630
datas += collect_data_files('safehttpx')
1731
datas += collect_data_files('transformers')
1832
datas += collect_data_files('fairseq')
@@ -32,6 +46,7 @@ datas += [('app.py', '.')]
3246
hiddenimports = []
3347
hiddenimports += collect_submodules('gradio')
3448
hiddenimports += collect_submodules('gradio_client')
49+
hiddenimports += collect_submodules('groovy')
3550
hiddenimports += collect_submodules('uvicorn')
3651
hiddenimports += collect_submodules('transformers')
3752
hiddenimports += collect_submodules('fairseq')
@@ -57,6 +72,7 @@ hiddenimports += [
5772
'uvicorn.protocols.websockets.auto',
5873
'uvicorn.lifespan',
5974
'uvicorn.lifespan.on',
75+
'app_paths',
6076
'rvc.lib.platform',
6177
'rvc.lib.zluda',
6278
'assets.i18n.i18n',

app.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,17 @@
3939
from tabs.realtime.realtime import realtime_tab
4040
from tabs.console.console import console_tab
4141

42-
# Run prerequisites
42+
# Run prerequisites (non-blocking: if offline/unreachable, app still starts; user can retry from Train tab)
4343
from core import run_prerequisites_script
4444

45-
run_prerequisites_script(
46-
pretraineds_hifigan=True,
47-
models=True,
48-
exe=True,
49-
)
45+
try:
46+
run_prerequisites_script(
47+
pretraineds_hifigan=True,
48+
models=True,
49+
exe=True,
50+
)
51+
except Exception as e:
52+
print(f"Prerequisites check/download skipped (will retry when online): {e}")
5053

5154
# Initialize i18n
5255
from assets.i18n.i18n import I18nAuto

app_paths.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
Central definition of user data root for Applio.
3+
All downloaded models, trained models, and reusable data should live under
4+
this directory so they persist across app updates and rebuilds.
5+
6+
- macOS (bundled): ~/Library/Application Support/Applio
7+
- Otherwise: APPLIO_APP_SUPPORT env var, or current working directory (dev/script)
8+
"""
9+
import os
10+
11+
12+
def get_app_support_dir():
13+
"""
14+
Return the root directory for user data (models, downloads, logs, etc.).
15+
When running as the macOS app, the launcher sets APPLIO_APP_SUPPORT to
16+
~/Library/Application Support/Applio so data is reused across builds/versions.
17+
"""
18+
return os.environ.get("APPLIO_APP_SUPPORT", os.getcwd())
19+
20+
21+
def get_models_dir():
22+
"""Trained models (RVC .pth, etc.) - e.g. AppSupport/logs."""
23+
return os.path.join(get_app_support_dir(), "logs")
24+
25+
26+
def get_rvc_models_dir():
27+
"""Pretraineds, embedders, predictors - e.g. AppSupport/rvc/models."""
28+
return os.path.join(get_app_support_dir(), "rvc", "models")

build/macos/generate_icon.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ else
4646
fi
4747

4848
# Fallback: Generate icon using Python if conversion failed
49+
# Use PYTHON if set (e.g. by local_build.sh) so the same interpreter with deps is used
4950
if [ ! -f "$TEMP_PNG" ]; then
5051
echo "⚠ Icon conversion failed, generating fallback icon..."
51-
python3 build/macos/generate_fallback_icon.py "$TEMP_PNG"
52+
"${PYTHON:-python3}" build/macos/generate_fallback_icon.py "$TEMP_PNG"
5253
fi
5354

5455
# Verify temp icon exists

build/macos/hooks/hook-torch.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# PyInstaller hook for torch with Apple MPS support
22
# This ensures torch MPS backend and necessary libraries are included
33

4+
import os
45
from PyInstaller.utils.hooks import collect_submodules, collect_dynamic_libs
56

67
# Collect all torch submodules including MPS backend
@@ -16,3 +17,12 @@
1617

1718
# Collect dynamic libraries
1819
binaries = collect_dynamic_libs('torch')
20+
21+
# torch.__init__ looks for torch/bin/torch_shm_manager at runtime; include it
22+
_torch_dir = os.path.dirname(__import__('torch').__file__)
23+
_torch_bin = os.path.join(_torch_dir, 'bin')
24+
if os.path.isdir(_torch_bin):
25+
for name in os.listdir(_torch_bin):
26+
path = os.path.join(_torch_bin, name)
27+
if os.path.isfile(path):
28+
binaries.append((path, 'torch/bin'))

core.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
now_dir = os.getcwd()
1010
sys.path.append(now_dir)
1111

12+
from app_paths import get_app_support_dir
13+
1214
current_script_directory = os.path.dirname(os.path.realpath(__file__))
13-
logs_path = os.path.join(current_script_directory, "logs")
15+
logs_path = os.path.join(get_app_support_dir(), "logs")
1416

1517
from rvc.lib.tools.prerequisites_download import prequisites_download_pipeline
1618
from rvc.train.process.model_blender import model_blender

launcher.py

Lines changed: 66 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,14 @@ def setup_environment():
4141
app_support_dir = home_dir / "Library" / "Application Support" / "Applio"
4242
logs_dir = home_dir / "Library" / "Logs" / "Applio"
4343

44-
# Create directories
44+
# Create directories (user data root so models/data persist across builds/versions)
45+
(app_support_dir / "logs").mkdir(parents=True, exist_ok=True)
4546
for directory in [app_support_dir, logs_dir]:
4647
directory.mkdir(parents=True, exist_ok=True)
4748
print(f"Created/verified directory: {directory}")
4849

50+
os.environ["APPLIO_APP_SUPPORT"] = str(app_support_dir)
51+
4952
# IMPORTANT: Set environment variables for Apple MPS optimization
5053
# These settings optimize for Apple's unified memory architecture
5154
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" # Enable Metal Performance Shaders with CPU fallback
@@ -116,21 +119,46 @@ def cleanup():
116119
print("Goodbye!")
117120

118121
def wait_for_server(url='http://127.0.0.1:6969', timeout=30):
119-
"""Wait for the server to be ready by polling the endpoint."""
120-
print("Waiting for Applio server to start...")
122+
"""Wait for the server to be ready by polling. Returns True when ready."""
121123
start_time = time.time()
122124
while time.time() - start_time < timeout:
123125
try:
124126
response = urllib.request.urlopen(url, timeout=1)
125127
if response.getcode() == 200:
126-
print("Applio server is ready!")
127128
return True
128129
except (urllib.error.URLError, ConnectionError, OSError):
129-
# Server not ready yet, wait a bit
130130
time.sleep(0.5)
131-
print(f"Warning: Server did not respond within {timeout} seconds")
132131
return False
133132

133+
# Shown immediately so user sees progress; we redirect to the real UI when server is up (no second instance).
134+
LOADING_HTML = '''<!DOCTYPE html>
135+
<html>
136+
<head>
137+
<meta charset="utf-8">
138+
<meta name="viewport" content="width=device-width, initial-scale=1">
139+
<title>Applio - Starting</title>
140+
<style>
141+
* { box-sizing: border-box; }
142+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a1a; color: #e5e5e5; margin: 0; padding: 2rem; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
143+
.box { max-width: 520px; text-align: center; }
144+
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
145+
p { color: #a3a3a3; line-height: 1.6; margin: 0.5rem 0; }
146+
.spinner { width: 40px; height: 40px; border: 3px solid #333; border-top-color: #0ea5e9; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 1.5rem; }
147+
@keyframes spin { to { transform: rotate(360deg); } }
148+
.log-path { font-size: 0.85rem; word-break: break-all; color: #737373; margin-top: 1.5rem; }
149+
</style>
150+
</head>
151+
<body>
152+
<div class="box">
153+
<div class="spinner"></div>
154+
<h1>Applio is starting</h1>
155+
<p>Checking and downloading prerequisites if needed. This may take several minutes on first run.</p>
156+
<p>You will be switched to the main interface when ready.</p>
157+
<p class="log-path">Log: ~/Library/Logs/Applio/console.log</p>
158+
</div>
159+
</body>
160+
</html>'''
161+
134162
def launch_gradio(app_dir, logs_dir):
135163
"""Launch the Gradio app."""
136164
global _gradio_thread
@@ -180,6 +208,10 @@ def flush(self):
180208
self.original_stream.flush()
181209
except Exception:
182210
pass
211+
212+
def isatty(self):
213+
"""Required by uvicorn/logging when configuring formatters; we are not a TTY."""
214+
return False
183215

184216
# Redirect stdout and stderr to our tee
185217
sys.stdout = TeeOutput(log_file_path, sys.stdout)
@@ -191,63 +223,65 @@ def flush(self):
191223
print(f"All output will be captured to the Console tab in the UI")
192224
print("=" * 60)
193225

194-
# Run the Gradio app in a thread so pywebview can take control of main thread
195-
def run_gradio():
226+
# Single background thread: import app (runs prerequisites) then start Gradio server.
227+
# We never start a second Applio process.
228+
def run_app_and_server():
196229
try:
197-
# Import the app module which will launch Gradio
198230
import app
231+
app.launch_gradio("127.0.0.1", 6969)
199232
except Exception as e:
200233
print(f"ERROR: Failed to start Applio: {e}")
201234
import traceback
202235
traceback.print_exc()
203236

204-
_gradio_thread = threading.Thread(target=run_gradio, daemon=True)
237+
_gradio_thread = threading.Thread(target=run_app_and_server, daemon=True)
205238
_gradio_thread.start()
206239

207-
# Wait for server to be ready
208-
wait_for_server()
209-
210-
# Launch pywebview window
211240
try:
212241
import webview
213-
print("Opening Applio window...")
214-
215-
# Create window and keep reference so we can treat close-as-quit
242+
print("Opening Applio window (loading page first)...")
243+
# Show loading page immediately; redirect to real UI when server is ready (no second instance).
216244
window = webview.create_window(
217245
'Applio',
218-
'http://127.0.0.1:6969',
246+
html=LOADING_HTML,
219247
width=1400,
220248
height=900,
221249
resizable=True,
222250
fullscreen=False,
223251
min_size=(800, 600),
224252
background_color='#1a1a1a',
225253
text_select=True,
226-
on_top=False, # Keep in foreground but not always on top
227-
focus=True # Get focus on creation
254+
on_top=False,
255+
focus=True,
228256
)
229257

230-
# On macOS, closing the window (red X) does not quit the app by default;
231-
# the Cocoa run loop keeps running and the app can "reopen". Treat window
232-
# close as an explicit quit: cleanup and exit so we don't respawn.
258+
# Redirect this same window when server is up (long timeout for first-run download).
259+
def redirect_when_ready():
260+
print("Waiting for Applio server (prerequisites may be downloading)...")
261+
if wait_for_server(timeout=600):
262+
print("Applio server is ready — switching to main interface.")
263+
try:
264+
window.load_url("http://127.0.0.1:6969")
265+
except Exception as e:
266+
print(f"Redirect failed: {e}")
267+
else:
268+
print("Server did not start in time. Check ~/Library/Logs/Applio/console.log")
269+
270+
threading.Thread(target=redirect_when_ready, daemon=True).start()
271+
272+
# Quit fully on close so macOS does not reopen the app.
233273
if window is not None:
234274
def on_window_closed():
235-
print("\nWindow closed by user — quitting.")
275+
print("\nWindow closed — quitting.")
236276
cleanup()
237277
os._exit(0)
238278
try:
239279
window.events.closed += on_window_closed
240280
except Exception:
241-
pass # older pywebview may not have events.closed
281+
pass
242282

243-
# Register cleanup handler for graceful shutdown (e.g. Dock Quit, SIGTERM)
244283
atexit.register(cleanup)
245-
246-
# Start the webview - this blocks until the GUI run loop exits
247-
# When window is closed, on_window_closed() runs and we os._exit(0)
248-
webview.start(gui='cocoa') # Explicitly use Cocoa for macOS
249-
250-
# If start() returns without on_window_closed firing (e.g. other backends)
284+
webview.start(gui='cocoa')
251285
print("\nWindow closed by user")
252286

253287
except ImportError:

0 commit comments

Comments
 (0)