From 3f20868ad4532ca5c8374c1485eb4a079edf40ae Mon Sep 17 00:00:00 2001
From: sketchasketch
Date: Mon, 27 Apr 2026 22:24:34 -0400
Subject: [PATCH] feat(mac): add Apple Silicon support and runtime hardening
---
.gitignore | 2 +
README.md | 27 +-
api/routers/model.py | 308 +++++++++++++++++-
api/routers/optimize.py | 46 ++-
api/runner.py | 54 +--
api/services/extension_process.py | 124 +++++--
api/services/generator_registry.py | 3 +-
api/tests/test_extension_process.py | 25 ++
api/tests/test_runner.py | 68 ++++
api/texture_baker/setup.py | 26 +-
api/uv_unwrapper/setup.py | 15 +-
arch/decisions/APPLE-SILICON-SUPPORT.md | 114 +++++++
arch/decisions/README.md | 10 +
.../main/extension-install-utils.test.mjs | 62 ++++
electron/main/extension-install-utils.ts | 54 +++
electron/main/index.ts | 20 +-
electron/main/ipc-handlers.ts | 258 +++++++++++++--
electron/main/model-downloader.ts | 38 ++-
electron/main/python-bridge.ts | 53 ++-
electron/preload/index.ts | 31 +-
package.json | 17 +-
src/App.tsx | 3 +
src/areas/generate/GeneratePage.tsx | 19 +-
.../generate/components/GenerationHUD.tsx | 48 ++-
.../generate/components/GenerationOptions.tsx | 4 +-
src/areas/generate/components/Viewer3D.tsx | 40 ++-
.../generate/components/WorkflowPanel.tsx | 53 ++-
src/areas/models/ModelsPage.tsx | 62 +++-
src/areas/models/components/ExtensionCard.tsx | 114 ++++++-
.../components/PerformanceSection.tsx | 5 +-
src/areas/setup/FirstRunSetup.tsx | 76 +++--
src/areas/workflows/WorkflowsPage.tsx | 38 ++-
src/areas/workflows/mockExtensions.ts | 16 +-
src/areas/workflows/nodes/ExtensionNode.tsx | 28 +-
.../nodes/mesh-remesher/manifest.json | 6 +-
src/areas/workflows/preflight.ts | 112 +++++++
src/areas/workflows/useWorkflowRunner.ts | 25 +-
src/areas/workflows/workflowRunStore.ts | 28 +-
.../components/layout/MemoryIndicator.tsx | 66 ++++
src/shared/components/layout/TopBar.tsx | 74 +++--
src/shared/components/ui/Toast.tsx | 44 +++
src/shared/components/ui/index.ts | 1 +
src/shared/stores/appStore.ts | 23 +-
src/shared/stores/extensionsStore.ts | 71 ++--
src/shared/types/electron.d.ts | 29 +-
45 files changed, 1992 insertions(+), 348 deletions(-)
create mode 100644 api/tests/test_extension_process.py
create mode 100644 api/tests/test_runner.py
create mode 100644 arch/decisions/APPLE-SILICON-SUPPORT.md
create mode 100644 arch/decisions/README.md
create mode 100644 electron/main/extension-install-utils.test.mjs
create mode 100644 electron/main/extension-install-utils.ts
create mode 100644 src/areas/workflows/preflight.ts
create mode 100644 src/shared/components/layout/MemoryIndicator.tsx
create mode 100644 src/shared/components/ui/Toast.tsx
diff --git a/.gitignore b/.gitignore
index d7178cd..ce6c416 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,5 @@ Thumbs.db
.env
.env.local
+# TypeScript incremental build output
+*.tsbuildinfo
diff --git a/README.md b/README.md
index 50738c1..94cc8d8 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
**Local, open source, AI-powered image-to-3D mesh generation.**
Turn any photo into a 3D model using open source AI models running entirely on your GPU.
-Modly is a desktop application for Windows and Linux (macOS coming soon)
+Modly is a desktop application for Windows, Linux, and Apple Silicon macOS.
> Created by [Lightning Pixel](https://github.com/lightningpixel)
@@ -19,7 +19,7 @@ Modly is a desktop application for Windows and Linux (macOS coming soon)
## Download
-Head to the [Releases](../../releases/latest) page to download the latest installer for Windows or Linux.
+Head to the [Releases](../../releases/latest) page to download the latest installer for Windows, Linux, or Apple Silicon macOS.
Alternatively, you can clone the repository and run the app directly without installing:
@@ -27,7 +27,7 @@ Alternatively, you can clone the repository and run the app directly without ins
# Windows
launcher.bat
-# Linux
+# Linux / macOS
./launcher.sh
```
@@ -59,11 +59,28 @@ pip install -r requirements.txt
npm run dev
```
+### 4. Test
+
+```bash
+npm test
+./node_modules/.bin/tsc --noEmit -p tsconfig.node.json
+npm run build
+```
+
+## Platform notes
+
+- macOS support targets Apple Silicon only.
+- macOS uses native window controls. Windows and Linux keep the existing custom controls.
+- The top bar includes a live RAM indicator sourced from the main process.
+- Workflow wiring is validated before run; invalid graphs stay in place and surface inline/toast warnings instead of dropping the current mesh view.
+- Package Apple Silicon macOS with `npm run package:mac`.
+- Imported meshes can be smoothed and decimated in-app; optimized results are written back into the workspace.
+
---
## Extension system
-Modly supports external AI model extensions. Each extension is a GitHub repository containing a `manifest.json` and a `generator.py`.
+Modly supports external model and process extensions. Each extension is a GitHub repository containing a `manifest.json` plus the runtime entry files required by its type.
### Official extensions
@@ -85,7 +102,7 @@ Modly supports external AI model extensions. Each extension is a GitHub reposito

-**3.** Once the extension is installed, download the model or one of its variants.
+**3.** If the extension exposes model nodes, download the model or one of its variants. Process extensions are ready once installation and setup complete.

diff --git a/api/routers/model.py b/api/routers/model.py
index 6d24871..547edc4 100644
--- a/api/routers/model.py
+++ b/api/routers/model.py
@@ -1,6 +1,14 @@
import asyncio
import json
+import time
+import os
+import socket
+import shutil
+import threading
+from pathlib import Path
from typing import Optional
+from urllib.error import HTTPError, URLError
+from urllib.request import Request, urlopen
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from services.generator_registry import generator_registry, MODELS_DIR
@@ -8,6 +16,32 @@
router = APIRouter(tags=["model"])
+class DownloadPaused(Exception):
+ pass
+
+
+class DownloadCancelled(Exception):
+ pass
+
+
+_download_controls: dict[str, dict[str, threading.Event]] = {}
+
+
+def _download_control(model_id: str) -> dict[str, threading.Event]:
+ control = _download_controls.get(model_id)
+ if control is None:
+ control = {"pause": threading.Event(), "cancel": threading.Event()}
+ _download_controls[model_id] = control
+ return control
+
+
+def _check_download_control(control: dict[str, threading.Event]) -> None:
+ if control["cancel"].is_set():
+ raise DownloadCancelled()
+ if control["pause"].is_set():
+ raise DownloadPaused()
+
+
@router.get("/status")
async def model_status():
"""Status of the active model."""
@@ -67,16 +101,37 @@ async def unload_model(model_id: str):
return {"unloaded": True} # already not loaded, that's fine
+@router.post("/hf-download/pause")
+async def pause_hf_download(model_id: str):
+ control = _download_control(model_id)
+ control["pause"].set()
+ return {"paused": True}
+
+
+@router.post("/hf-download/cancel")
+async def cancel_hf_download(model_id: str):
+ control = _download_control(model_id)
+ control["cancel"].set()
+ return {"cancelled": True}
+
+
@router.get("/hf-download")
-async def hf_download(repo_id: str, model_id: str, skip_prefixes: Optional[str] = None, token: Optional[str] = None):
+async def hf_download(
+ repo_id: str,
+ model_id: str,
+ skip_prefixes: Optional[str] = None,
+ include_prefixes: Optional[str] = None,
+ token: Optional[str] = None,
+):
"""
Streams a HuggingFace Hub model download via SSE.
Downloads into MODELS_DIR / model_id applying the filtering
- declared in the extension manifest (hf_skip_prefixes).
+ declared in the extension manifest (hf_skip_prefixes / hf_include_prefixes).
- skip_prefixes: JSON-encoded list of path prefixes to exclude (passed from Electron).
- token: HuggingFace access token for gated repos (passed from Electron settings).
- Falls back to registry manifest if not provided.
+ skip_prefixes: JSON-encoded list of path prefixes to exclude.
+ include_prefixes: JSON-encoded list of path prefixes to include (whitelist).
+ token: HuggingFace access token for gated repos (from Electron settings).
+ All three fall back to the extension's manifest / environment when not supplied.
SSE format: data: {"percent": 0-100, "file": "...", "status": "..."}
"""
@@ -95,8 +150,22 @@ async def hf_download(repo_id: str, model_id: str, skip_prefixes: Optional[str]
except KeyError:
skip_list = []
- # Token: explicit param > env var
+ if include_prefixes:
+ try:
+ include_list = _json.loads(include_prefixes)
+ except Exception:
+ include_list = []
+ else:
+ try:
+ include_list = generator_registry.get_manifest(model_id).get("hf_include_prefixes", [])
+ except KeyError:
+ include_list = []
+
+ # Token: explicit query param > env var > None
hf_token = token or os.environ.get("HUGGING_FACE_HUB_TOKEN") or os.environ.get("HF_TOKEN") or None
+ control = _download_control(model_id)
+ control["pause"].clear()
+ control["cancel"].clear()
async def stream():
loop = asyncio.get_running_loop()
@@ -106,11 +175,13 @@ def _fmt(data: dict) -> str:
try:
yield _fmt({"percent": 0, "status": "Listing repository files..."})
+ _check_download_control(control)
def _list_files():
from huggingface_hub import list_repo_files
return [
f for f in list_repo_files(repo_id, token=hf_token)
+ if (not include_list or any(f.startswith(p) for p in include_list))
if not any(f.startswith(p) for p in skip_list)
]
@@ -123,29 +194,234 @@ def _list_files():
yield _fmt({"percent": 1, "status": f"Downloading {total} files..."})
- from huggingface_hub import hf_hub_download
+ from huggingface_hub import hf_hub_url
for i, filename in enumerate(files):
- def _dl(f=filename):
- hf_hub_download(
- repo_id=repo_id,
- filename=f,
- local_dir=dest_dir,
- local_dir_use_symlinks=False,
+ _check_download_control(control)
+ yield _fmt({
+ "percent": 1 + round(i / total * 94),
+ "file": filename,
+ "fileIndex": i + 1,
+ "totalFiles": total,
+ "status": f"Starting {filename}",
+ "bytesDownloaded": 0,
+ "stalledSeconds": 0,
+ })
+
+ base_pct = 1 + round(i / total * 94)
+ queue: asyncio.Queue[dict] = asyncio.Queue()
+
+ def _progress(msg: dict) -> None:
+ loop.call_soon_threadsafe(queue.put_nowait, msg)
+
+ url = hf_hub_url(repo_id=repo_id, filename=filename)
+ dl_future = loop.run_in_executor(
+ None,
+ lambda: _download_file_streamed(
+ url=url,
+ filename=filename,
+ dest_dir=dest_dir,
+ file_index=i + 1,
+ total_files=total,
+ base_percent=base_pct,
+ progress_cb=_progress,
+ control=control,
token=hf_token,
- )
+ ),
+ )
+
+ while not dl_future.done():
+ try:
+ msg = await asyncio.wait_for(queue.get(), timeout=2.0)
+ except asyncio.TimeoutError:
+ continue
+ else:
+ yield _fmt(msg)
- await loop.run_in_executor(None, _dl)
+ final_size = await dl_future
+ _check_download_control(control)
# Reserve 1-95 for file downloads, leave 95-100 for finalisation
pct = 1 + round((i + 1) / total * 94)
- yield _fmt({"percent": pct, "file": filename, "fileIndex": i + 1, "totalFiles": total})
+ yield _fmt({
+ "percent": pct,
+ "file": filename,
+ "fileIndex": i + 1,
+ "totalFiles": total,
+ "status": "Downloaded",
+ "bytesDownloaded": final_size,
+ "stalledSeconds": 0,
+ })
yield _fmt({"percent": 100, "status": "done"})
+ except DownloadPaused:
+ yield _fmt({"paused": True, "status": "paused"})
+ except DownloadCancelled:
+ shutil.rmtree(dest_dir, ignore_errors=True)
+ yield _fmt({"cancelled": True, "status": "cancelled"})
except Exception as exc:
yield _fmt({"error": str(exc)})
+ finally:
+ _download_controls.pop(model_id, None)
return StreamingResponse(stream(), media_type="text/event-stream")
+def _download_file_streamed(
+ *,
+ url: str,
+ filename: str,
+ dest_dir: str,
+ file_index: int,
+ total_files: int,
+ base_percent: int,
+ progress_cb,
+ control: dict[str, threading.Event],
+ token: Optional[str] = None,
+) -> int:
+ final_path = Path(dest_dir) / filename
+ temp_path = final_path.with_suffix(final_path.suffix + ".part")
+ final_path.parent.mkdir(parents=True, exist_ok=True)
+
+ if final_path.exists():
+ return final_path.stat().st_size
+
+ # Explicit token (from caller) > env vars > none
+ hf_token = (
+ token
+ or os.environ.get("HF_TOKEN")
+ or os.environ.get("HUGGINGFACE_HUB_TOKEN")
+ or os.environ.get("HUGGING_FACE_HUB_TOKEN")
+ )
+ headers = {"User-Agent": "modly/0.3.1"}
+ if hf_token:
+ headers["Authorization"] = f"Bearer {hf_token}"
+
+ retries = 3
+ backoff = 2.0
+ last_error: Exception | None = None
+
+ for attempt in range(1, retries + 1):
+ try:
+ _check_download_control(control)
+ existing_bytes = temp_path.stat().st_size if temp_path.exists() else 0
+ request_headers = dict(headers)
+ request_url = url
+ if existing_bytes > 0:
+ request_url = _resolve_direct_download_url(url, headers)
+ request_headers["Range"] = f"bytes={existing_bytes}-"
+
+ request = Request(request_url, headers=request_headers)
+ with urlopen(request, timeout=30) as response:
+ resumed = existing_bytes > 0 and getattr(response, "status", None) == 206
+ if existing_bytes > 0 and not resumed:
+ temp_path.unlink(missing_ok=True)
+ existing_bytes = 0
+
+ total_bytes = _response_total_bytes(response.headers, existing_bytes if resumed else 0)
+ bytes_downloaded = existing_bytes
+ last_emit = 0.0
+ chunk_size = 1024 * 1024
+ mode = "ab" if resumed else "wb"
+
+ progress_cb({
+ "percent": base_percent,
+ "file": filename,
+ "fileIndex": file_index,
+ "totalFiles": total_files,
+ "status": _download_status(bytes_downloaded, total_bytes, attempt, retries, resumed=resumed),
+ "bytesDownloaded": bytes_downloaded,
+ "totalBytes": total_bytes,
+ "stalledSeconds": 0,
+ })
+
+ with open(temp_path, mode) as out:
+ while True:
+ _check_download_control(control)
+ try:
+ chunk = response.read(chunk_size)
+ except socket.timeout as exc:
+ raise TimeoutError(f"Timed out while downloading {filename}") from exc
+
+ if not chunk:
+ break
+
+ out.write(chunk)
+ bytes_downloaded += len(chunk)
+
+ now = time.monotonic()
+ if now - last_emit >= 0.5:
+ progress_cb({
+ "percent": base_percent,
+ "file": filename,
+ "fileIndex": file_index,
+ "totalFiles": total_files,
+ "status": _download_status(bytes_downloaded, total_bytes, attempt, retries, resumed=resumed),
+ "bytesDownloaded": bytes_downloaded,
+ "totalBytes": total_bytes,
+ "stalledSeconds": 0,
+ })
+ last_emit = now
+
+ temp_path.replace(final_path)
+ return bytes_downloaded
+
+ except (HTTPError, URLError, TimeoutError, OSError) as exc:
+ last_error = exc
+ preserved_bytes = temp_path.stat().st_size if temp_path.exists() else 0
+ progress_cb({
+ "percent": base_percent,
+ "file": filename,
+ "fileIndex": file_index,
+ "totalFiles": total_files,
+ "status": f"Retrying after error ({attempt}/{retries})…",
+ "bytesDownloaded": preserved_bytes,
+ "stalledSeconds": 0,
+ })
+ if attempt >= retries:
+ break
+ time.sleep(backoff)
+ backoff *= 2
+
+ raise RuntimeError(f"Failed to download {filename}: {last_error}")
+
+
+def _resolve_direct_download_url(url: str, headers: dict[str, str]) -> str:
+ request = Request(url, headers=headers)
+ with urlopen(request, timeout=30) as response:
+ return response.geturl()
+
+
+def _parse_content_length(raw: Optional[str]) -> Optional[int]:
+ if not raw:
+ return None
+ try:
+ return int(raw)
+ except (TypeError, ValueError):
+ return None
+
+
+def _download_status(downloaded: int, total: Optional[int], attempt: int, retries: int, resumed: bool = False) -> str:
+ prefix = "Resuming…" if resumed and downloaded > 0 else "Downloading…"
+ if total and total > 0:
+ pct = min(100, round(downloaded / total * 100))
+ return f"{prefix} {pct}%"
+ if retries > 1 and attempt > 1:
+ return f"{prefix} retry {attempt}/{retries}"
+ return prefix
+
+
+def _response_total_bytes(headers, already_downloaded: int) -> Optional[int]:
+ content_range = headers.get("Content-Range")
+ if content_range and "/" in content_range:
+ total_raw = content_range.split("/")[-1].strip()
+ try:
+ return int(total_raw)
+ except (TypeError, ValueError):
+ pass
+
+ content_length = _parse_content_length(headers.get("Content-Length"))
+ if content_length is None:
+ return None
+ return already_downloaded + content_length
diff --git a/api/routers/optimize.py b/api/routers/optimize.py
index f68e261..49f4b39 100644
--- a/api/routers/optimize.py
+++ b/api/routers/optimize.py
@@ -39,17 +39,28 @@ def _require_pymeshlab():
raise HTTPException(503, "pymeshlab is unavailable on this system (DLL blocked by Windows Application Control policy)")
+def _resolve_input_path(raw_path: str) -> Path:
+ candidate = Path(raw_path)
+ if candidate.is_absolute():
+ resolved = candidate.resolve()
+ if not resolved.exists():
+ raise HTTPException(404, f"File not found: {raw_path}")
+ return resolved
+
+ resolved = (WORKSPACE_DIR / raw_path).resolve()
+ if not str(resolved).startswith(str(WORKSPACE_DIR.resolve())):
+ raise HTTPException(400, "Invalid path")
+ if not resolved.exists():
+ raise HTTPException(404, f"File not found: {raw_path}")
+ return resolved
+
+
@router.post("/mesh")
def optimize_mesh(body: OptimizeRequest):
_require_pymeshlab()
target_faces = max(100, min(500_000, body.target_faces))
- # Security: prevent path traversal
- input_path = (WORKSPACE_DIR / body.path).resolve()
- if not str(input_path).startswith(str(WORKSPACE_DIR.resolve())):
- raise HTTPException(400, "Invalid path")
- if not input_path.exists():
- raise HTTPException(404, f"File not found: {body.path}")
+ input_path = _resolve_input_path(body.path)
tmp_dir = tempfile.mkdtemp()
try:
@@ -59,13 +70,14 @@ def optimize_mesh(body: OptimizeRequest):
stem = input_path.stem
output_name = f"{stem}_opt{target_faces}.glb"
- output_path = input_path.parent / output_name
+ output_dir = input_path.parent if str(input_path).startswith(str(WORKSPACE_DIR.resolve())) else WORKSPACE_DIR / "Workflows"
+ output_dir.mkdir(parents=True, exist_ok=True)
+ output_path = output_dir / output_name
result.export(str(output_path))
- # Reconstruct the collection name from the path
- collection_name = body.path.split("/")[0]
face_count = len(result.faces)
- return {"url": f"/workspace/{collection_name}/{output_name}", "face_count": face_count}
+ rel = output_path.relative_to(WORKSPACE_DIR).as_posix()
+ return {"url": f"/workspace/{rel}", "face_count": face_count}
def _has_texture(geom: trimesh.Trimesh) -> bool:
@@ -162,11 +174,7 @@ def smooth_mesh(body: SmoothRequest):
_require_pymeshlab()
iterations = max(1, min(20, body.iterations))
- input_path = (WORKSPACE_DIR / body.path).resolve()
- if not str(input_path).startswith(str(WORKSPACE_DIR.resolve())):
- raise HTTPException(400, "Invalid path")
- if not input_path.exists():
- raise HTTPException(404, f"File not found: {body.path}")
+ input_path = _resolve_input_path(body.path)
tmp_dir = tempfile.mkdtemp()
try:
@@ -176,11 +184,13 @@ def smooth_mesh(body: SmoothRequest):
stem = input_path.stem
output_name = f"{stem}_smooth{iterations}.glb"
- output_path = input_path.parent / output_name
+ output_dir = input_path.parent if str(input_path).startswith(str(WORKSPACE_DIR.resolve())) else WORKSPACE_DIR / "Workflows"
+ output_dir.mkdir(parents=True, exist_ok=True)
+ output_path = output_dir / output_name
result.export(str(output_path))
- collection_name = body.path.split("/")[0]
- return {"url": f"/workspace/{collection_name}/{output_name}"}
+ rel = output_path.relative_to(WORKSPACE_DIR).as_posix()
+ return {"url": f"/workspace/{rel}"}
def _smooth(input_path: str, iterations: int, tmp_dir: str) -> trimesh.Trimesh:
diff --git a/api/runner.py b/api/runner.py
index e7d46d3..12f2bf2 100644
--- a/api/runner.py
+++ b/api/runner.py
@@ -81,6 +81,30 @@ def load_generator(manifest: dict):
return getattr(mod, manifest["generator_class"])
+def _select_node(manifest: dict, model_dir_override: str) -> dict:
+ nodes = manifest.get("nodes") or []
+ if nodes and model_dir_override:
+ node_id = Path(model_dir_override).name
+ return next((n for n in nodes if n.get("id") == node_id), nodes[0])
+ if nodes:
+ return nodes[0]
+ return {}
+
+
+def _resolve_ready_schema(GenClass, node: dict, manifest: dict) -> list:
+ try:
+ return GenClass.params_schema()
+ except Exception:
+ return node.get("params_schema") or manifest.get("params_schema", [])
+
+
+def _apply_manifest_metadata(gen, manifest: dict, node: dict) -> None:
+ gen.hf_repo = node.get("hf_repo") or manifest.get("hf_repo", "")
+ gen.hf_skip_prefixes = node.get("hf_skip_prefixes") or manifest.get("hf_skip_prefixes", [])
+ gen.download_check = node.get("download_check") or manifest.get("download_check", "")
+ gen._params_schema = node.get("params_schema") or manifest.get("params_schema", [])
+
+
# ------------------------------------------------------------------ #
# Main loop
# ------------------------------------------------------------------ #
@@ -97,38 +121,24 @@ def main() -> None:
"traceback": traceback.format_exc()})
return
- # Announce readiness and send params_schema so ExtensionProcess
- # can serve it without needing to query the subprocess later.
- # We try to get it from the generator class (may be a classmethod),
- # falling back to the manifest field.
- try:
- schema = GenClass.params_schema()
- except Exception:
- node0 = (manifest.get("nodes") or [{}])[0]
- schema = manifest.get("params_schema", []) or node0.get("params_schema", [])
- send({"type": "ready", "params_schema": schema})
-
# Support both flat manifest (legacy) and nodes[] format.
# Use MODEL_DIR to find the correct node for multi-node extensions:
# MODEL_DIR is set by ExtensionProcess to MODELS_DIR/ext_id/node_id,
# so its last component matches the node id.
- nodes = manifest.get("nodes") or []
- node = {}
- if nodes and _MODEL_DIR_OVERRIDE:
- node_id = Path(_MODEL_DIR_OVERRIDE).name
- node = next((n for n in nodes if n.get("id") == node_id), nodes[0])
- elif nodes:
- node = nodes[0]
+ node = _select_node(manifest, _MODEL_DIR_OVERRIDE)
+
+ # Announce readiness and send params_schema so ExtensionProcess
+ # can serve it without needing to query the subprocess later.
+ # We try to get it from the generator class (may be a classmethod),
+ # falling back to the selected node, then to the top-level manifest.
+ send({"type": "ready", "params_schema": _resolve_ready_schema(GenClass, node, manifest)})
# Use MODEL_DIR env var (set by ExtensionProcess) when available so the
# generator uses the exact same path that is_downloaded() checks against.
# Falls back to MODELS_DIR/manifest_id for legacy / standalone use.
model_dir = Path(_MODEL_DIR_OVERRIDE) if _MODEL_DIR_OVERRIDE else MODELS_DIR / model_id
gen = GenClass(model_dir, WORKSPACE_DIR)
- gen.hf_repo = manifest.get("hf_repo", "") or node.get("hf_repo", "")
- gen.hf_skip_prefixes = manifest.get("hf_skip_prefixes", []) or node.get("hf_skip_prefixes", [])
- gen.download_check = manifest.get("download_check", "") or node.get("download_check", "")
- gen._params_schema = manifest.get("params_schema", []) or node.get("params_schema", [])
+ _apply_manifest_metadata(gen, manifest, node)
# Active cancel events keyed by request id
_cancel: dict[str, threading.Event] = {}
diff --git a/api/services/extension_process.py b/api/services/extension_process.py
index fc6b3e4..d063fc7 100644
--- a/api/services/extension_process.py
+++ b/api/services/extension_process.py
@@ -73,8 +73,11 @@ def _build_env(self) -> dict:
env["MODELS_DIR"] = str(MODELS_DIR)
env["WORKSPACE_DIR"] = str(WORKSPACE_DIR)
env["MODLY_API_DIR"] = str(Path(__file__).parent.parent)
+ if sys.platform == "darwin":
+ env.setdefault("NUMBA_DISABLE_JIT", "1")
# Pass the exact model_dir so runner.py doesn't have to re-derive it
# from manifest["id"] (which is the ext_id, not the composite node id).
+ # runner.py extracts the node id from MODEL_DIR's trailing path component.
if self.model_dir is not None:
env["MODEL_DIR"] = str(self.model_dir)
# Extension venvs are based on python-embed which ships without a CA bundle.
@@ -97,7 +100,10 @@ def _start(self) -> None:
)
for attempt in range(3):
- self._queue = queue.Queue()
+ # Use a fresh queue per subprocess lifetime so late messages from an
+ # older reader thread cannot poison startup for the new process.
+ run_queue: queue.Queue = queue.Queue()
+ self._queue = run_queue
self._proc = subprocess.Popen(
[str(python), str(_RUNNER_PATH)],
stdin=subprocess.PIPE,
@@ -109,11 +115,11 @@ def _start(self) -> None:
)
# Background thread: read stdout → queue
- reader = threading.Thread(target=self._read_loop, daemon=True)
+ reader = threading.Thread(target=self._read_loop, args=(self._proc, run_queue), daemon=True)
reader.start()
# Background thread: forward stderr to our stderr
- stderr_fwd = threading.Thread(target=self._stderr_loop, daemon=True)
+ stderr_fwd = threading.Thread(target=self._stderr_loop, args=(self._proc,), daemon=True)
stderr_fwd.start()
# Wait for ready — runner sends params_schema in this message
@@ -175,23 +181,46 @@ def _install_missing_package(self, python: Path, module_name: str, package_name:
f"for missing module '{module_name}'.\n{details[-2000:]}"
) from exc
- def _read_loop(self) -> None:
+ def _read_loop(self, proc: subprocess.Popen, msg_queue: queue.Queue) -> None:
"""Continuously reads stdout and pushes parsed JSON to the queue."""
try:
- for line in self._proc.stdout:
+ for line in proc.stdout:
line = line.strip()
if line:
try:
- self._queue.put(json.loads(line))
+ msg_queue.put(json.loads(line))
except json.JSONDecodeError:
print(f"[{self.MODEL_ID}] bad JSON: {line}", file=sys.stderr)
finally:
- self._queue.put(None) # sentinel: process is done
-
- def _stderr_loop(self) -> None:
- """Forwards subprocess stderr to the main process stderr."""
- for line in self._proc.stderr:
- print(f"[{self.MODEL_ID}] {line}", end="", file=sys.stderr)
+ msg_queue.put(None) # sentinel: process is done
+
+ def _stderr_loop(self, proc: subprocess.Popen) -> None:
+ """Forward subprocess stderr to the main process stderr, emitting
+ one line every time we see EITHER '\\n' or '\\r'. tqdm writes live
+ progress updates with '\\r' only, so a newline-only iterator would
+ buffer every tick until the loop exits with '\\n' — which is why
+ the HUD's log pane went dark during multi-minute volume decode.
+
+ No per-line extension-id prefix: the HUD log pane is a single
+ truncated line, and eating 20 characters with "[modly-hy3d2-mac] "
+ hides the tail of the tqdm bar the user actually wants to read.
+ """
+ stream = proc.stderr
+ if stream is None:
+ return
+ buf = []
+ while True:
+ ch = stream.read(1)
+ if not ch:
+ if buf:
+ print(''.join(buf), file=sys.stderr, flush=True)
+ return
+ if ch in ("\r", "\n"):
+ if buf:
+ print(''.join(buf), file=sys.stderr, flush=True)
+ buf = []
+ else:
+ buf.append(ch)
def _send(self, msg: dict) -> None:
with self._lock:
@@ -262,14 +291,45 @@ def generate(
"outputs_dir": str(self.outputs_dir) if self.outputs_dir else None,
})
+ # Grace period after sending a cooperative cancel before hard-killing
+ # the subprocess. Long enough to let generators that check cancel_event
+ # between steps shut down cleanly, short enough that the user isn't
+ # left staring at a stuck UI when the subprocess is blocked inside a
+ # native call (octree decode, marching cubes, etc.) that ignores stdin.
+ CANCEL_GRACE_SECONDS = 3.0
+
+ cancel_sent_at: Optional[float] = None
while True:
# Check for cancellation
if cancel_event and cancel_event.is_set():
- self._send({"action": "cancel", "id": req_id})
- # Drain until the subprocess acknowledges
- while True:
- msg = self._recv(timeout=30.0)
- if msg.get("type") in ("cancelled", "done", "error"):
+ if cancel_sent_at is None:
+ # First observation of the cancel — ask the subprocess to stop.
+ try:
+ self._send({"action": "cancel", "id": req_id})
+ except Exception:
+ pass
+ import time
+ cancel_sent_at = time.monotonic()
+ else:
+ import time
+ if time.monotonic() - cancel_sent_at >= CANCEL_GRACE_SECONDS:
+ # Grace period expired — the subprocess is not
+ # responding (almost certainly stuck in native code).
+ # Hard-kill it and drop our state so the next
+ # generation forces a fresh load.
+ try:
+ if self._proc and self._proc.poll() is None:
+ self._proc.kill()
+ self._proc.wait(timeout=5.0)
+ except Exception:
+ pass
+ self._loaded = False
+ self._proc = None
+ print(
+ f"[ExtensionProcess] {self.MODEL_ID} subprocess killed "
+ f"after {CANCEL_GRACE_SECONDS}s grace; model will reload on next run",
+ file=sys.stderr,
+ )
raise GenerationCancelled()
# Poll queue with short timeout so we can re-check cancel_event
@@ -303,12 +363,28 @@ def params_schema(self) -> list:
return self._params_schema
def stop(self) -> None:
- """Gracefully shut down the subprocess."""
- if self._proc and self._proc.poll() is None:
+ """Hard-stop the subprocess.
+
+ Used by Free Memory / unload_all. Cooperative shutdown was the wrong
+ semantics here: torch.mps.empty_cache() does not reliably release
+ wired Metal pages, so only process exit actually returns the memory
+ to the OS. We SIGKILL, reap the zombie, and drop our refs so the
+ next load() starts a fresh subprocess.
+ """
+ proc = self._proc
+ self._proc = None
+ self._loaded = False
+ if proc and proc.poll() is None:
try:
- self._send({"action": "shutdown"})
- self._proc.wait(timeout=15)
+ proc.kill()
+ proc.wait(timeout=5)
except Exception:
- self._proc.kill()
- self._loaded = False
- self._proc = None
+ pass
+ self._drain_queue()
+
+ def _drain_queue(self) -> None:
+ while not self._queue.empty():
+ try:
+ self._queue.get_nowait()
+ except queue.Empty:
+ break
diff --git a/api/services/generator_registry.py b/api/services/generator_registry.py
index de1f20c..b36c6c9 100644
--- a/api/services/generator_registry.py
+++ b/api/services/generator_registry.py
@@ -106,7 +106,8 @@ def _discover_extensions() -> Dict[str, Tuple[type, dict]]:
"hf_repo": node.get("hf_repo", ""),
"download_check": node.get("download_check", ""),
"hf_skip_prefixes": node.get("hf_skip_prefixes", []),
- "params_schema": node.get("params_schema", []),
+ "hf_include_prefixes": node.get("hf_include_prefixes", []),
+ "params_schema": node.get("params_schema", manifest.get("params_schema", [])),
"input": node.get("input", "image"),
"output": node.get("output", "mesh"),
}
diff --git a/api/tests/test_extension_process.py b/api/tests/test_extension_process.py
new file mode 100644
index 0000000..cbe7562
--- /dev/null
+++ b/api/tests/test_extension_process.py
@@ -0,0 +1,25 @@
+import io
+import queue
+import unittest
+
+from services.extension_process import ExtensionProcess
+
+
+class ExtensionProcessTests(unittest.TestCase):
+ def test_read_loop_writes_sentinel_to_own_queue_only(self) -> None:
+ proc = ExtensionProcess(ext_dir=None, manifest={"id": "demo"}) # type: ignore[arg-type]
+
+ old_queue: queue.Queue = queue.Queue()
+ new_queue: queue.Queue = queue.Queue()
+ proc._queue = new_queue
+
+ fake_proc = type("FakeProc", (), {"stdout": io.StringIO("")})()
+
+ proc._read_loop(fake_proc, old_queue)
+
+ self.assertFalse(old_queue.empty())
+ self.assertTrue(new_queue.empty())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/api/tests/test_runner.py b/api/tests/test_runner.py
new file mode 100644
index 0000000..ada9dfc
--- /dev/null
+++ b/api/tests/test_runner.py
@@ -0,0 +1,68 @@
+import unittest
+import os
+import tempfile
+import importlib
+from pathlib import Path
+
+
+_tmp_ext_dir = tempfile.mkdtemp(prefix="modly-runner-test-")
+Path(_tmp_ext_dir, "manifest.json").write_text("{}", encoding="utf-8")
+os.environ.setdefault("EXTENSION_DIR", _tmp_ext_dir)
+
+runner = importlib.import_module("runner")
+_apply_manifest_metadata = runner._apply_manifest_metadata
+_resolve_ready_schema = runner._resolve_ready_schema
+_select_node = runner._select_node
+
+
+class RunnerTests(unittest.TestCase):
+ def test_select_node_uses_model_dir_override(self) -> None:
+ manifest = {
+ "nodes": [
+ {"id": "fast", "params_schema": [{"id": "a"}]},
+ {"id": "quality", "params_schema": [{"id": "b"}]},
+ ]
+ }
+
+ node = _select_node(manifest, str(Path("/tmp/ext/quality")))
+
+ self.assertEqual(node["id"], "quality")
+
+ def test_ready_schema_falls_back_to_selected_node_schema(self) -> None:
+ class GenClass:
+ @classmethod
+ def params_schema(cls):
+ raise RuntimeError("not available")
+
+ manifest = {"params_schema": [{"id": "manifest"}]}
+ node = {"params_schema": [{"id": "node"}]}
+
+ schema = _resolve_ready_schema(GenClass, node, manifest)
+
+ self.assertEqual(schema, [{"id": "node"}])
+
+ def test_apply_manifest_metadata_prefers_node_specific_values(self) -> None:
+ gen = type("Gen", (), {})()
+ manifest = {
+ "hf_repo": "top/repo",
+ "hf_skip_prefixes": ["top/"],
+ "download_check": "top/file",
+ "params_schema": [{"id": "top"}],
+ }
+ node = {
+ "hf_repo": "node/repo",
+ "hf_skip_prefixes": ["node/"],
+ "download_check": "node/file",
+ "params_schema": [{"id": "node"}],
+ }
+
+ _apply_manifest_metadata(gen, manifest, node)
+
+ self.assertEqual(gen.hf_repo, "node/repo")
+ self.assertEqual(gen.hf_skip_prefixes, ["node/"])
+ self.assertEqual(gen.download_check, "node/file")
+ self.assertEqual(gen._params_schema, [{"id": "node"}])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/api/texture_baker/setup.py b/api/texture_baker/setup.py
index 1b45cf2..e5158cb 100644
--- a/api/texture_baker/setup.py
+++ b/api/texture_baker/setup.py
@@ -14,6 +14,7 @@
library_name = "texture_baker"
IS_WINDOWS = platform.system() == "Windows"
+IS_MACOS = platform.system() == "Darwin"
def get_extensions():
@@ -39,6 +40,18 @@ def get_extensions():
if debug_mode:
cxx_flags += ["/Z7"]
extra_link_args += ["/DEBUG"]
+ elif IS_MACOS:
+ # Prefer a conservative Apple Silicon toolchain over OpenMP-specific flags.
+ cxx_flags = [
+ "-O3" if not debug_mode else "-O0",
+ "-fdiagnostics-color=always",
+ "-mmacosx-version-min=11.0",
+ ]
+ if use_native_arch:
+ cxx_flags.append("-mcpu=apple-m1")
+ if debug_mode:
+ cxx_flags += ["-g", "-UNDEBUG"]
+ extra_link_args += ["-O0", "-g"]
else:
# GCC/Clang flags
cxx_flags = [
@@ -91,8 +104,17 @@ def get_extensions():
sources += glob.glob(
os.path.join(this_dir, library_name, "csrc", "**", "*.mm"), recursive=True
)
- extra_compile_args.update({"cxx": ["-O3", "-arch", "arm64", "-mmacosx-version-min=10.15"]})
- extra_link_args += ["-arch", "arm64"]
+ if IS_MACOS:
+ if "-arch" not in extra_link_args:
+ extra_link_args += [
+ "-arch",
+ "arm64",
+ "-framework",
+ "Metal",
+ "-framework",
+ "Foundation",
+ ]
+ cxx_flags.extend(["-arch", "arm64"])
extensions.append(
extension(
diff --git a/api/uv_unwrapper/setup.py b/api/uv_unwrapper/setup.py
index bff9eec..8284ed9 100644
--- a/api/uv_unwrapper/setup.py
+++ b/api/uv_unwrapper/setup.py
@@ -12,6 +12,7 @@
library_name = "uv_unwrapper"
IS_WINDOWS = platform.system() == "Windows"
+IS_MACOS = platform.system() == "Darwin"
def get_extensions():
@@ -19,8 +20,7 @@ def get_extensions():
if debug_mode:
print("Compiling in debug mode")
- is_mac = True if torch.backends.mps.is_available() else False
- use_native_arch = not is_mac and not IS_WINDOWS and os.getenv("USE_NATIVE_ARCH", "1") == "1"
+ use_native_arch = not IS_MACOS and not IS_WINDOWS and os.getenv("USE_NATIVE_ARCH", "1") == "1"
extension = CppExtension
extra_link_args = []
@@ -31,12 +31,13 @@ def get_extensions():
if debug_mode:
cxx_flags += ["/Z7", "/UNDEBUG"]
extra_link_args += ["-O0"]
- elif is_mac:
+ elif IS_MACOS:
cxx_flags = [
"-O3" if not debug_mode else "-O0",
"-fdiagnostics-color=always",
- "-Xclang -fopenmp",
- "-mmacosx-version-min=10.15",
+ "-mmacosx-version-min=11.0",
+ "-arch",
+ "arm64",
]
if debug_mode:
cxx_flags += ["-g", "-UNDEBUG"]
@@ -74,9 +75,7 @@ def get_extensions():
define_macros=define_macros,
extra_compile_args=extra_compile_args,
extra_link_args=extra_link_args,
- libraries=["c10", "torch", "torch_cpu", "torch_python"] + ["omp"]
- if is_mac
- else [],
+ libraries=["c10", "torch", "torch_cpu", "torch_python"] if IS_MACOS else [],
)
)
diff --git a/arch/decisions/APPLE-SILICON-SUPPORT.md b/arch/decisions/APPLE-SILICON-SUPPORT.md
new file mode 100644
index 0000000..c49c153
--- /dev/null
+++ b/arch/decisions/APPLE-SILICON-SUPPORT.md
@@ -0,0 +1,114 @@
+# APPLE-SILICON-SUPPORT
+
+- Status: proposed
+- Date: 2026-04-23
+
+## Decision
+
+Modly supports macOS on Apple Silicon (`darwin/arm64`) as a first-class
+platform. This ADR consolidates the runtime, packaging, extension, and
+workflow rules needed to run the image-to-mesh pipeline reliably on 16 GB
+unified-memory Macs.
+
+Scope and operating rules:
+
+- macOS support targets Apple Silicon only. See `package.json:99`.
+- Intel macOS, universal binaries, and Rosetta fallback are out of scope.
+- Model weights stay separate from extension code and are installed per node. See `api/routers/model.py:76` and `api/runner.py:101`.
+- The Mac workflow is sequential and memory-budgeted: one heavy generative
+ stage resident at a time. See `src/areas/workflows/workflowRunStore.ts:185`.
+- Download/install state must be observable and resumable. See `electron/main/model-downloader.ts:116`.
+- Releasing GPU memory means terminating the owning subprocess. See `api/services/extension_process.py:205` and `electron/main/index.ts:105`.
+- Generation progress and cancel behavior must remain visible and responsive in
+ the UI. See `api/services/extension_process.py:135` and `src/areas/workflows/workflowRunStore.ts:232`.
+
+## Context
+
+Apple Silicon changes the constraints under which Modly runs:
+
+- Unified memory means overlapping heavy GPU stages can destabilize the whole
+ machine on 16 GB systems.
+- Metal/MPS memory is not returned predictably by Python-side cleanup alone;
+ process exit is the reliable release boundary.
+- Large model downloads need byte-level visibility, stall detection, and proper
+ resume behavior to avoid appearing hung or silently reinstalling from zero.
+- Extension manifests now need per-node distribution metadata because one
+ extension can expose multiple model variants that share code but differ in
+ weights, defaults, and required artifacts.
+- Workflow graphs need preflight validation before execution so invalid wiring
+ is reported without replacing the current mesh view with a terminal error
+ state.
+- The renderer needs progress text that stays live through long native phases
+ and a cancel path that clears UI state immediately even if backend teardown
+ takes longer.
+
+## Consequences
+
+- Packaging:
+ Modly packages macOS as an Apple Silicon build path only, including the
+ embedded Python runtime in the app bundle. See `package.json:99`.
+
+- Extension and model distribution:
+ Extension payloads contain code, manifests, setup scripts, and lightweight
+ assets. Model nodes declare their own `download_check`, can narrow downloads
+ with `hf_include_prefixes` and `hf_skip_prefixes`, and may provide
+ node-specific `params_schema` and `param_defaults` with top-level fallback.
+ See `api/routers/model.py:76`, `api/runner.py:84`, and
+ `electron/main/model-downloader.ts:116`.
+
+- Runtime selection and defaults:
+ The runner resolves the active node from `MODEL_DIR` so multi-node extensions
+ use the correct schema, model directory, and node-specific metadata. Workflow
+ submission merges displayed parameter defaults under user overrides before the
+ request reaches Python. See `api/runner.py:84` and
+ `src/areas/workflows/workflowRunStore.ts:207`.
+
+- Mesh optimization path handling:
+ Smooth and decimate operations accept both workspace-relative meshes and
+ imported absolute-path meshes, then write optimized output back into the
+ workspace so the result remains visible and reusable in the app. See
+ `api/routers/optimize.py:42` and `src/areas/generate/GeneratePage.tsx:331`.
+
+- Memory-budgeted workflow:
+ Heavy stages hand off through files and unload before the next heavy stage
+ begins. CPU-oriented stages can run between GPU-heavy stages without
+ competing for MPS residency. See `electron/main/index.ts:105`,
+ `api/services/extension_process.py:214`, and
+ `src/areas/workflows/workflowRunStore.ts:142`.
+
+- Download behavior:
+ The downloader emits byte-level progress, file context, and stall state.
+ Partial downloads are preserved as `.part` files. Resume is attempted against
+ the resolved final URL so `Range` works even when upstream redirects to a CDN.
+ Install completion is verified by the declared `download_check`, not by
+ directory existence alone. See `electron/main/model-downloader.ts:10`,
+ `electron/main/model-downloader.ts:31`, and `api/routers/model.py:84`.
+
+- Subprocess lifecycle:
+ Extension subprocesses are owned as a full process tree. On Unix, the Python
+ bridge runs as its own process-group leader and Modly kills the process group
+ on quit. Free-memory/unload operations hard-stop the subprocess. Cancel first
+ sends a cooperative request, then escalates to a kill after a short grace
+ period if native code is still blocking. See `api/services/extension_process.py:78`
+ and `electron/main/index.ts:112`.
+
+- Observability and UX:
+ Generator stderr stays available for tqdm-style progress parsing, long phases
+ surface readable status text, and cancel clears renderer job state
+ immediately. Workflow editors run a preflight pass before execution and
+ surface wiring problems through inline warnings and toasts instead of
+ replacing the current mesh view. Error output in the HUD remains
+ copyable/selectable. The top bar includes a live RAM indicator backed by a
+ main-process `system:memory` IPC call; macOS uses `vm_stat` to approximate
+ Activity Monitor's "Memory Used" semantics and other platforms fall back to
+ `total - free`. macOS uses native window controls instead of custom
+ right-side controls. See
+ `api/services/extension_process.py:135`,
+ `src/areas/workflows/preflight.ts:51`,
+ `src/areas/generate/components/WorkflowPanel.tsx:425`,
+ `src/areas/workflows/WorkflowsPage.tsx:847`,
+ `src/shared/components/ui/Toast.tsx:4`,
+ `electron/main/ipc-handlers.ts:372`,
+ `src/shared/components/layout/MemoryIndicator.tsx:9`,
+ `src/shared/components/layout/TopBar.tsx:4`, and
+ `src/areas/setup/FirstRunSetup.tsx:258`.
diff --git a/arch/decisions/README.md b/arch/decisions/README.md
new file mode 100644
index 0000000..063df57
--- /dev/null
+++ b/arch/decisions/README.md
@@ -0,0 +1,10 @@
+# Architecture Decisions
+
+This directory stores architecture decision records for Modly.
+
+The current Apple Silicon support work is documented in one consolidated ADR so
+the platform scope, runtime assumptions, and operational constraints stay in a
+single reviewable document.
+
+Current ADRs:
+- [APPLE-SILICON-SUPPORT](./APPLE-SILICON-SUPPORT.md)
diff --git a/electron/main/extension-install-utils.test.mjs b/electron/main/extension-install-utils.test.mjs
new file mode 100644
index 0000000..5a7e10d
--- /dev/null
+++ b/electron/main/extension-install-utils.test.mjs
@@ -0,0 +1,62 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { buildSync } from 'esbuild'
+import { createRequire } from 'node:module'
+import { mkdtempSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+
+function loadModule() {
+ const outfile = join(mkdtempSync(join(tmpdir(), 'modly-ext-test-')), 'extension-install-utils.cjs')
+ const require = createRequire(import.meta.url)
+ const result = buildSync({
+ entryPoints: [resolve('electron/main/extension-install-utils.ts')],
+ bundle: true,
+ platform: 'node',
+ format: 'cjs',
+ write: false,
+ })
+ writeFileSync(outfile, result.outputFiles[0].text, 'utf8')
+ return require(outfile)
+}
+
+test('validateInstallManifest accepts legacy flat model manifests', () => {
+ const mod = loadModule()
+
+ const validated = mod.validateInstallManifest(
+ { id: 'legacy-model', generator_class: 'Generator' },
+ {
+ hasEntryFile: () => false,
+ hasGeneratorFile: () => true,
+ },
+ 'repository',
+ )
+
+ assert.equal(validated.id, 'legacy-model')
+ assert.equal(validated.isProcess, false)
+ assert.equal(validated.hasNodes, false)
+})
+
+test('validateInstallManifest still rejects missing process entry files', () => {
+ const mod = loadModule()
+
+ assert.throws(
+ () => mod.validateInstallManifest(
+ { id: 'proc', type: 'process', entry: 'processor.py' },
+ {
+ hasEntryFile: () => false,
+ hasGeneratorFile: () => false,
+ },
+ 'selected folder',
+ ),
+ /entry file "processor\.py" missing from selected folder/,
+ )
+})
+
+test('python process setup failures are treated as fatal', () => {
+ const mod = loadModule()
+
+ assert.equal(mod.isSetupFailureFatal({ isProcess: true, isPythonProcess: true }), true)
+ assert.equal(mod.isSetupFailureFatal({ isProcess: true, isPythonProcess: false }), false)
+ assert.equal(mod.isSetupFailureFatal({ isProcess: false, isPythonProcess: false }), true)
+})
diff --git a/electron/main/extension-install-utils.ts b/electron/main/extension-install-utils.ts
new file mode 100644
index 0000000..73e5cfa
--- /dev/null
+++ b/electron/main/extension-install-utils.ts
@@ -0,0 +1,54 @@
+export interface InstallManifest {
+ id?: string
+ type?: 'model' | 'process'
+ entry?: string
+ generator_class?: string
+ nodes?: Array<{ id?: string }>
+}
+
+export interface ValidatedInstallManifest {
+ id: string
+ isProcess: boolean
+ isPythonProcess: boolean
+ entryFile: string
+ hasNodes: boolean
+}
+
+export function validateInstallManifest(
+ manifest: InstallManifest,
+ opts: {
+ hasEntryFile: (entryFile: string) => boolean
+ hasGeneratorFile: () => boolean
+ },
+ sourceLabel: string,
+): ValidatedInstallManifest {
+ if (!manifest.id) throw new Error('manifest.json: required field "id" missing')
+
+ const isProcess = manifest.type === 'process'
+ const entryFile = manifest.entry ?? 'processor.js'
+ const nodes = Array.isArray(manifest.nodes) ? manifest.nodes.filter((node) => node?.id) : []
+
+ if (isProcess) {
+ if (!opts.hasEntryFile(entryFile)) {
+ throw new Error(`manifest.json: entry file "${entryFile}" missing from ${sourceLabel}`)
+ }
+ } else {
+ if (!opts.hasGeneratorFile()) throw new Error(`generator.py missing from ${sourceLabel}`)
+ if (!manifest.generator_class) throw new Error('manifest.json: required field "generator_class" missing')
+ }
+
+ return {
+ id: manifest.id,
+ isProcess,
+ isPythonProcess: isProcess && entryFile.endsWith('.py'),
+ entryFile,
+ hasNodes: nodes.length > 0,
+ }
+}
+
+export function isSetupFailureFatal(kind: {
+ isProcess: boolean
+ isPythonProcess: boolean
+}): boolean {
+ return !kind.isProcess || kind.isPythonProcess
+}
diff --git a/electron/main/index.ts b/electron/main/index.ts
index 19b75df..6ef0b46 100644
--- a/electron/main/index.ts
+++ b/electron/main/index.ts
@@ -33,6 +33,21 @@ function createWindow(): void {
mainWindow?.show()
})
+ mainWindow.webContents.on('before-input-event', (event, input) => {
+ const isMacQuitShortcut =
+ process.platform === 'darwin' &&
+ input.type === 'keyDown' &&
+ input.key.toLowerCase() === 'q' &&
+ input.meta &&
+ !input.control &&
+ !input.alt
+
+ if (isMacQuitShortcut) {
+ event.preventDefault()
+ app.quit()
+ }
+ })
+
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
@@ -88,7 +103,10 @@ app.whenReady().then(async () => {
})
app.on('window-all-closed', () => {
- if (process.platform !== 'darwin') app.quit()
+ // Modly holds a multi-GB Python subprocess; leaving it running in the
+ // Dock after the window closes (the Mac default) is the wrong behavior
+ // for this app. Closing the window means quit.
+ app.quit()
})
app.on('before-quit', (event) => {
diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts
index ac868a0..3c5e4b9 100644
--- a/electron/main/ipc-handlers.ts
+++ b/electron/main/ipc-handlers.ts
@@ -6,6 +6,8 @@ import { rm as rmAsync, readFile, writeFile, mkdir, readdir, rename, cp } from '
import { existsSync, readdirSync, statSync } from 'fs'
import axios from 'axios'
import * as tar from 'tar'
+import * as os from 'os'
+import { promisify } from 'util'
import { PythonBridge, API_BASE_URL } from './python-bridge'
import {
isModelDownloaded,
@@ -17,16 +19,26 @@ import { checkSetupNeeded, markSetupDone, runFullSetup, getVenvPythonExe, ensure
import { logger } from './logger'
import { getProcessRunner, getPythonProcessRunner, getExtPythonExe, terminateProcessRunner, terminateAllProcessRunners } from './process-runner'
import { getBuiltinExtensionsDir } from './builtin-sync'
-import { spawn } from 'child_process'
+import { spawn, execFile } from 'child_process'
import { assertSafeExtensionId, buildExtensionBackupPath, resolveExtensionPathWithinRoot } from './extension-path-guard'
+import { isSetupFailureFatal, validateInstallManifest } from './extension-install-utils'
type WindowGetter = () => BrowserWindow | null
+const pExecFile = promisify(execFile)
// ─── GPU detect (best-effort, no Python required) ─────────────────────────────
-interface GpuInfo { sm: number; cudaVersion: number }
+interface GpuInfo {
+ sm: number
+ cudaVersion: number
+ accelerator: 'cuda' | 'mps' | 'cpu'
+}
function detectGpuInfo(): Promise {
+ if (process.platform === 'darwin' && process.arch === 'arm64') {
+ return Promise.resolve({ sm: 0, cudaVersion: 0, accelerator: 'mps' })
+ }
+
return new Promise((resolve) => {
// Query compute cap + driver version in one call
const proc = spawn('nvidia-smi', ['--query-gpu=compute_cap,driver_version', '--format=csv,noheader'], {
@@ -53,12 +65,12 @@ function detectGpuInfo(): Promise {
else if (driverMajor >= 530) cudaVersion = 121
else if (driverMajor >= 525) cudaVersion = 120
else if (driverMajor >= 520) cudaVersion = 118
- resolve({ sm: isNaN(sm) ? 86 : sm, cudaVersion })
+ resolve({ sm: isNaN(sm) ? 86 : sm, cudaVersion, accelerator: 'cuda' })
} else {
- resolve({ sm: 86, cudaVersion: 118 })
+ resolve({ sm: 0, cudaVersion: 0, accelerator: 'cpu' })
}
})
- proc.on('error', () => resolve({ sm: 86, cudaVersion: 118 }))
+ proc.on('error', () => resolve({ sm: 0, cudaVersion: 0, accelerator: 'cpu' }))
})
}
@@ -76,8 +88,92 @@ function runExtensionSetup(
const pythonExe = getVenvPythonExe(userData)
const setupPy = join(extDir, 'setup.py')
- const args = JSON.stringify({ python_exe: pythonExe, ext_dir: extDir, gpu_sm: gpuSm, cuda_version: cudaVersion })
- const proc = spawn(pythonExe, [setupPy, args], {
+ const accelerator = process.platform === 'darwin' && process.arch === 'arm64' ? 'mps' : gpuSm > 0 ? 'cuda' : 'cpu'
+ const args = JSON.stringify({
+ python_exe: pythonExe,
+ ext_dir: extDir,
+ gpu_sm: gpuSm,
+ cuda_version: cudaVersion,
+ accelerator,
+ platform: process.platform,
+ arch: process.arch,
+ })
+ const launcher = `
+import runpy
+import subprocess
+import sys
+
+setup_py = sys.argv[1]
+setup_args = sys.argv[2:]
+
+_original_run = subprocess.run
+_original_check_call = subprocess.check_call
+_original_check_output = subprocess.check_output
+
+def _is_cuda_torch_index(value):
+ return isinstance(value, str) and value.startswith("https://download.pytorch.org/whl/cu")
+
+def _mentions_torch(command):
+ if not isinstance(command, (list, tuple)):
+ return False
+ return any(str(part).startswith(("torch==", "torchvision==", "torchaudio==")) for part in command)
+
+def _rewrite_command(command):
+ if sys.platform != "darwin" or not _mentions_torch(command):
+ return command
+ if not isinstance(command, (list, tuple)):
+ return command
+
+ rewritten = []
+ changed = False
+ i = 0
+ while i < len(command):
+ part = command[i]
+ text = str(part)
+ if text in ("--index-url", "-i", "--extra-index-url") and i + 1 < len(command) and _is_cuda_torch_index(str(command[i + 1])):
+ changed = True
+ i += 2
+ continue
+ if text.startswith("--index-url=") or text.startswith("--extra-index-url="):
+ value = text.split("=", 1)[1]
+ if _is_cuda_torch_index(value):
+ changed = True
+ i += 1
+ continue
+ rewritten.append(part)
+ i += 1
+
+ if changed:
+ print("[Modly setup compat] Removed CUDA-only PyTorch index on macOS; pip will use macOS wheels.", file=sys.stderr)
+ return rewritten
+ return command
+
+def _patched_run(*args, **kwargs):
+ args = list(args)
+ if args:
+ args[0] = _rewrite_command(args[0])
+ return _original_run(*args, **kwargs)
+
+def _patched_check_call(*args, **kwargs):
+ args = list(args)
+ if args:
+ args[0] = _rewrite_command(args[0])
+ return _original_check_call(*args, **kwargs)
+
+def _patched_check_output(*args, **kwargs):
+ args = list(args)
+ if args:
+ args[0] = _rewrite_command(args[0])
+ return _original_check_output(*args, **kwargs)
+
+subprocess.run = _patched_run
+subprocess.check_call = _patched_check_call
+subprocess.check_output = _patched_check_output
+
+sys.argv = [setup_py] + setup_args
+runpy.run_path(setup_py, run_name="__main__")
+`
+ const proc = spawn(pythonExe, ['-c', launcher, setupPy, args], {
stdio: ['ignore', 'pipe', 'pipe'],
})
@@ -145,7 +241,12 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
ipcMain.handle('setup:check', async () => {
const userData = app.getPath('userData')
const defaultDataDir = join(app.getPath('documents'), 'Modly')
- return { needed: checkSetupNeeded(userData), defaultDataDir }
+ return {
+ needed: checkSetupNeeded(userData),
+ defaultDataDir,
+ platform: process.platform,
+ arch: process.arch,
+ }
})
ipcMain.handle('setup:saveDataDir', async (_event, { baseDir }: { baseDir: string }) => {
@@ -315,22 +416,65 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
return listDownloadedModels(modelsDir)
})
- ipcMain.handle('model:isDownloaded', (_, modelId: string): boolean => {
+ ipcMain.handle('model:isDownloaded', (_, modelId: string, downloadCheck?: string): boolean => {
const modelsDir = getSettings(app.getPath('userData')).modelsDir
- return isModelDownloaded(modelsDir, modelId)
+ return isModelDownloaded(modelsDir, modelId, downloadCheck)
})
ipcMain.handle('model:activeDownloads', () =>
[...activeDownloads.entries()].map(([modelId, progress]) => ({ modelId, ...progress }))
)
- ipcMain.handle('model:download', async (event, { repoId, modelId, skipPrefixes }: { repoId: string; modelId: string; skipPrefixes?: string[] }) => {
+ ipcMain.handle('model:download', async (
+ event,
+ { repoId, modelId, skipPrefixes, includePrefixes }: { repoId: string; modelId: string; skipPrefixes?: string[]; includePrefixes?: string[] },
+ ) => {
+ if (activeDownloads.has(modelId)) {
+ return { success: false, error: 'Download already in progress' }
+ }
activeDownloads.set(modelId, { percent: 0 })
try {
await downloadModelFromHF(repoId, modelId, (progress) => {
activeDownloads.set(modelId, progress)
event.sender.send('model:downloadProgress', { modelId, ...progress })
- }, skipPrefixes)
+ }, skipPrefixes, includePrefixes)
+ return { success: true }
+ } catch (err: any) {
+ const message = err?.message ?? String(err)
+ if (message.includes('paused')) {
+ event.sender.send('model:downloadProgress', { modelId, percent: 0, status: 'paused', paused: true })
+ return { success: false, paused: true }
+ }
+ if (message.includes('cancelled')) {
+ event.sender.send('model:downloadProgress', { modelId, percent: 0, status: 'cancelled', cancelled: true })
+ return { success: false, cancelled: true }
+ }
+ return { success: false, error: String(err) }
+ } finally {
+ activeDownloads.delete(modelId)
+ }
+ })
+
+ ipcMain.handle('model:pauseDownload', async (_, modelId: string): Promise<{ success: boolean; error?: string }> => {
+ try {
+ await axios.post(`${API_BASE_URL}/model/hf-download/pause`, null, {
+ params: { model_id: modelId },
+ timeout: 5000,
+ })
+ return { success: true }
+ } catch (err) {
+ return { success: false, error: String(err) }
+ }
+ })
+
+ ipcMain.handle('model:cancelDownload', async (_, modelId: string): Promise<{ success: boolean; error?: string }> => {
+ try {
+ await axios.post(`${API_BASE_URL}/model/hf-download/cancel`, null, {
+ params: { model_id: modelId },
+ timeout: 5000,
+ })
+ const modelDir = join(getSettings(app.getPath('userData')).modelsDir, modelId)
+ await rmAsync(modelDir, { recursive: true, force: true })
return { success: true }
} catch (err) {
return { success: false, error: String(err) }
@@ -370,11 +514,46 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
ipcMain.handle('shell:openExternal', (_, url: string) => shell.openExternal(url))
// App info
+ // System memory (used/available/total bytes).
+ // On macOS, matches Activity Monitor's "Memory Used":
+ // used = wired + active + compressed.
+ ipcMain.handle('system:memory', async () => {
+ const total = os.totalmem()
+
+ if (process.platform === 'darwin') {
+ try {
+ const { stdout } = await pExecFile('vm_stat', [])
+ const pageSizeMatch = stdout.match(/page size of (\d+) bytes/)
+ const pageSize = pageSizeMatch ? parseInt(pageSizeMatch[1]!, 10) : 16384
+
+ const pagesFor = (label: string): number => {
+ const m = stdout.match(new RegExp(`${label}:\\s+(\\d+)`))
+ return m ? parseInt(m[1]!, 10) : 0
+ }
+
+ const active = pagesFor('Pages active')
+ const wired = pagesFor('Pages wired down')
+ const compressed = pagesFor('Pages occupied by compressor')
+
+ const used = (active + wired + compressed) * pageSize
+ const available = Math.max(0, total - used)
+ return { total, used, available }
+ } catch {
+ // Fall back to total - free outside Activity Monitor semantics.
+ }
+ }
+
+ const free = os.freemem()
+ return { total, used: total - free, available: free }
+ })
+
ipcMain.handle('app:info', () => ({
version: app.getVersion(),
userData: app.getPath('userData'),
modelsDir: getSettings(app.getPath('userData')).modelsDir,
- apiUrl: API_BASE_URL
+ apiUrl: API_BASE_URL,
+ platform: process.platform,
+ arch: process.arch,
}))
// Settings — seed HF token into main-process env at startup
@@ -559,6 +738,9 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
// extension type
type?: 'model' | 'process'
entry?: string
+ // Optional top-level fallbacks — applied to each node if not set on the node
+ params_schema?: unknown[]
+ param_defaults?: Record
nodes?: {
id: string
name?: string
@@ -566,9 +748,11 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
inputs?: ('mesh' | 'image' | 'text')[]
output?: 'mesh' | 'image' | 'text'
params_schema?: unknown[]
+ param_defaults?: Record
hf_repo?: string
download_check?: string
hf_skip_prefixes?: string[]
+ hf_include_prefixes?: string[]
}[]
}
@@ -590,10 +774,12 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
input: n.input ?? 'image' as const,
inputs: n.inputs,
output: n.output ?? 'mesh' as const,
- paramsSchema: n.params_schema ?? [],
+ paramsSchema: n.params_schema ?? parsed.params_schema ?? [],
+ paramDefaults: { ...(parsed.param_defaults ?? {}), ...(n.param_defaults ?? {}) },
hfRepo: n.hf_repo,
downloadCheck: n.download_check,
hfSkipPrefixes: n.hf_skip_prefixes,
+ hfIncludePrefixes: n.hf_include_prefixes,
}))
if (parsed.type === 'process') {
@@ -696,25 +882,17 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
const manifestRaw = await readFile(manifestPath, 'utf-8')
const manifest = JSON.parse(manifestRaw) as ParsedManifest
- if (!manifest.id) throw new Error('manifest.json: required field "id" missing')
- const extensionId = assertSafeExtensionId(manifest.id)
+ const { id: rawManifestId, isProcess, entryFile, isPythonProcess, hasNodes } = validateInstallManifest(
+ manifest,
+ {
+ hasEntryFile: (candidate) => existsSync(join(extractDir, candidate)),
+ hasGeneratorFile: () => existsSync(join(extractDir, 'generator.py')),
+ },
+ 'repository',
+ )
+ if (!hasNodes) throw new Error('manifest.json: required field "nodes" missing or empty')
+ const extensionId = assertSafeExtensionId(rawManifestId)
manifest.id = extensionId
- if (!manifest.nodes?.length) throw new Error('manifest.json: required field "nodes" missing or empty')
-
- const isProcess = manifest.type === 'process'
- const entryFile = manifest.entry ?? 'processor.js'
- const isPythonProcess = isProcess && entryFile.endsWith('.py')
-
- if (isProcess) {
- // Process extension validation
- if (!existsSync(join(extractDir, entryFile)))
- throw new Error(`manifest.json: entry file "${entryFile}" missing from repository`)
- } else {
- // Model extension validation
- const generatorPath = join(extractDir, 'generator.py')
- if (!existsSync(generatorPath)) throw new Error('generator.py missing from repository')
- if (!manifest.generator_class) throw new Error('manifest.json: required field "generator_class" missing')
- }
// Override source field with the actual GitHub URL so trust is based on origin
manifest.source = `https://github.com/${owner}/${repo}`
@@ -754,10 +932,18 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
if (existsSync(join(destDir, 'setup.py'))) {
emit({ step: 'setting_up', message: 'Setting up Python environment…' })
const { sm: gpuSm, cudaVersion } = await detectGpuInfo()
- await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => {
- logger.info(`[ext-setup] ${line}`)
- emit({ step: 'setting_up', message: line })
- })
+ try {
+ await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => {
+ logger.info(`[ext-setup] ${line}`)
+ emit({ step: 'setting_up', message: line })
+ })
+ } catch (err) {
+ if (isSetupFailureFatal({ isProcess, isPythonProcess })) {
+ throw new Error(`Extension setup failed: ${err}`)
+ }
+ logger.warn(`[ext-setup] setup.py failed: ${err}`)
+ emit({ step: 'setting_up', message: `Warning: setup failed — ${err}` })
+ }
}
} else if (isProcess) {
// 6b. JS process extension: npm install if package.json present
diff --git a/electron/main/model-downloader.ts b/electron/main/model-downloader.ts
index 4d6f76c..8c5571d 100644
--- a/electron/main/model-downloader.ts
+++ b/electron/main/model-downloader.ts
@@ -13,6 +13,9 @@ export interface DownloadProgress {
fileIndex?: number
totalFiles?: number
status?: string
+ bytesDownloaded?: number
+ totalBytes?: number
+ stalledSeconds?: number
}
export type ProgressCallback = (progress: DownloadProgress) => void
@@ -25,9 +28,12 @@ const PYTHON_API_URL = process.env['PYTHON_API_URL'] ?? 'http://127.0.0.1:8765'
/**
* Check if a model is already downloaded (directory exists and is non-empty).
*/
-export function isModelDownloaded(modelsDir: string, modelId: string): boolean {
+export function isModelDownloaded(modelsDir: string, modelId: string, downloadCheck?: string): boolean {
const modelDir = join(modelsDir, modelId)
if (!existsSync(modelDir)) return false
+ if (downloadCheck && downloadCheck.trim()) {
+ return existsSync(join(modelDir, downloadCheck))
+ }
try {
return readdirSync(modelDir).length > 0
} catch {
@@ -112,12 +118,17 @@ export async function downloadModelFromHF(
modelId: string,
onProgress: ProgressCallback,
skipPrefixes?: string[],
+ includePrefixes?: string[],
): Promise {
const { net } = require('electron')
+ const STALL_TIMEOUT_MS = 120_000
let url = `${PYTHON_API_URL}/model/hf-download?repo_id=${encodeURIComponent(repoId)}&model_id=${encodeURIComponent(modelId)}`
if (skipPrefixes && skipPrefixes.length > 0) {
url += `&skip_prefixes=${encodeURIComponent(JSON.stringify(skipPrefixes))}`
}
+ if (includePrefixes && includePrefixes.length > 0) {
+ url += `&include_prefixes=${encodeURIComponent(JSON.stringify(includePrefixes))}`
+ }
const hfToken = getSettings(app.getPath('userData')).hfToken
if (hfToken) {
url += `&token=${encodeURIComponent(hfToken)}`
@@ -131,8 +142,17 @@ export async function downloadModelFromHF(
const reader = res.body.getReader()
let buffer = ''
+ async function readWithTimeout() {
+ return await Promise.race([
+ reader.read(),
+ new Promise((_, reject) => {
+ setTimeout(() => reject(new Error(`Model download stalled for ${Math.round(STALL_TIMEOUT_MS / 1000)}s`)), STALL_TIMEOUT_MS)
+ }),
+ ])
+ }
+
while (true) {
- const { done, value } = await reader.read()
+ const { done, value } = await readWithTimeout()
if (done) break
buffer += decoder.decode(value, { stream: true })
@@ -149,10 +169,22 @@ export async function downloadModelFromHF(
fileIndex: data.fileIndex,
totalFiles: data.totalFiles,
status: data.status,
+ bytesDownloaded: data.bytesDownloaded,
+ totalBytes: data.totalBytes,
+ stalledSeconds: data.stalledSeconds,
})
+ if (data.paused) throw new Error('Model download paused')
+ if (data.cancelled) throw new Error('Model download cancelled')
if (data.error) throw new Error(`HF download error: ${data.error}`)
} catch (e) {
- if (e instanceof Error && e.message.startsWith('HF download error:')) throw e
+ if (
+ e instanceof Error &&
+ (
+ e.message.startsWith('HF download error:') ||
+ e.message === 'Model download paused' ||
+ e.message === 'Model download cancelled'
+ )
+ ) throw e
}
}
}
diff --git a/electron/main/python-bridge.ts b/electron/main/python-bridge.ts
index f0451ce..94dcfb6 100644
--- a/electron/main/python-bridge.ts
+++ b/electron/main/python-bridge.ts
@@ -51,15 +51,21 @@ export class PythonBridge {
cwd: apiDir,
env: {
...cleanPythonEnv(),
- PYTHONUNBUFFERED: '1',
- // No PYTHONPATH needed — the venv's Python has its own isolated site-packages
- MODELS_DIR: this.resolveModelsDir(),
- WORKSPACE_DIR: this.resolveWorkspaceDir(),
- EXTENSIONS_DIR: this.resolveExtensionsDir(),
- ...(process.env['SELECTED_MODEL_ID'] ? { SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] } : {}),
- HUGGING_FACE_HUB_TOKEN: this.resolveHfToken(),
- HF_TOKEN: this.resolveHfToken(),
- }
+ PYTHONUNBUFFERED: '1',
+ // No PYTHONPATH needed - the venv's Python has its own isolated site-packages
+ MODELS_DIR: this.resolveModelsDir(),
+ WORKSPACE_DIR: this.resolveWorkspaceDir(),
+ EXTENSIONS_DIR: this.resolveExtensionsDir(),
+ SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] ?? '',
+ HUGGING_FACE_HUB_TOKEN: this.resolveHfToken(),
+ HF_TOKEN: this.resolveHfToken(),
+ },
+ // On Unix, put the bridge in its own process group so every subprocess
+ // it spawns (extension runners, etc.) inherits that group. On shutdown
+ // we SIGKILL the whole group (negative PID) to take them all out
+ // together — otherwise children get reparented to launchd and keep
+ // holding MPS-wired memory until the user kills them manually.
+ detached: process.platform !== 'win32',
})
this.process.stdout?.on('data', (data) => {
@@ -82,7 +88,13 @@ export class PythonBridge {
this.ready = false
this.process = null
if (wasReady && !this.intentionalStop) {
- this.getWindow()?.webContents.send('python:crashed', { code })
+ const getWindow = this.getWindow
+ if (!getWindow) return
+ const win = getWindow()
+ const contents = win?.webContents
+ if (contents && !contents.isDestroyed()) {
+ contents.send('python:crashed', { code })
+ }
}
})
@@ -97,8 +109,17 @@ export class PythonBridge {
if (process.platform === 'win32') {
const { execSync } = require('child_process')
try { execSync(`taskkill /PID ${proc.pid} /T /F`) } catch {}
- } else {
- proc.kill('SIGTERM')
+ } else if (proc.pid) {
+ // Kill the entire process group (negative PID) so extension subprocesses
+ // die with the bridge instead of being orphaned to launchd. SIGKILL
+ // rather than SIGTERM: on app quit we want immediate release of Metal
+ // wired memory, not a polite request the subprocess might ignore while
+ // it finishes an operation.
+ try {
+ process.kill(-proc.pid, 'SIGKILL')
+ } catch {
+ try { proc.kill('SIGKILL') } catch {}
+ }
}
console.log('[PythonBridge] Stopped')
}
@@ -114,7 +135,13 @@ export class PythonBridge {
private emitTqdmLog(raw: string): void {
if (/INFO/.test(raw)) return
if (!raw.trim()) return
- this.getWindow()?.webContents.send('python:log', raw.trim())
+ const getWindow = this.getWindow
+ if (!getWindow) return
+ const win = getWindow()
+ const contents = win?.webContents
+ if (contents && !contents.isDestroyed()) {
+ contents.send('python:log', raw.trim())
+ }
}
isReady(): boolean { return this.ready }
diff --git a/electron/preload/index.ts b/electron/preload/index.ts
index c5e455b..c223d8b 100644
--- a/electron/preload/index.ts
+++ b/electron/preload/index.ts
@@ -14,6 +14,12 @@ contextBridge.exposeInMainWorld('electron', {
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
},
+ // System info
+ system: {
+ memory: (): Promise<{ total: number; used: number; available: number }> =>
+ ipcRenderer.invoke('system:memory'),
+ },
+
// Python / FastAPI bridge
python: {
start: (): Promise<{ success: boolean; port?: number; error?: string }> =>
@@ -78,13 +84,28 @@ contextBridge.exposeInMainWorld('electron', {
model: {
export: (args: { outputUrl: string; format: string }) => ipcRenderer.invoke('model:export', args),
listDownloaded: () => ipcRenderer.invoke('model:listDownloaded'),
- isDownloaded: (modelId: string) => ipcRenderer.invoke('model:isDownloaded', modelId),
- download: (repoId: string, modelId: string, skipPrefixes?: string[]) => ipcRenderer.invoke('model:download', { repoId, modelId, skipPrefixes }),
+ isDownloaded: (modelId: string, downloadCheck?: string) => ipcRenderer.invoke('model:isDownloaded', modelId, downloadCheck),
+ download: (repoId: string, modelId: string, skipPrefixes?: string[], includePrefixes?: string[]) =>
+ ipcRenderer.invoke('model:download', { repoId, modelId, skipPrefixes, includePrefixes }),
+ pauseDownload: (modelId: string) => ipcRenderer.invoke('model:pauseDownload', modelId),
+ cancelDownload: (modelId: string) => ipcRenderer.invoke('model:cancelDownload', modelId),
delete: (modelId: string) => ipcRenderer.invoke('model:delete', modelId),
unloadAll: () => ipcRenderer.invoke('model:unloadAll'),
showInFolder: (modelId: string) => ipcRenderer.invoke('model:showInFolder', modelId),
activeDownloads: (): Promise<{ modelId: string; percent: number; file?: string; fileIndex?: number; totalFiles?: number }[]> => ipcRenderer.invoke('model:activeDownloads'),
- onProgress: (cb: (data: { modelId: string; percent: number; file?: string; fileIndex?: number; totalFiles?: number; status?: string }) => void) => {
+ onProgress: (cb: (data: {
+ modelId: string
+ percent: number
+ file?: string
+ fileIndex?: number
+ totalFiles?: number
+ status?: string
+ bytesDownloaded?: number
+ totalBytes?: number
+ stalledSeconds?: number
+ paused?: boolean
+ cancelled?: boolean
+ }) => void) => {
ipcRenderer.on('model:downloadProgress', (_event, data) => cb(data))
},
offProgress: () => ipcRenderer.removeAllListeners('model:downloadProgress')
@@ -92,7 +113,7 @@ contextBridge.exposeInMainWorld('electron', {
// App metadata
app: {
- info: (): Promise<{ version: string; userData: string; modelsDir: string; apiUrl: string }> =>
+ info: (): Promise<{ version: string; userData: string; modelsDir: string; apiUrl: string; platform: string; arch: string }> =>
ipcRenderer.invoke('app:info'),
onError: (cb: (message: string) => void) => {
ipcRenderer.on('app:error', (_event, message) => cb(message))
@@ -191,7 +212,7 @@ contextBridge.exposeInMainWorld('electron', {
// First-run setup
setup: {
- check: (): Promise<{ needed: boolean; defaultDataDir: string }> =>
+ check: (): Promise<{ needed: boolean; defaultDataDir: string; platform: string; arch: string }> =>
ipcRenderer.invoke('setup:check'),
run: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('setup:run'),
diff --git a/package.json b/package.json
index c81592a..8828919 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,9 @@
"build": "node scripts/build-builtins.mjs && electron-vite build",
"preview": "electron-vite preview",
"prepare-resources": "node scripts/download-python-embed.js",
+ "test": "cd api && python3 -m unittest discover -s tests && cd .. && node --test electron/main/*.test.mjs",
"package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder",
+ "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64",
"lint": "eslint ."
},
"dependencies": {
@@ -63,7 +65,10 @@
"filter": [
"**/*",
"!.venv/**/*",
- "!__pycache__/**/*"
+ "!__pycache__/**/*",
+ "!**/build/**/*",
+ "!**/*.pyd",
+ "!**/*.egg-info/**/*"
]
},
{
@@ -93,7 +98,15 @@
},
"mac": {
"target": "dmg",
- "icon": "resources/icons/icon.icns"
+ "icon": "resources/icons/icon.icns",
+ "artifactName": "${productName}-${version}-arm64.${ext}",
+ "category": "public.app-category.graphics-design",
+ "extraResources": [
+ {
+ "from": "resources/python-embed",
+ "to": "python-embed"
+ }
+ ]
},
"linux": {
"target": "AppImage",
diff --git a/src/App.tsx b/src/App.tsx
index 82180ee..9ef3edc 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,6 +4,7 @@ import FirstRunSetup from '@areas/setup/FirstRunSetup'
import MainLayout from '@shared/components/layout/MainLayout'
import { UpdateModal } from '@shared/components/ui/UpdateModal'
import { ErrorModal } from '@shared/components/ui/ErrorModal'
+import { Toast } from '@shared/components/ui/Toast'
export default function App(): JSX.Element {
const { checkSetup, setupStatus, initApp, backendStatus, showError } = useAppStore()
@@ -41,12 +42,14 @@ export default function App(): JSX.Element {
onDismiss={() => setUpdateVersion(null)}
/>
)}
+
>
)
return (
<>
+
>
)
diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx
index 318d235..bb9db4d 100644
--- a/src/areas/generate/GeneratePage.tsx
+++ b/src/areas/generate/GeneratePage.tsx
@@ -285,6 +285,7 @@ export default function GeneratePage(): JSX.Element {
)
const currentJob = useAppStore((s) => s.currentJob)
const apiUrl = useAppStore((s) => s.apiUrl)
+ const showError = useAppStore((s) => s.showError)
const updateCurrentJob = useAppStore((s) => s.updateCurrentJob)
const setCurrentJob = useAppStore((s) => s.setCurrentJob)
const meshStats = useAppStore((s) => s.meshStats)
@@ -327,6 +328,16 @@ export default function GeneratePage(): JSX.Element {
link.click()
}
+ function getOptimizePath(url: string): string {
+ if (url.startsWith('/workspace/')) {
+ return url.slice('/workspace/'.length)
+ }
+ if (url.startsWith('/optimize/serve-file?path=')) {
+ return decodeURIComponent(url.split('path=')[1] ?? '')
+ }
+ return url
+ }
+
async function handleImportMesh() {
const filePath = await window.electron.fs.selectMeshFile()
if (!filePath) return
@@ -354,11 +365,13 @@ export default function GeneratePage(): JSX.Element {
if (!currentJob?.outputUrl) return
setSmoothing(true)
try {
- const path = currentJob.outputUrl.replace('/workspace/', '')
+ const path = getOptimizePath(currentJob.outputUrl)
const { url } = await smoothMesh(path, iterations)
updateCurrentJob({ outputUrl: url })
pushMeshUrl(url)
setOpenPanel(null)
+ } catch (err) {
+ showError(err instanceof Error ? err.message : String(err))
} finally {
setSmoothing(false)
}
@@ -368,11 +381,13 @@ export default function GeneratePage(): JSX.Element {
if (!currentJob?.outputUrl) return
setDecimating(true)
try {
- const path = currentJob.outputUrl.replace('/workspace/', '')
+ const path = getOptimizePath(currentJob.outputUrl)
const { url } = await optimizeMesh(path, targetFaces)
updateCurrentJob({ outputUrl: url })
pushMeshUrl(url)
setOpenPanel(null)
+ } catch (err) {
+ showError(err instanceof Error ? err.message : String(err))
} finally {
setDecimating(false)
}
diff --git a/src/areas/generate/components/GenerationHUD.tsx b/src/areas/generate/components/GenerationHUD.tsx
index 768797a..6a9314e 100644
--- a/src/areas/generate/components/GenerationHUD.tsx
+++ b/src/areas/generate/components/GenerationHUD.tsx
@@ -7,6 +7,41 @@ function formatElapsed(seconds: number): string {
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
+// Convert tqdm bar text to a clean "Verbing (N%)" form. Examples:
+// "Diffusion Sampling: 60%|████| 3/5 [..." → "Diffusing (60%)"
+// "Volume Decoding: 42%|██▏ | 1752/4200 [..." → "Decoding (42%)"
+// "Loading pipeline components...: 100%|███| 4/4" → "Loading (100%)"
+// Anything that isn't a tqdm progress line returns null so the caller
+// leaves the HUD's sub-line untouched instead of filling it with noise.
+const PHASE_VERB: Array<[RegExp, string]> = [
+ [/diffusion|sampling/i, 'Generating'],
+ [/volume|flashvdm|decoding/i, 'Decoding'],
+ [/surface|extract/i, 'Extracting'],
+ [/loading|download/i, 'Loading'],
+]
+
+function parseTqdmLine(line: string): string | null {
+ const m = line.match(/^(.+?):\s*(\d+)%\|/)
+ if (!m) return null
+ const desc = m[1].replace(/\.+$/, '').trim()
+ const pct = m[2]
+ const verb =
+ PHASE_VERB.find(([re]) => re.test(desc))?.[1] ??
+ desc.replace(/^./, (c) => c.toUpperCase())
+ return `${verb} (${pct}%)`
+}
+
+function parseProgressFromStderr(chunk: string): string | null {
+ // One chunk may contain multiple tqdm ticks separated by \r or \n;
+ // show the most recent tqdm-like line.
+ const lines = chunk.split(/[\r\n]+/).filter(Boolean)
+ for (let i = lines.length - 1; i >= 0; i--) {
+ const parsed = parseTqdmLine(lines[i]!)
+ if (parsed) return parsed
+ }
+ return null
+}
+
export default function GenerationHUD(): JSX.Element | null {
const { currentJob, reset } = useGeneration()
const [elapsed, setElapsed] = useState(0)
@@ -37,11 +72,16 @@ export default function GenerationHUD(): JSX.Element | null {
}
}, [isActive, currentJob?.createdAt])
- // tqdm log listener
+ // tqdm log listener — parse to "Verbing (N%)" and skip non-progress noise
useEffect(() => {
if (isActive) {
setTqdmLog(null)
- window.electron.python.onLog((line) => setTqdmLog(line))
+ window.electron.python.onLog((line) => {
+ const parsed = parseProgressFromStderr(line)
+ if (parsed !== null) {
+ setTqdmLog((prev) => (prev === parsed ? prev : parsed))
+ }
+ })
return () => { window.electron.python.offLog(); setTqdmLog(null) }
}
}, [isActive])
@@ -96,9 +136,9 @@ export default function GenerationHUD(): JSX.Element | null {
Generation failed
-
+
{error}
-
+