#!/usr/bin/env python3 """ LocalSpace Deployer — Hugging Face Space (Vanilla) Drag a DMG. It gets OPENED on the server: extracted, inspected, and its app bundle metadata is displayed. The Space itself hosts the DMG contents. Pure FastAPI + HTML/JS. No Gradio, no Streamlit, no API keys. Public by default. """ from __future__ import annotations import hashlib import json import os import plistlib import re import shutil import subprocess import time import uuid from pathlib import Path from typing import Any from fastapi import FastAPI, File, Request, UploadFile from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles # Try biplist for binary plists, fall back to plistlib try: import biplist HAS_BIPLIST = True except ImportError: HAS_BIPLIST = False DATA_DIR = Path("data") DATA_DIR.mkdir(exist_ok=True) UPLOAD_DIR = DATA_DIR / "uploads" UPLOAD_DIR.mkdir(exist_ok=True) EXTRACT_DIR = DATA_DIR / "extracted" EXTRACT_DIR.mkdir(exist_ok=True) DB_PATH = DATA_DIR / "apps.json" app = FastAPI(title="LocalSpace Deployer") app.mount("/static", StaticFiles(directory="static"), name="static") _store: dict[str, dict[str, Any]] = {} def _load_db() -> None: global _store if DB_PATH.exists(): with open(DB_PATH) as f: _store = json.load(f) else: _store = {} def _save_db() -> None: with open(DB_PATH, "w") as f: json.dump(_store, f, indent=2) def _slugify(name: str) -> str: return re.sub(r"[^a-z0-9]+", "-", name.lower().replace(".dmg", "")).strip("-") def _hash_file(path: Path) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return h.hexdigest() def _read_plist(path: Path) -> dict[str, Any]: """Read a plist file (XML or binary).""" try: with open(path, "rb") as f: data = f.read() try: return plistlib.loads(data) except Exception: pass if HAS_BIPLIST: try: return biplist.readPlistFromString(data) except Exception: pass except Exception: pass return {} def _find_app_bundles(root: Path) -> list[Path]: """Find all .app directories under root.""" apps = [] for p in root.rglob("*.app"): if p.is_dir(): apps.append(p) return apps def _extract_dmg(dmg_path: Path, dest: Path) -> bool: """Extract DMG using 7z. Returns True on success.""" try: dest.mkdir(parents=True, exist_ok=True) result = subprocess.run( ["7z", "x", "-y", "-o" + str(dest), str(dmg_path)], capture_output=True, text=True, timeout=120, ) return result.returncode == 0 except Exception: return False def _inspect_app_bundle(app_path: Path) -> dict[str, Any]: """Read Info.plist and extract metadata from an .app bundle.""" info_plist = app_path / "Contents" / "Info.plist" if not info_plist.exists(): info_plist = app_path / "Info.plist" info = _read_plist(info_plist) if info_plist.exists() else {} icon_name = info.get("CFBundleIconFile", "") icon_path = None if icon_name: icons_dir = app_path / "Contents" / "Resources" if icons_dir.exists(): icns = icons_dir / (icon_name if icon_name.endswith(".icns") else icon_name + ".icns") if icns.exists(): icon_path = str(icns) files = [] if app_path.exists(): for f in sorted(app_path.rglob("*")): if f.is_file(): try: rel = str(f.relative_to(app_path)) files.append(rel) except ValueError: pass return { "bundle_name": app_path.name, "bundle_id": info.get("CFBundleIdentifier", ""), "display_name": info.get("CFBundleDisplayName", "") or info.get("CFBundleName", ""), "version": info.get("CFBundleShortVersionString", "") or info.get("CFBundleVersion", ""), "min_os_version": info.get("LSMinimumSystemVersion", ""), "executable": info.get("CFBundleExecutable", ""), "icon_path": icon_path, "info_plist": info, "file_count": len(files), "files": files[:200], } def _open_dmg(app_id: str, dmg_path: Path) -> dict[str, Any]: """Open a DMG: extract it, find apps, inspect them.""" extract_to = EXTRACT_DIR / app_id if extract_to.exists(): shutil.rmtree(extract_to) success = _extract_dmg(dmg_path, extract_to) if not success: return {"opened": False, "error": "Extraction failed. DMG may be encrypted or use an unsupported format."} apps = _find_app_bundles(extract_to) inspected = [_inspect_app_bundle(a) for a in apps] # Build top-level tree tree = [] for item in sorted(extract_to.iterdir()): tree.append({ "name": item.name, "type": "directory" if item.is_dir() else "file", "size": item.stat().st_size if item.is_file() else 0, }) # Discover all HTML files recursively for iframe preview html_files: list[dict[str, Any]] = [] for f in extract_to.rglob("*.html"): rel = f.relative_to(extract_to) depth = len(rel.parts) html_files.append({"path": str(rel), "depth": depth, "size": f.stat().st_size}) # Prefer shallowest HTML, then shortest path, then largest html_files.sort(key=lambda x: (x["depth"], len(x["path"]), -x["size"])) return { "opened": True, "extracted_path": str(extract_to), "apps_found": len(inspected), "apps": inspected, "tree": tree, "html_files": html_files[:50], "has_preview": bool(html_files), "preview_entry": html_files[0]["path"] if html_files else None, } _load_db() @app.get("/", response_class=HTMLResponse) def index(request: Request) -> HTMLResponse: with open("static/index.html") as f: return HTMLResponse(content=f.read()) @app.post("/api/upload") async def upload_file(file: UploadFile = File(...)) -> JSONResponse: if not file.filename or not file.filename.lower().endswith(".dmg"): return JSONResponse({"error": "Only .dmg files are accepted."}, status_code=400) app_id = str(uuid.uuid4()) slug = _slugify(file.filename) base_slug = slug counter = 1 while any(a.get("slug") == slug for a in _store.values()): slug = f"{base_slug}-{counter}" counter += 1 dest = UPLOAD_DIR / f"{app_id}.dmg" with open(dest, "wb") as f: while True: chunk = await file.read(65536) if not chunk: break f.write(chunk) sha256 = _hash_file(dest) size = dest.stat().st_size opened = _open_dmg(app_id, dest) entry = { "app_id": app_id, "slug": slug, "filename": file.filename, "size": size, "sha256": sha256, "download_url": f"/api/download/{app_id}", "opened": opened.get("opened", False), "apps_found": opened.get("apps_found", 0), "apps": opened.get("apps", []), "tree": opened.get("tree", []), "html_files": opened.get("html_files", []), "has_preview": opened.get("has_preview", False), "preview_entry": opened.get("preview_entry", None), "created_at": time.time(), } _store[app_id] = entry _save_db() return JSONResponse({ "app": entry, "message": "DMG uploaded and opened." if entry["opened"] else "DMG uploaded but could not be opened.", }, status_code=201) @app.get("/api/apps") def list_apps() -> JSONResponse: apps = sorted(_store.values(), key=lambda a: a["created_at"], reverse=True) return JSONResponse({"apps": apps}) @app.get("/app/{app_id}", response_class=HTMLResponse) def serve_app(app_id: str) -> HTMLResponse: """Serve a published app as a full-page iframe viewer.""" entry = _store.get(app_id) if not entry: return HTMLResponse("
Loading {url}...
''') return bundle_root def _create_dmg_from_app(app_bundle: Path, output_name: str) -> Path | None: """Best-effort DMG creation. Returns path to DMG or None.""" dmg_path = WEBAPP_DIR / f"{output_name}.dmg" # Try genisoimage + dmg if available (Linux) try: iso_path = WEBAPP_DIR / f"{output_name}.iso" result = subprocess.run( ["genisoimage", "-D", "-V", output_name, "-no-pad", "-r", "-apple", "-o", str(iso_path), str(app_bundle)], capture_output=True, text=True, timeout=30, ) if result.returncode == 0: # Try converting ISO to DMG dmg_result = subprocess.run( ["dmg", "iso", str(iso_path), str(dmg_path)], capture_output=True, text=True, timeout=30, ) if dmg_result.returncode == 0 and dmg_path.exists(): iso_path.unlink(missing_ok=True) return dmg_path except FileNotFoundError: pass except Exception: pass # Fallback: create a ZIP that user can extract on Mac and run hdiutil zip_path = WEBAPP_DIR / f"{output_name}.zip" shutil.make_archive( base_name=str(WEBAPP_DIR / output_name), format="zip", root_dir=str(app_bundle.parent), base_dir=app_bundle.name, ) if zip_path.exists(): return zip_path return None @app.post("/api/create-webapp") async def create_webapp(request: Request) -> JSONResponse: """Create a macOS app bundle + DMG from a URL.""" try: data = await request.json() except Exception: return JSONResponse({"error": "Invalid JSON"}, status_code=400) url = data.get("url", "").strip() app_name = data.get("app_name", "WebApp").strip() bundle_id = data.get("bundle_id", "app.localspace.webapp").strip() version = data.get("version", "1.0.0").strip() if not url: return JSONResponse({"error": "URL is required"}, status_code=400) if not app_name: return JSONResponse({"error": "App name is required"}, status_code=400) # Sanitize app_name_safe = re.sub(r'[^a-zA-Z0-9 ]+', '', app_name).strip() if not app_name_safe: app_name_safe = "WebApp" try: bundle = _create_app_bundle(url, app_name_safe, bundle_id, version) pkg = _create_dmg_from_app(bundle, app_name_safe.replace(" ", "-")) if pkg is None: return JSONResponse({"error": "Failed to create package"}, status_code=500) pkg_id = str(uuid.uuid4()) dest = UPLOAD_DIR / f"{pkg_id}{pkg.suffix}" shutil.copy2(pkg, dest) entry = { "app_id": pkg_id, "slug": _slugify(app_name_safe), "filename": dest.name, "size": dest.stat().st_size, "sha256": _hash_file(dest), "download_url": f"/api/download/{pkg_id}", "source_url": url, "app_name": app_name_safe, "bundle_id": bundle_id, "version": version, "is_webapp": True, "created_at": time.time(), } _store[pkg_id] = entry _save_db() return JSONResponse({ "app": entry, "message": f"'{app_name_safe}' packaged. Download and extract on macOS, then run 'hdiutil create -srcfolder {app_name_safe}.app {app_name_safe}.dmg' to convert to DMG." if pkg.suffix == ".zip" else f"'{app_name_safe}' DMG created.", }, status_code=201) except Exception as exc: return JSONResponse({"error": str(exc)}, status_code=500) @app.delete("/api/apps/{app_id}") def delete_app(app_id: str) -> JSONResponse: entry = _store.pop(app_id, None) if entry: dmg = UPLOAD_DIR / f"{app_id}.dmg" if dmg.exists(): dmg.unlink() extracted = EXTRACT_DIR / app_id if extracted.exists(): shutil.rmtree(extracted) _save_db() return JSONResponse({"ok": True})