Upload folder using huggingface_hub

#1
by luguog - opened
Files changed (6) hide show
  1. Dockerfile +7 -4
  2. README.md +61 -5
  3. __pycache__/app.cpython-313.pyc +0 -0
  4. app.py +863 -61
  5. requirements.txt +8 -5
  6. static/index.html +374 -1489
Dockerfile CHANGED
@@ -1,15 +1,18 @@
1
  FROM python:3.11-slim
2
 
3
- RUN apt-get update && apt-get install -y p7zip-full genisoimage && rm -rf /var/lib/apt/lists/*
 
 
4
 
5
  WORKDIR /app
6
 
7
  COPY requirements.txt .
8
  RUN pip install --no-cache-dir -r requirements.txt
9
 
10
- COPY app.py .
11
- COPY static/ static/
12
 
13
- ENV PYTHONUNBUFFERED=1
 
 
14
 
15
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
  FROM python:3.11-slim
2
 
3
+ RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ p7zip-full \
5
+ && rm -rf /var/lib/apt/lists/*
6
 
7
  WORKDIR /app
8
 
9
  COPY requirements.txt .
10
  RUN pip install --no-cache-dir -r requirements.txt
11
 
12
+ COPY . .
 
13
 
14
+ RUN mkdir -p data/uploads data/extracted data/webapps static
15
+
16
+ EXPOSE 7860
17
 
18
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,66 @@
1
  ---
2
- title: Localspace Deployer
3
- emoji: 🐨
4
- colorFrom: indigo
5
- colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: LocalSpace Deployer
3
+ emoji: πŸ“¦
4
+ colorFrom: gray
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # LocalSpace Deployer
12
+
13
+ Drag a DMG, GGUF, or ZIP. It gets opened on the server: extracted, inspected,
14
+ and served. Pure FastAPI + HTML/JS. No Gradio, no Streamlit, no API keys.
15
+
16
+ ## Features
17
+
18
+ - **DMG upload & extraction** β€” 7z-based server-side extraction
19
+ - **App bundle inspection** β€” reads Info.plist, lists files, finds icons
20
+ - **GGUF model inspection** β€” parses GGUF header, tensor count, metadata KV pairs
21
+ - **GGUF inference** β€” OpenAI-compatible chat/completion via llama-cpp-python (optional)
22
+ - **Tiny Netlify** β€” upload a static site ZIP, get a hosted URL with base-tag injection
23
+ - **ChatGPT export β†’ dApp** β€” upload conversations.json, get a browsable dApp with search
24
+ - **iframe preview** β€” auto-discovers HTML files and serves them in a full-page viewer
25
+ - **Web App β†’ DMG** β€” paste a URL, get back a macOS .app bundle wrapper
26
+ - **Persistent storage** β€” auto-detects /data mount on HF Spaces
27
+ - **SHA-256 hashing** β€” every upload is hash-verified
28
+
29
+ ## API
30
+
31
+ | Endpoint | Method | Description |
32
+ |---|---|---|
33
+ | `/` | GET | Landing page + deployer UI |
34
+ | `/api/upload` | POST | Upload DMG or GGUF (multipart file) |
35
+ | `/api/deploy-site` | POST | Deploy static site from ZIP |
36
+ | `/api/import/chatgpt` | POST | Import ChatGPT export (JSON or ZIP) |
37
+ | `/api/apps` | GET | List all artifacts |
38
+ | `/api/apps/{id}` | GET | Get single artifact metadata |
39
+ | `/api/apps/{id}` | DELETE | Delete artifact |
40
+ | `/app/{id}` | GET | Full-page iframe preview (DMG) |
41
+ | `/site/{id}/` | GET | Visit deployed static site |
42
+ | `/site/{id}/{path}` | GET | Serve file from deployed site |
43
+ | `/dapp/{id}/` | GET | Visit ChatGPT dApp |
44
+ | `/api/preview/{id}/{path}` | GET | Serve extracted DMG file |
45
+ | `/api/browse/{id}/{path}` | GET | Browse extracted file tree |
46
+ | `/api/download/{id}` | GET | Download original uploaded file |
47
+ | `/api/create-webapp` | POST | Create .app from URL |
48
+ | `/api/storage/status` | GET | Check persistent storage status |
49
+ | `/api/inference/status` | GET | Check inference availability |
50
+ | `/api/inference/load/{id}` | POST | Load GGUF model into memory |
51
+ | `/api/inference/chat/{id}` | POST | OpenAI-compatible chat completion |
52
+ | `/api/inference/completion/{id}` | POST | Text completion |
53
+
54
+ ## Enable GGUF Inference
55
+
56
+ Uncomment `llama-cpp-python` in `requirements.txt` and rebuild the Space.
57
+ For GPU acceleration, use `llama-cpp-python[cuda]` and set the Space hardware to GPU.
58
+
59
+ ## Run locally
60
+
61
+ ```bash
62
+ pip install -r requirements.txt
63
+ uvicorn app:app --host 0.0.0.0 --port 7860
64
+ ```
65
+
66
+ Requires `7z` (p7zip-full) for DMG extraction.
__pycache__/app.cpython-313.pyc ADDED
Binary file (71.1 kB). View file
 
app.py CHANGED
@@ -1,11 +1,9 @@
1
  #!/usr/bin/env python3
2
  """
3
  LocalSpace Deployer β€” Hugging Face Space (Vanilla)
4
-
5
- Drag a DMG. It gets OPENED on the server: extracted, inspected, and its
6
- app bundle metadata is displayed. The Space itself hosts the DMG contents.
7
-
8
- Pure FastAPI + HTML/JS. No Gradio, no Streamlit, no API keys.
9
  Public by default.
10
  """
11
  from __future__ import annotations
@@ -16,14 +14,16 @@ import os
16
  import plistlib
17
  import re
18
  import shutil
 
19
  import subprocess
20
  import time
21
  import uuid
 
22
  from pathlib import Path
23
  from typing import Any
24
 
25
  from fastapi import FastAPI, File, Request, UploadFile
26
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
27
  from fastapi.staticfiles import StaticFiles
28
 
29
  # Try biplist for binary plists, fall back to plistlib
@@ -33,16 +33,42 @@ try:
33
  except ImportError:
34
  HAS_BIPLIST = False
35
 
36
- DATA_DIR = Path("data")
37
- DATA_DIR.mkdir(exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  UPLOAD_DIR = DATA_DIR / "uploads"
39
  UPLOAD_DIR.mkdir(exist_ok=True)
40
  EXTRACT_DIR = DATA_DIR / "extracted"
41
  EXTRACT_DIR.mkdir(exist_ok=True)
 
 
 
 
42
  DB_PATH = DATA_DIR / "apps.json"
43
 
 
 
 
44
  app = FastAPI(title="LocalSpace Deployer")
45
- app.mount("/static", StaticFiles(directory="static"), name="static")
 
 
 
 
46
 
47
  _store: dict[str, dict[str, Any]] = {}
48
 
@@ -62,7 +88,7 @@ def _save_db() -> None:
62
 
63
 
64
  def _slugify(name: str) -> str:
65
- return re.sub(r"[^a-z0-9]+", "-", name.lower().replace(".dmg", "")).strip("-")
66
 
67
 
68
  def _hash_file(path: Path) -> str:
@@ -73,6 +99,14 @@ def _hash_file(path: Path) -> str:
73
  return h.hexdigest()
74
 
75
 
 
 
 
 
 
 
 
 
76
  def _read_plist(path: Path) -> dict[str, Any]:
77
  """Read a plist file (XML or binary)."""
78
  try:
@@ -157,6 +191,95 @@ def _inspect_app_bundle(app_path: Path) -> dict[str, Any]:
157
  }
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  def _open_dmg(app_id: str, dmg_path: Path) -> dict[str, Any]:
161
  """Open a DMG: extract it, find apps, inspect them."""
162
  extract_to = EXTRACT_DIR / app_id
@@ -211,8 +334,15 @@ def index(request: Request) -> HTMLResponse:
211
 
212
  @app.post("/api/upload")
213
  async def upload_file(file: UploadFile = File(...)) -> JSONResponse:
214
- if not file.filename or not file.filename.lower().endswith(".dmg"):
215
- return JSONResponse({"error": "Only .dmg files are accepted."}, status_code=400)
 
 
 
 
 
 
 
216
 
217
  app_id = str(uuid.uuid4())
218
  slug = _slugify(file.filename)
@@ -222,7 +352,8 @@ async def upload_file(file: UploadFile = File(...)) -> JSONResponse:
222
  slug = f"{base_slug}-{counter}"
223
  counter += 1
224
 
225
- dest = UPLOAD_DIR / f"{app_id}.dmg"
 
226
  with open(dest, "wb") as f:
227
  while True:
228
  chunk = await file.read(65536)
@@ -233,31 +364,51 @@ async def upload_file(file: UploadFile = File(...)) -> JSONResponse:
233
  sha256 = _hash_file(dest)
234
  size = dest.stat().st_size
235
 
236
- opened = _open_dmg(app_id, dest)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
- entry = {
239
- "app_id": app_id,
240
- "slug": slug,
241
- "filename": file.filename,
242
- "size": size,
243
- "sha256": sha256,
244
- "download_url": f"/api/download/{app_id}",
245
- "opened": opened.get("opened", False),
246
- "apps_found": opened.get("apps_found", 0),
247
- "apps": opened.get("apps", []),
248
- "tree": opened.get("tree", []),
249
- "html_files": opened.get("html_files", []),
250
- "has_preview": opened.get("has_preview", False),
251
- "preview_entry": opened.get("preview_entry", None),
252
- "created_at": time.time(),
253
- }
254
  _store[app_id] = entry
255
  _save_db()
256
 
257
- return JSONResponse({
258
- "app": entry,
259
- "message": "DMG uploaded and opened." if entry["opened"] else "DMG uploaded but could not be opened.",
260
- }, status_code=201)
261
 
262
 
263
  @app.get("/api/apps")
@@ -340,18 +491,16 @@ def download_app(app_id: str):
340
  if not entry:
341
  return JSONResponse({"error": "Not found"}, status_code=404)
342
 
343
- path = UPLOAD_DIR / f"{app_id}.dmg"
 
344
  if not path.exists():
345
  return JSONResponse({
346
  "error": "File not found on disk",
347
- "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload the DMG."
348
  }, status_code=404)
349
 
350
- return FileResponse(
351
- path=path,
352
- filename=entry["filename"],
353
- media_type="application/x-apple-diskimage",
354
- )
355
 
356
 
357
  @app.get("/api/browse/{app_id}/{path:path}")
@@ -367,7 +516,7 @@ def browse_extracted(app_id: str, path: str):
367
  if not base.exists():
368
  return JSONResponse({
369
  "error": "Extracted files not found",
370
- "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload the DMG."
371
  }, status_code=404)
372
 
373
  try:
@@ -404,16 +553,14 @@ def preview_content(app_id: str, path: str):
404
  if not base.exists():
405
  return JSONResponse({
406
  "error": "Extracted files not found",
407
- "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload the DMG."
408
  }, status_code=404)
409
 
410
  if path:
411
  target = base / path
412
  else:
413
- # Default to index.html if no path
414
  target = base / "index.html"
415
  if not target.exists():
416
- # Find any HTML file
417
  for f in base.rglob("*.html"):
418
  target = f
419
  break
@@ -421,14 +568,12 @@ def preview_content(app_id: str, path: str):
421
  if not target.exists():
422
  return JSONResponse({"error": "Not found"}, status_code=404)
423
 
424
- # Security check
425
  try:
426
  target.resolve().relative_to(base.resolve())
427
  except ValueError:
428
  return JSONResponse({"error": "Access denied"}, status_code=403)
429
 
430
  if target.is_dir():
431
- # Look for index.html in directory
432
  idx = target / "index.html"
433
  if idx.exists():
434
  target = idx
@@ -453,10 +598,8 @@ def preview_content(app_id: str, path: str):
453
  suffix = target.suffix.lower()
454
  media_type = mime_types.get(suffix, "application/octet-stream")
455
 
456
- # For HTML, inject base tag to handle relative paths
457
  if media_type == "text/html":
458
  content = target.read_text(errors="replace")
459
- # Inject base tag after <head>
460
  if "<base" not in content:
461
  rel_dir = str(target.parent.relative_to(base)) if target.parent != base else ""
462
  base_tag = f'<base href="/api/preview/{app_id}/{rel_dir}/">' if rel_dir else f'<base href="/api/preview/{app_id}/">'
@@ -485,7 +628,6 @@ def _create_app_bundle(url: str, app_name: str, bundle_id: str, version: str) ->
485
  macos.mkdir(parents=True)
486
  resources.mkdir(parents=True)
487
 
488
- # Info.plist
489
  plist = {
490
  "CFBundleDevelopmentRegion": "en",
491
  "CFBundleExecutable": app_name.replace(" ", ""),
@@ -501,7 +643,6 @@ def _create_app_bundle(url: str, app_name: str, bundle_id: str, version: str) ->
501
  with open(contents / "Info.plist", "wb") as f:
502
  plistlib.dump(plist, f)
503
 
504
- # Wrapper script that opens URL
505
  script_path = macos / app_name.replace(" ", "")
506
  script_content = f'''#!/bin/bash
507
  # Auto-generated web app wrapper
@@ -509,14 +650,12 @@ URL="{url}"
509
  if command -v open >/dev/null 2>&1; then
510
  open "$URL"
511
  else
512
- # Fallback for Linux testing
513
  xdg-open "$URL" 2>/dev/null || python3 -m webbrowser "$URL"
514
  fi
515
  '''
516
  script_path.write_text(script_content)
517
  script_path.chmod(0o755)
518
 
519
- # Create a local HTML file as backup/embedded view
520
  html_path = resources / "index.html"
521
  html_path.write_text(f'''<!DOCTYPE html>
522
  <html><head><meta charset="utf-8"><title>{app_name}</title>
@@ -531,7 +670,6 @@ def _create_dmg_from_app(app_bundle: Path, output_name: str) -> Path | None:
531
  """Best-effort DMG creation. Returns path to DMG or None."""
532
  dmg_path = WEBAPP_DIR / f"{output_name}.dmg"
533
 
534
- # Try genisoimage + dmg if available (Linux)
535
  try:
536
  iso_path = WEBAPP_DIR / f"{output_name}.iso"
537
  result = subprocess.run(
@@ -540,7 +678,6 @@ def _create_dmg_from_app(app_bundle: Path, output_name: str) -> Path | None:
540
  capture_output=True, text=True, timeout=30,
541
  )
542
  if result.returncode == 0:
543
- # Try converting ISO to DMG
544
  dmg_result = subprocess.run(
545
  ["dmg", "iso", str(iso_path), str(dmg_path)],
546
  capture_output=True, text=True, timeout=30,
@@ -553,7 +690,6 @@ def _create_dmg_from_app(app_bundle: Path, output_name: str) -> Path | None:
553
  except Exception:
554
  pass
555
 
556
- # Fallback: create a ZIP that user can extract on Mac and run hdiutil
557
  zip_path = WEBAPP_DIR / f"{output_name}.zip"
558
  shutil.make_archive(
559
  base_name=str(WEBAPP_DIR / output_name),
@@ -585,7 +721,6 @@ async def create_webapp(request: Request) -> JSONResponse:
585
  if not app_name:
586
  return JSONResponse({"error": "App name is required"}, status_code=400)
587
 
588
- # Sanitize
589
  app_name_safe = re.sub(r'[^a-zA-Z0-9 ]+', '', app_name).strip()
590
  if not app_name_safe:
591
  app_name_safe = "WebApp"
@@ -605,7 +740,9 @@ async def create_webapp(request: Request) -> JSONResponse:
605
  "app_id": pkg_id,
606
  "slug": _slugify(app_name_safe),
607
  "filename": dest.name,
 
608
  "size": dest.stat().st_size,
 
609
  "sha256": _hash_file(dest),
610
  "download_url": f"/api/download/{pkg_id}",
611
  "source_url": url,
@@ -620,21 +757,686 @@ async def create_webapp(request: Request) -> JSONResponse:
620
 
621
  return JSONResponse({
622
  "app": entry,
623
- "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.",
624
  }, status_code=201)
625
  except Exception as exc:
626
  return JSONResponse({"error": str(exc)}, status_code=500)
627
 
628
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  @app.delete("/api/apps/{app_id}")
630
  def delete_app(app_id: str) -> JSONResponse:
631
  entry = _store.pop(app_id, None)
632
  if entry:
633
- dmg = UPLOAD_DIR / f"{app_id}.dmg"
634
- if dmg.exists():
635
- dmg.unlink()
 
 
 
636
  extracted = EXTRACT_DIR / app_id
637
  if extracted.exists():
638
  shutil.rmtree(extracted)
 
 
 
 
 
 
 
 
 
 
639
  _save_db()
640
  return JSONResponse({"ok": True})
 
1
  #!/usr/bin/env python3
2
  """
3
  LocalSpace Deployer β€” Hugging Face Space (Vanilla)
4
+ Drag a DMG or GGUF. It gets OPENED on the server: extracted, inspected, and its
5
+ app bundle metadata or model metadata is displayed. The Space itself hosts the
6
+ contents. Pure FastAPI + HTML/JS. No Gradio, no Streamlit, no API keys.
 
 
7
  Public by default.
8
  """
9
  from __future__ import annotations
 
14
  import plistlib
15
  import re
16
  import shutil
17
+ import struct
18
  import subprocess
19
  import time
20
  import uuid
21
+ import zipfile
22
  from pathlib import Path
23
  from typing import Any
24
 
25
  from fastapi import FastAPI, File, Request, UploadFile
26
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
27
  from fastapi.staticfiles import StaticFiles
28
 
29
  # Try biplist for binary plists, fall back to plistlib
 
33
  except ImportError:
34
  HAS_BIPLIST = False
35
 
36
+ # Try llama-cpp-python for GGUF inference (optional)
37
+ try:
38
+ from llama_cpp import Llama as _Llama
39
+ HAS_LLAMA = True
40
+ except ImportError:
41
+ HAS_LLAMA = False
42
+
43
+ # ─── Persistent storage detection ──────────────────────────────────────────
44
+ # HF Spaces mounts persistent storage at /data when enabled.
45
+ # Fall back to local ./data when running outside HF or without persistent storage.
46
+ _PERSISTENT = Path("/data")
47
+ if _PERSISTENT.exists() and _PERSISTENT.is_dir() and os.access(_PERSISTENT, os.W_OK):
48
+ DATA_DIR = _PERSISTENT / "localspace"
49
+ else:
50
+ DATA_DIR = Path("data")
51
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
52
+
53
  UPLOAD_DIR = DATA_DIR / "uploads"
54
  UPLOAD_DIR.mkdir(exist_ok=True)
55
  EXTRACT_DIR = DATA_DIR / "extracted"
56
  EXTRACT_DIR.mkdir(exist_ok=True)
57
+ SITES_DIR = DATA_DIR / "sites"
58
+ SITES_DIR.mkdir(exist_ok=True)
59
+ DAPPS_DIR = DATA_DIR / "dapps"
60
+ DAPPS_DIR.mkdir(exist_ok=True)
61
  DB_PATH = DATA_DIR / "apps.json"
62
 
63
+ # Inference model cache (in-memory)
64
+ _llm_cache: dict[str, Any] = {}
65
+
66
  app = FastAPI(title="LocalSpace Deployer")
67
+
68
+ # Ensure static dir exists
69
+ STATIC_DIR = Path("static")
70
+ STATIC_DIR.mkdir(exist_ok=True)
71
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
72
 
73
  _store: dict[str, dict[str, Any]] = {}
74
 
 
88
 
89
 
90
  def _slugify(name: str) -> str:
91
+ return re.sub(r"[^a-z0-9]+", "-", name.lower().replace(".dmg", "").replace(".gguf", "").replace(".zip", "").replace(".json", "")).strip("-")
92
 
93
 
94
  def _hash_file(path: Path) -> str:
 
99
  return h.hexdigest()
100
 
101
 
102
+ def _format_bytes(n: int) -> str:
103
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
104
+ if n < 1024:
105
+ return f"{n:.1f} {unit}"
106
+ n /= 1024
107
+ return f"{n:.1f} PB"
108
+
109
+
110
  def _read_plist(path: Path) -> dict[str, Any]:
111
  """Read a plist file (XML or binary)."""
112
  try:
 
191
  }
192
 
193
 
194
+ # ─── GGUF parsing ───────────────────────────────────────────────────────────
195
+
196
+ GGUF_MAGIC = 0x46554747 # "GGUF" in little-endian
197
+ GGUF_TYPE_MAP = {
198
+ 0: "UINT8", 1: "INT8", 2: "UINT16", 3: "INT16",
199
+ 4: "UINT32", 5: "INT32", 6: "FLOAT32", 7: "BOOL",
200
+ 8: "STRING", 9: "ARRAY", 10: "UINT64", 11: "INT64", 12: "FLOAT64",
201
+ }
202
+
203
+
204
+ def _read_gguf_string(f) -> str:
205
+ n = struct.unpack("<Q", f.read(8))[0]
206
+ return f.read(n).decode("utf-8", errors="replace")
207
+
208
+
209
+ def _read_gguf_value(f, vtype: int) -> Any:
210
+ if vtype == 0: return struct.unpack("<B", f.read(1))[0]
211
+ if vtype == 1: return struct.unpack("<b", f.read(1))[0]
212
+ if vtype == 2: return struct.unpack("<H", f.read(2))[0]
213
+ if vtype == 3: return struct.unpack("<h", f.read(2))[0]
214
+ if vtype == 4: return struct.unpack("<I", f.read(4))[0]
215
+ if vtype == 5: return struct.unpack("<i", f.read(4))[0]
216
+ if vtype == 6: return struct.unpack("<f", f.read(4))[0]
217
+ if vtype == 7: return struct.unpack("<?", f.read(1))[0]
218
+ if vtype == 8: return _read_gguf_string(f)
219
+ if vtype == 10: return struct.unpack("<Q", f.read(8))[0]
220
+ if vtype == 11: return struct.unpack("<q", f.read(8))[0]
221
+ if vtype == 12: return struct.unpack("<d", f.read(8))[0]
222
+ if vtype == 9:
223
+ inner = struct.unpack("<I", f.read(4))[0]
224
+ n = struct.unpack("<Q", f.read(8))[0]
225
+ return {"type": GGUF_TYPE_MAP.get(inner, str(inner)), "count": n}
226
+ return None
227
+
228
+
229
+ def _inspect_gguf(gguf_path: Path) -> dict[str, Any]:
230
+ """Parse GGUF header: version, tensor count, metadata KV pairs."""
231
+ try:
232
+ with open(gguf_path, "rb") as f:
233
+ magic = struct.unpack("<I", f.read(4))[0]
234
+ if magic != GGUF_MAGIC:
235
+ return {"valid": False, "error": f"Not a GGUF file (magic={magic:#x})"}
236
+
237
+ version = struct.unpack("<I", f.read(4))[0]
238
+ tensor_count = struct.unpack("<Q", f.read(8))[0]
239
+ kv_count = struct.unpack("<Q", f.read(8))[0]
240
+
241
+ metadata = {}
242
+ for _ in range(kv_count):
243
+ key = _read_gguf_string(f)
244
+ vtype = struct.unpack("<I", f.read(4))[0]
245
+ value = _read_gguf_value(f, vtype)
246
+ metadata[key] = {
247
+ "type": GGUF_TYPE_MAP.get(vtype, str(vtype)),
248
+ "value": value,
249
+ }
250
+
251
+ # Read tensor info (names + shapes)
252
+ tensors = []
253
+ for _ in range(min(tensor_count, 200)):
254
+ n_dims = struct.unpack("<I", f.read(4))[0]
255
+ name = _read_gguf_string(f)
256
+ dims = [struct.unpack("<Q", f.read(8))[0] for _ in range(n_dims)]
257
+ dtype = struct.unpack("<I", f.read(4))[0]
258
+ offset = struct.unpack("<Q", f.read(8))[0]
259
+ tensors.append({
260
+ "name": name,
261
+ "dims": dims,
262
+ "dtype": GGUF_TYPE_MAP.get(dtype, str(dtype)),
263
+ "offset": offset,
264
+ })
265
+
266
+ return {
267
+ "valid": True,
268
+ "version": version,
269
+ "tensor_count": tensor_count,
270
+ "kv_count": kv_count,
271
+ "metadata": metadata,
272
+ "tensors": tensors,
273
+ "architecture": metadata.get("general.architecture", {}).get("value", "unknown"),
274
+ "name": metadata.get("general.name", {}).get("value", ""),
275
+ "quantization": metadata.get("general.quantization_version", {}).get("value", ""),
276
+ "context_length": metadata.get("general.context_length", {}).get("value", 0),
277
+ "file_size": gguf_path.stat().st_size,
278
+ }
279
+ except Exception as exc:
280
+ return {"valid": False, "error": str(exc)}
281
+
282
+
283
  def _open_dmg(app_id: str, dmg_path: Path) -> dict[str, Any]:
284
  """Open a DMG: extract it, find apps, inspect them."""
285
  extract_to = EXTRACT_DIR / app_id
 
334
 
335
  @app.post("/api/upload")
336
  async def upload_file(file: UploadFile = File(...)) -> JSONResponse:
337
+ if not file.filename:
338
+ return JSONResponse({"error": "No filename provided."}, status_code=400)
339
+
340
+ fname = file.filename.lower()
341
+ is_dmg = fname.endswith(".dmg")
342
+ is_gguf = fname.endswith(".gguf")
343
+
344
+ if not is_dmg and not is_gguf:
345
+ return JSONResponse({"error": "Only .dmg and .gguf files are accepted."}, status_code=400)
346
 
347
  app_id = str(uuid.uuid4())
348
  slug = _slugify(file.filename)
 
352
  slug = f"{base_slug}-{counter}"
353
  counter += 1
354
 
355
+ ext = ".dmg" if is_dmg else ".gguf"
356
+ dest = UPLOAD_DIR / f"{app_id}{ext}"
357
  with open(dest, "wb") as f:
358
  while True:
359
  chunk = await file.read(65536)
 
364
  sha256 = _hash_file(dest)
365
  size = dest.stat().st_size
366
 
367
+ if is_dmg:
368
+ opened = _open_dmg(app_id, dest)
369
+ entry = {
370
+ "app_id": app_id,
371
+ "slug": slug,
372
+ "filename": file.filename,
373
+ "file_type": "dmg",
374
+ "size": size,
375
+ "size_human": _format_bytes(size),
376
+ "sha256": sha256,
377
+ "download_url": f"/api/download/{app_id}",
378
+ "opened": opened.get("opened", False),
379
+ "apps_found": opened.get("apps_found", 0),
380
+ "apps": opened.get("apps", []),
381
+ "tree": opened.get("tree", []),
382
+ "html_files": opened.get("html_files", []),
383
+ "has_preview": opened.get("has_preview", False),
384
+ "preview_entry": opened.get("preview_entry", None),
385
+ "error": opened.get("error"),
386
+ "created_at": time.time(),
387
+ }
388
+ msg = "DMG uploaded and opened." if entry["opened"] else f"DMG uploaded but could not be opened: {entry.get('error', '')}"
389
+ else:
390
+ gguf_info = _inspect_gguf(dest)
391
+ entry = {
392
+ "app_id": app_id,
393
+ "slug": slug,
394
+ "filename": file.filename,
395
+ "file_type": "gguf",
396
+ "size": size,
397
+ "size_human": _format_bytes(size),
398
+ "sha256": sha256,
399
+ "download_url": f"/api/download/{app_id}",
400
+ "gguf": gguf_info,
401
+ "opened": gguf_info.get("valid", False),
402
+ "has_preview": False,
403
+ "preview_entry": None,
404
+ "created_at": time.time(),
405
+ }
406
+ msg = "GGUF uploaded and inspected." if gguf_info.get("valid") else f"GGUF uploaded but inspection failed: {gguf_info.get('error', '')}"
407
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  _store[app_id] = entry
409
  _save_db()
410
 
411
+ return JSONResponse({"app": entry, "message": msg}, status_code=201)
 
 
 
412
 
413
 
414
  @app.get("/api/apps")
 
491
  if not entry:
492
  return JSONResponse({"error": "Not found"}, status_code=404)
493
 
494
+ ext = ".dmg" if entry.get("file_type", "dmg") == "dmg" else ".gguf"
495
+ path = UPLOAD_DIR / f"{app_id}{ext}"
496
  if not path.exists():
497
  return JSONResponse({
498
  "error": "File not found on disk",
499
+ "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload."
500
  }, status_code=404)
501
 
502
+ media = "application/x-apple-diskimage" if ext == ".dmg" else "application/octet-stream"
503
+ return FileResponse(path=path, filename=entry["filename"], media_type=media)
 
 
 
504
 
505
 
506
  @app.get("/api/browse/{app_id}/{path:path}")
 
516
  if not base.exists():
517
  return JSONResponse({
518
  "error": "Extracted files not found",
519
+ "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload."
520
  }, status_code=404)
521
 
522
  try:
 
553
  if not base.exists():
554
  return JSONResponse({
555
  "error": "Extracted files not found",
556
+ "detail": "This upload was stored in temporary storage that was cleared during a Space restart. Please re-upload."
557
  }, status_code=404)
558
 
559
  if path:
560
  target = base / path
561
  else:
 
562
  target = base / "index.html"
563
  if not target.exists():
 
564
  for f in base.rglob("*.html"):
565
  target = f
566
  break
 
568
  if not target.exists():
569
  return JSONResponse({"error": "Not found"}, status_code=404)
570
 
 
571
  try:
572
  target.resolve().relative_to(base.resolve())
573
  except ValueError:
574
  return JSONResponse({"error": "Access denied"}, status_code=403)
575
 
576
  if target.is_dir():
 
577
  idx = target / "index.html"
578
  if idx.exists():
579
  target = idx
 
598
  suffix = target.suffix.lower()
599
  media_type = mime_types.get(suffix, "application/octet-stream")
600
 
 
601
  if media_type == "text/html":
602
  content = target.read_text(errors="replace")
 
603
  if "<base" not in content:
604
  rel_dir = str(target.parent.relative_to(base)) if target.parent != base else ""
605
  base_tag = f'<base href="/api/preview/{app_id}/{rel_dir}/">' if rel_dir else f'<base href="/api/preview/{app_id}/">'
 
628
  macos.mkdir(parents=True)
629
  resources.mkdir(parents=True)
630
 
 
631
  plist = {
632
  "CFBundleDevelopmentRegion": "en",
633
  "CFBundleExecutable": app_name.replace(" ", ""),
 
643
  with open(contents / "Info.plist", "wb") as f:
644
  plistlib.dump(plist, f)
645
 
 
646
  script_path = macos / app_name.replace(" ", "")
647
  script_content = f'''#!/bin/bash
648
  # Auto-generated web app wrapper
 
650
  if command -v open >/dev/null 2>&1; then
651
  open "$URL"
652
  else
 
653
  xdg-open "$URL" 2>/dev/null || python3 -m webbrowser "$URL"
654
  fi
655
  '''
656
  script_path.write_text(script_content)
657
  script_path.chmod(0o755)
658
 
 
659
  html_path = resources / "index.html"
660
  html_path.write_text(f'''<!DOCTYPE html>
661
  <html><head><meta charset="utf-8"><title>{app_name}</title>
 
670
  """Best-effort DMG creation. Returns path to DMG or None."""
671
  dmg_path = WEBAPP_DIR / f"{output_name}.dmg"
672
 
 
673
  try:
674
  iso_path = WEBAPP_DIR / f"{output_name}.iso"
675
  result = subprocess.run(
 
678
  capture_output=True, text=True, timeout=30,
679
  )
680
  if result.returncode == 0:
 
681
  dmg_result = subprocess.run(
682
  ["dmg", "iso", str(iso_path), str(dmg_path)],
683
  capture_output=True, text=True, timeout=30,
 
690
  except Exception:
691
  pass
692
 
 
693
  zip_path = WEBAPP_DIR / f"{output_name}.zip"
694
  shutil.make_archive(
695
  base_name=str(WEBAPP_DIR / output_name),
 
721
  if not app_name:
722
  return JSONResponse({"error": "App name is required"}, status_code=400)
723
 
 
724
  app_name_safe = re.sub(r'[^a-zA-Z0-9 ]+', '', app_name).strip()
725
  if not app_name_safe:
726
  app_name_safe = "WebApp"
 
740
  "app_id": pkg_id,
741
  "slug": _slugify(app_name_safe),
742
  "filename": dest.name,
743
+ "file_type": "webapp",
744
  "size": dest.stat().st_size,
745
+ "size_human": _format_bytes(dest.stat().st_size),
746
  "sha256": _hash_file(dest),
747
  "download_url": f"/api/download/{pkg_id}",
748
  "source_url": url,
 
757
 
758
  return JSONResponse({
759
  "app": entry,
760
+ "message": f"'{app_name_safe}' packaged." if pkg.suffix == ".zip" else f"'{app_name_safe}' DMG created.",
761
  }, status_code=201)
762
  except Exception as exc:
763
  return JSONResponse({"error": str(exc)}, status_code=500)
764
 
765
 
766
+ # ─── Tiny Netlify: deploy static sites from ZIP ─────────────────────────────
767
+
768
+ def _find_index_html(root: Path) -> str | None:
769
+ """Find the entry HTML file in a deployed site."""
770
+ candidates = ["index.html", "index.htm"]
771
+ for c in candidates:
772
+ if (root / c).exists():
773
+ return c
774
+ # Search one level deep
775
+ for item in sorted(root.iterdir()):
776
+ if item.is_dir():
777
+ for c in candidates:
778
+ if (item / c).exists():
779
+ return f"{item.name}/{c}"
780
+ # Fallback: any HTML file
781
+ for f in root.rglob("*.html"):
782
+ return str(f.relative_to(root))
783
+ return None
784
+
785
+
786
+ def _build_site_tree(root: Path, max_depth: int = 3) -> list[dict[str, Any]]:
787
+ """Build a file tree for a deployed site."""
788
+ tree = []
789
+ try:
790
+ for item in sorted(root.iterdir()):
791
+ entry: dict[str, Any] = {
792
+ "name": item.name,
793
+ "type": "directory" if item.is_dir() else "file",
794
+ "size": item.stat().st_size if item.is_file() else 0,
795
+ }
796
+ if item.is_dir() and max_depth > 0:
797
+ entry["children"] = _build_site_tree(item, max_depth - 1)
798
+ tree.append(entry)
799
+ except Exception:
800
+ pass
801
+ return tree
802
+
803
+
804
+ @app.post("/api/deploy-site")
805
+ async def deploy_site(file: UploadFile = File(...)) -> JSONResponse:
806
+ """Deploy a static site from a ZIP file. Returns a hosted URL."""
807
+ if not file.filename or not file.filename.lower().endswith(".zip"):
808
+ return JSONResponse({"error": "Only .zip files are accepted for site deployment."}, status_code=400)
809
+
810
+ site_id = str(uuid.uuid4())
811
+ slug = _slugify(file.filename) or f"site-{site_id[:8]}"
812
+ base_slug = slug
813
+ counter = 1
814
+ while any(a.get("slug") == slug and a.get("file_type") == "site" for a in _store.values()):
815
+ slug = f"{base_slug}-{counter}"
816
+ counter += 1
817
+
818
+ # Save ZIP
819
+ zip_path = UPLOAD_DIR / f"{site_id}.zip"
820
+ with open(zip_path, "wb") as f:
821
+ while True:
822
+ chunk = await file.read(65536)
823
+ if not chunk:
824
+ break
825
+ f.write(chunk)
826
+
827
+ # Extract
828
+ site_root = SITES_DIR / site_id
829
+ site_root.mkdir(parents=True, exist_ok=True)
830
+ try:
831
+ with zipfile.ZipFile(zip_path, "r") as zf:
832
+ zf.extractall(site_root)
833
+ except zipfile.BadZipFile:
834
+ shutil.rmtree(site_root)
835
+ zip_path.unlink(missing_ok=True)
836
+ return JSONResponse({"error": "Invalid ZIP file."}, status_code=400)
837
+
838
+ # Handle nested root: if ZIP contains a single top-level dir, use that as root
839
+ top_items = list(site_root.iterdir())
840
+ if len(top_items) == 1 and top_items[0].is_dir():
841
+ real_root = top_items[0]
842
+ else:
843
+ real_root = site_root
844
+
845
+ index_file = _find_index_html(real_root)
846
+ if not index_file:
847
+ return JSONResponse({"error": "No HTML file found in ZIP."}, status_code=400)
848
+
849
+ sha256 = _hash_file(zip_path)
850
+ file_count = sum(1 for _ in real_root.rglob("*") if _.is_file())
851
+ total_size = sum(f.stat().st_size for f in real_root.rglob("*") if f.is_file())
852
+ tree = _build_site_tree(real_root)
853
+
854
+ entry = {
855
+ "app_id": site_id,
856
+ "slug": slug,
857
+ "filename": file.filename,
858
+ "file_type": "site",
859
+ "size": zip_path.stat().st_size,
860
+ "size_human": _format_bytes(zip_path.stat().st_size),
861
+ "sha256": sha256,
862
+ "site_url": f"/site/{site_id}/",
863
+ "site_preview": f"/site/{site_id}/{index_file}",
864
+ "index_file": index_file,
865
+ "file_count": file_count,
866
+ "total_size": total_size,
867
+ "total_size_human": _format_bytes(total_size),
868
+ "tree": tree,
869
+ "has_preview": True,
870
+ "preview_entry": index_file,
871
+ "opened": True,
872
+ "created_at": time.time(),
873
+ }
874
+ _store[site_id] = entry
875
+ _save_db()
876
+
877
+ return JSONResponse({
878
+ "app": entry,
879
+ "message": f"Site deployed. {file_count} files, {entry['size_human']}.",
880
+ "site_url": entry["site_url"],
881
+ }, status_code=201)
882
+
883
+
884
+ @app.get("/site/{site_id}/", response_class=HTMLResponse)
885
+ @app.get("/site/{site_id}", response_class=HTMLResponse)
886
+ def serve_site_root(site_id: str) -> HTMLResponse:
887
+ """Serve a deployed static site's index page."""
888
+ entry = _store.get(site_id)
889
+ if not entry or entry.get("file_type") != "site":
890
+ return HTMLResponse("<h1>Site not found</h1>", status_code=404)
891
+
892
+ site_root = SITES_DIR / site_id
893
+ if not site_root.exists():
894
+ return HTMLResponse("<h1>Site files not found. Storage may have been cleared.</h1>", status_code=404)
895
+
896
+ index_file = entry.get("index_file", "index.html")
897
+ target = site_root / index_file
898
+ if not target.exists():
899
+ for f in site_root.rglob("*.html"):
900
+ target = f
901
+ break
902
+
903
+ if not target.exists():
904
+ return HTMLResponse("<h1>No HTML found</h1>", status_code=404)
905
+
906
+ content = target.read_text(errors="replace")
907
+ rel_dir = str(target.parent.relative_to(site_root))
908
+ if "<base" not in content:
909
+ base_href = f"/site/{site_id}/{rel_dir}/" if rel_dir and rel_dir != "." else f"/site/{site_id}/"
910
+ base_tag = f'<base href="{base_href}">'
911
+ content = content.replace("<head>", f"<head>{base_tag}", 1)
912
+ content = content.replace("<HEAD>", f"<HEAD>{base_tag}", 1)
913
+
914
+ return HTMLResponse(content=content)
915
+
916
+
917
+ @app.get("/site/{site_id}/{path:path}")
918
+ def serve_site_file(site_id: str, path: str):
919
+ """Serve any file from a deployed static site."""
920
+ entry = _store.get(site_id)
921
+ if not entry or entry.get("file_type") != "site":
922
+ return JSONResponse({"error": "Site not found"}, status_code=404)
923
+
924
+ site_root = SITES_DIR / site_id
925
+ if not site_root.exists():
926
+ return JSONResponse({"error": "Site storage cleared"}, status_code=404)
927
+
928
+ target = site_root / path
929
+
930
+ try:
931
+ target.resolve().relative_to(site_root.resolve())
932
+ except ValueError:
933
+ return JSONResponse({"error": "Access denied"}, status_code=403)
934
+
935
+ if not target.exists():
936
+ return JSONResponse({"error": "File not found"}, status_code=404)
937
+
938
+ if target.is_dir():
939
+ idx = target / "index.html"
940
+ if idx.exists():
941
+ content = idx.read_text(errors="replace")
942
+ rel_dir = str(idx.parent.relative_to(site_root))
943
+ if "<base" not in content:
944
+ base_href = f"/site/{site_id}/{rel_dir}/" if rel_dir and rel_dir != "." else f"/site/{site_id}/"
945
+ content = content.replace("<head>", f'<head><base href="{base_href}">', 1)
946
+ return HTMLResponse(content=content)
947
+ return JSONResponse({"error": "No index.html in directory"}, status_code=404)
948
+
949
+ mime_types = {
950
+ ".html": "text/html", ".htm": "text/html",
951
+ ".js": "application/javascript", ".mjs": "application/javascript",
952
+ ".css": "text/css",
953
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
954
+ ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
955
+ ".ico": "image/x-icon",
956
+ ".json": "application/json", ".xml": "application/xml",
957
+ ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf",
958
+ ".otf": "font/otf",
959
+ ".txt": "text/plain", ".md": "text/markdown",
960
+ ".wasm": "application/wasm",
961
+ ".map": "application/json",
962
+ }
963
+ media_type = mime_types.get(target.suffix.lower(), "application/octet-stream")
964
+ return FileResponse(path=target, media_type=media_type)
965
+
966
+
967
+ # ─── GGUF Inference ─────────────────────────────────────────────────────────
968
+
969
+ def _get_llm(app_id: str) -> Any | None:
970
+ """Load a GGUF model into memory, with caching."""
971
+ if not HAS_LLAMA:
972
+ return None
973
+
974
+ if app_id in _llm_cache:
975
+ return _llm_cache[app_id]
976
+
977
+ entry = _store.get(app_id)
978
+ if not entry or entry.get("file_type") != "gguf":
979
+ return None
980
+
981
+ gguf_path = UPLOAD_DIR / f"{app_id}.gguf"
982
+ if not gguf_path.exists():
983
+ return None
984
+
985
+ try:
986
+ ctx = entry.get("gguf", {}).get("context_length", 2048)
987
+ n_ctx = min(int(ctx) if ctx else 2048, 4096)
988
+ llm = _Llama(model_path=str(gguf_path), n_ctx=n_ctx, verbose=False)
989
+ _llm_cache[app_id] = llm
990
+ return llm
991
+ except Exception:
992
+ return None
993
+
994
+
995
+ @app.get("/api/inference/status")
996
+ def inference_status() -> JSONResponse:
997
+ """Check if GGUF inference is available."""
998
+ loaded = list(_llm_cache.keys())
999
+ gguf_apps = [a for a in _store.values() if a.get("file_type") == "gguf"]
1000
+ return JSONResponse({
1001
+ "available": HAS_LLAMA,
1002
+ "loaded_models": len(loaded),
1003
+ "loaded_ids": loaded,
1004
+ "gguf_apps": [{"app_id": a["app_id"], "filename": a["filename"]} for a in gguf_apps],
1005
+ })
1006
+
1007
+
1008
+ @app.post("/api/inference/load/{app_id}")
1009
+ def load_model(app_id: str) -> JSONResponse:
1010
+ """Load a GGUF model into memory for inference."""
1011
+ if not HAS_LLAMA:
1012
+ return JSONResponse({
1013
+ "error": "llama-cpp-python not installed. Inference unavailable.",
1014
+ "hint": "Add llama-cpp-python to requirements.txt to enable inference.",
1015
+ }, status_code=503)
1016
+
1017
+ entry = _store.get(app_id)
1018
+ if not entry or entry.get("file_type") != "gguf":
1019
+ return JSONResponse({"error": "Not a GGUF artifact."}, status_code=400)
1020
+
1021
+ llm = _get_llm(app_id)
1022
+ if llm is None:
1023
+ return JSONResponse({"error": "Failed to load model."}, status_code=500)
1024
+
1025
+ return JSONResponse({
1026
+ "ok": True,
1027
+ "message": f"Model '{entry['filename']}' loaded.",
1028
+ "context_length": entry.get("gguf", {}).get("context_length", 2048),
1029
+ })
1030
+
1031
+
1032
+ @app.post("/api/inference/chat/{app_id}")
1033
+ async def inference_chat(app_id: str, request: Request) -> JSONResponse:
1034
+ """OpenAI-compatible chat completion endpoint using a loaded GGUF model."""
1035
+ if not HAS_LLAMA:
1036
+ return JSONResponse({"error": "Inference unavailable. llama-cpp-python not installed."}, status_code=503)
1037
+
1038
+ llm = _get_llm(app_id)
1039
+ if llm is None:
1040
+ return JSONResponse({"error": "Model not loaded. POST /api/inference/load/{app_id} first."}, status_code=400)
1041
+
1042
+ try:
1043
+ data = await request.json()
1044
+ except Exception:
1045
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
1046
+
1047
+ messages = data.get("messages", [])
1048
+ if not messages:
1049
+ return JSONResponse({"error": "messages is required"}, status_code=400)
1050
+
1051
+ max_tokens = min(int(data.get("max_tokens", 512)), 2048)
1052
+ temperature = float(data.get("temperature", 0.7))
1053
+ stream = bool(data.get("stream", False))
1054
+
1055
+ if stream:
1056
+ def gen():
1057
+ for chunk in llm.create_chat_completion(
1058
+ messages=messages, max_tokens=max_tokens,
1059
+ temperature=temperature, stream=True,
1060
+ ):
1061
+ yield f"data: {json.dumps(chunk)}\n\n"
1062
+ yield "data: [DONE]\n\n"
1063
+ return StreamingResponse(gen(), media_type="text/event-stream")
1064
+
1065
+ result = llm.create_chat_completion(
1066
+ messages=messages, max_tokens=max_tokens, temperature=temperature,
1067
+ )
1068
+ return JSONResponse(result)
1069
+
1070
+
1071
+ @app.post("/api/inference/completion/{app_id}")
1072
+ async def inference_completion(app_id: str, request: Request) -> JSONResponse:
1073
+ """Text completion endpoint using a loaded GGUF model."""
1074
+ if not HAS_LLAMA:
1075
+ return JSONResponse({"error": "Inference unavailable. llama-cpp-python not installed."}, status_code=503)
1076
+
1077
+ llm = _get_llm(app_id)
1078
+ if llm is None:
1079
+ return JSONResponse({"error": "Model not loaded."}, status_code=400)
1080
+
1081
+ try:
1082
+ data = await request.json()
1083
+ except Exception:
1084
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
1085
+
1086
+ prompt = data.get("prompt", "")
1087
+ if not prompt:
1088
+ return JSONResponse({"error": "prompt is required"}, status_code=400)
1089
+
1090
+ max_tokens = min(int(data.get("max_tokens", 256)), 2048)
1091
+ temperature = float(data.get("temperature", 0.7))
1092
+
1093
+ result = llm(prompt=prompt, max_tokens=max_tokens, temperature=temperature)
1094
+ return JSONResponse(result)
1095
+
1096
+
1097
+ # ─── ChatGPT Export β†’ dApp ──────────────────────────────────────────────────
1098
+
1099
+ def _parse_chatgpt_export(data: Any) -> dict[str, Any]:
1100
+ """Parse a ChatGPT export conversations.json structure."""
1101
+ conversations = []
1102
+ raw = data if isinstance(data, list) else data.get("conversations", data) if isinstance(data, dict) else []
1103
+
1104
+ for conv in raw:
1105
+ if not isinstance(conv, dict):
1106
+ continue
1107
+ title = conv.get("title", "Untitled")
1108
+ conv_id = conv.get("id", conv.get("uuid", str(uuid.uuid4())))
1109
+ create_time = conv.get("create_time", 0)
1110
+ update_time = conv.get("update_time", 0)
1111
+
1112
+ messages = []
1113
+ mapping = conv.get("mapping", {})
1114
+ if isinstance(mapping, dict):
1115
+ for node_id, node in mapping.items():
1116
+ if not isinstance(node, dict):
1117
+ continue
1118
+ msg = node.get("message")
1119
+ if not msg or not isinstance(msg, dict):
1120
+ continue
1121
+ author = msg.get("author", {})
1122
+ role = author.get("role", "unknown")
1123
+ content = msg.get("content", {})
1124
+ parts = content.get("parts", [])
1125
+ text = ""
1126
+ for p in parts:
1127
+ if isinstance(p, str):
1128
+ text += p
1129
+ elif isinstance(p, dict):
1130
+ text += p.get("text", str(p))
1131
+ if text.strip():
1132
+ messages.append({
1133
+ "role": role,
1134
+ "text": text[:50000],
1135
+ "create_time": msg.get("create_time", 0),
1136
+ })
1137
+
1138
+ messages.sort(key=lambda m: m.get("create_time", 0) or 0)
1139
+ conversations.append({
1140
+ "id": str(conv_id),
1141
+ "title": title,
1142
+ "message_count": len(messages),
1143
+ "create_time": create_time,
1144
+ "update_time": update_time,
1145
+ "messages": messages,
1146
+ })
1147
+
1148
+ conversations.sort(key=lambda c: c.get("create_time", 0) or 0, reverse=True)
1149
+ return {"conversations": conversations, "total": len(conversations)}
1150
+
1151
+
1152
+ def _generate_dapp_html(parsed: dict[str, Any], title: str) -> str:
1153
+ """Generate a self-contained dApp HTML for browsing ChatGPT conversations."""
1154
+ convs = parsed["conversations"]
1155
+ convs_json = json.dumps(convs[:500]) # Limit to 500 conversations
1156
+
1157
+ return f'''<!DOCTYPE html>
1158
+ <html lang="en">
1159
+ <head>
1160
+ <meta charset="UTF-8">
1161
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1162
+ <title>{title} β€” ChatGPT Archive dApp</title>
1163
+ <style>
1164
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
1165
+ body {{ background: #0a0a0f; color: #e2e2e8; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; height: 100vh; display: flex; overflow: hidden; }}
1166
+ .sidebar {{ width: 300px; min-width: 300px; background: #14141a; border-right: 1px solid #2a2a35; overflow-y: auto; display: flex; flex-direction: column; }}
1167
+ .sidebar-header {{ padding: 16px; border-bottom: 1px solid #2a2a35; }}
1168
+ .sidebar-header h2 {{ font-size: 1rem; margin-bottom: 4px; }}
1169
+ .sidebar-header .count {{ font-size: 0.8rem; color: #888898; }}
1170
+ .search {{ padding: 12px 16px; border-bottom: 1px solid #2a2a35; }}
1171
+ .search input {{ width: 100%; background: #0a0a0f; border: 1px solid #2a2a35; color: #e2e2e8; padding: 8px 12px; border-radius: 8px; font-size: 0.85rem; outline: none; }}
1172
+ .search input:focus {{ border-color: #6366f1; }}
1173
+ .conv-list {{ flex: 1; overflow-y: auto; }}
1174
+ .conv-item {{ padding: 12px 16px; border-bottom: 1px solid #1c1c24; cursor: pointer; transition: background 0.15s; }}
1175
+ .conv-item:hover {{ background: #1c1c24; }}
1176
+ .conv-item.active {{ background: rgba(99,102,241,0.15); border-left: 3px solid #6366f1; }}
1177
+ .conv-item .title {{ font-size: 0.88rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
1178
+ .conv-item .meta {{ font-size: 0.75rem; color: #5a5a68; margin-top: 2px; }}
1179
+ .main {{ flex: 1; overflow-y: auto; padding: 24px 32px; }}
1180
+ .welcome {{ display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #5a5a68; }}
1181
+ .welcome .icon {{ font-size: 3rem; margin-bottom: 12px; }}
1182
+ .welcome h3 {{ font-size: 1.1rem; margin-bottom: 4px; color: #888898; }}
1183
+ .msg {{ margin-bottom: 20px; max-width: 800px; }}
1184
+ .msg .role {{ font-size: 0.78rem; font-weight: 600; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; }}
1185
+ .msg .role.user {{ color: #6366f1; }}
1186
+ .msg .role.assistant {{ color: #22c55e; }}
1187
+ .msg .role.system {{ color: #eab300; }}
1188
+ .msg .bubble {{ background: #14141a; border: 1px solid #2a2a35; border-radius: 12px; padding: 14px 18px; font-size: 0.9rem; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }}
1189
+ .msg .bubble.user {{ border-left: 3px solid #6366f1; }}
1190
+ .msg .bubble.assistant {{ border-left: 3px solid #22c55e; }}
1191
+ .empty {{ text-align: center; padding: 40px; color: #5a5a68; }}
1192
+ </style>
1193
+ </head>
1194
+ <body>
1195
+ <div class="sidebar">
1196
+ <div class="sidebar-header">
1197
+ <h2>⬑ {title}</h2>
1198
+ <div class="count">{parsed["total"]} conversations</div>
1199
+ </div>
1200
+ <div class="search">
1201
+ <input type="text" id="search" placeholder="Search conversations..." oninput="filterConvs()">
1202
+ </div>
1203
+ <div class="conv-list" id="conv-list"></div>
1204
+ </div>
1205
+ <div class="main" id="main">
1206
+ <div class="welcome">
1207
+ <div class="icon">πŸ’¬</div>
1208
+ <h3>ChatGPT Archive dApp</h3>
1209
+ <p>Select a conversation to browse</p>
1210
+ </div>
1211
+ </div>
1212
+ <script>
1213
+ const CONVS = {convs_json};
1214
+ let activeId = null;
1215
+
1216
+ function renderConvList(filter) {{
1217
+ filter = (filter || "").toLowerCase();
1218
+ const list = document.getElementById("conv-list");
1219
+ const filtered = CONVS.filter(c => c.title.toLowerCase().includes(filter));
1220
+ list.innerHTML = filtered.map(c => `
1221
+ <div class="conv-item ${{c.id === activeId ? 'active' : ''}}" onclick="selectConv('${{c.id}}')">
1222
+ <div class="title">${{escHtml(c.title)}}</div>
1223
+ <div class="meta">${{c.message_count}} messages</div>
1224
+ </div>
1225
+ `).join('') || '<div class="empty">No matches</div>';
1226
+ }}
1227
+
1228
+ function selectConv(id) {{
1229
+ activeId = id;
1230
+ const conv = CONVS.find(c => c.id === id);
1231
+ if (!conv) return;
1232
+ const main = document.getElementById("main");
1233
+ main.innerHTML = `<div style="margin-bottom:20px"><h2 style="font-size:1.2rem">${{escHtml(conv.title)}}</h2><div style="font-size:0.8rem;color:#888898">${{conv.message_count}} messages</div></div>` +
1234
+ conv.messages.map(m => `
1235
+ <div class="msg">
1236
+ <div class="role ${{m.role}}">${{m.role}}</div>
1237
+ <div class="bubble ${{m.role}}">${{escHtml(m.text)}}</div>
1238
+ </div>
1239
+ `).join('');
1240
+ renderConvList(document.getElementById("search").value);
1241
+ main.scrollTop = 0;
1242
+ }}
1243
+
1244
+ function filterConvs() {{
1245
+ renderConvList(document.getElementById("search").value);
1246
+ }}
1247
+
1248
+ function escHtml(s) {{
1249
+ if (!s) return '';
1250
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1251
+ }}
1252
+
1253
+ renderConvList();
1254
+ </script>
1255
+ </body>
1256
+ </html>'''
1257
+
1258
+
1259
+ @app.post("/api/import/chatgpt")
1260
+ async def import_chatgpt(file: UploadFile = File(...)) -> JSONResponse:
1261
+ """Import a ChatGPT export (conversations.json) and deploy as a browsable dApp."""
1262
+ if not file.filename:
1263
+ return JSONResponse({"error": "No filename provided."}, status_code=400)
1264
+
1265
+ fname = file.filename.lower()
1266
+ if not (fname.endswith(".json") or fname.endswith(".zip")):
1267
+ return JSONResponse({"error": "Only .json or .zip ChatGPT exports are accepted."}, status_code=400)
1268
+
1269
+ app_id = str(uuid.uuid4())
1270
+ slug = _slugify(file.filename) or f"chatgpt-archive-{app_id[:8]}"
1271
+
1272
+ # Save the uploaded file
1273
+ if fname.endswith(".zip"):
1274
+ zip_path = UPLOAD_DIR / f"{app_id}.zip"
1275
+ with open(zip_path, "wb") as f:
1276
+ while True:
1277
+ chunk = await file.read(65536)
1278
+ if not chunk:
1279
+ break
1280
+ f.write(chunk)
1281
+ # Extract and find conversations.json
1282
+ extract_to = EXTRACT_DIR / app_id
1283
+ extract_to.mkdir(parents=True, exist_ok=True)
1284
+ try:
1285
+ with zipfile.ZipFile(zip_path, "r") as zf:
1286
+ zf.extractall(extract_to)
1287
+ except zipfile.BadZipFile:
1288
+ shutil.rmtree(extract_to)
1289
+ zip_path.unlink(missing_ok=True)
1290
+ return JSONResponse({"error": "Invalid ZIP file."}, status_code=400)
1291
+
1292
+ # Find conversations.json
1293
+ conv_file = None
1294
+ for f in extract_to.rglob("conversations.json"):
1295
+ conv_file = f
1296
+ break
1297
+ if not conv_file:
1298
+ shutil.rmtree(extract_to)
1299
+ return JSONResponse({"error": "No conversations.json found in ZIP export."}, status_code=400)
1300
+ raw_data = json.loads(conv_file.read_text(encoding="utf-8"))
1301
+ sha256 = _hash_file(zip_path)
1302
+ stored_size = zip_path.stat().st_size
1303
+ else:
1304
+ # Direct JSON upload
1305
+ json_path = UPLOAD_DIR / f"{app_id}.json"
1306
+ with open(json_path, "wb") as f:
1307
+ while True:
1308
+ chunk = await file.read(65536)
1309
+ if not chunk:
1310
+ break
1311
+ f.write(chunk)
1312
+ try:
1313
+ raw_data = json.loads(json_path.read_text(encoding="utf-8"))
1314
+ except json.JSONDecodeError:
1315
+ json_path.unlink(missing_ok=True)
1316
+ return JSONResponse({"error": "Invalid JSON file."}, status_code=400)
1317
+ sha256 = _hash_file(json_path)
1318
+ stored_size = json_path.stat().st_size
1319
+
1320
+ # Parse the ChatGPT export
1321
+ parsed = _parse_chatgpt_export(raw_data)
1322
+ if parsed["total"] == 0:
1323
+ return JSONResponse({"error": "No conversations found in export."}, status_code=400)
1324
+
1325
+ # Generate dApp HTML
1326
+ title = f"ChatGPT Archive ({parsed['total']} conversations)"
1327
+ dapp_html = _generate_dapp_html(parsed, title)
1328
+
1329
+ # Deploy as a site
1330
+ dapp_root = DAPPS_DIR / app_id
1331
+ dapp_root.mkdir(parents=True, exist_ok=True)
1332
+ (dapp_root / "index.html").write_text(dapp_html, encoding="utf-8")
1333
+
1334
+ entry = {
1335
+ "app_id": app_id,
1336
+ "slug": slug,
1337
+ "filename": file.filename,
1338
+ "file_type": "chatgpt",
1339
+ "size": stored_size,
1340
+ "size_human": _format_bytes(stored_size),
1341
+ "sha256": sha256,
1342
+ "dapp_url": f"/dapp/{app_id}/",
1343
+ "conversation_count": parsed["total"],
1344
+ "total_messages": sum(c["message_count"] for c in parsed["conversations"]),
1345
+ "has_preview": True,
1346
+ "preview_entry": "index.html",
1347
+ "opened": True,
1348
+ "created_at": time.time(),
1349
+ }
1350
+ _store[app_id] = entry
1351
+ _save_db()
1352
+
1353
+ return JSONResponse({
1354
+ "app": entry,
1355
+ "message": f"ChatGPT archive deployed as dApp. {parsed['total']} conversations, {entry['total_messages']} messages.",
1356
+ "dapp_url": entry["dapp_url"],
1357
+ }, status_code=201)
1358
+
1359
+
1360
+ @app.get("/dapp/{app_id}/", response_class=HTMLResponse)
1361
+ @app.get("/dapp/{app_id}", response_class=HTMLResponse)
1362
+ def serve_dapp(app_id: str) -> HTMLResponse:
1363
+ """Serve a deployed ChatGPT dApp."""
1364
+ entry = _store.get(app_id)
1365
+ if not entry or entry.get("file_type") != "chatgpt":
1366
+ return HTMLResponse("<h1>dApp not found</h1>", status_code=404)
1367
+
1368
+ dapp_root = DAPPS_DIR / app_id
1369
+ if not dapp_root.exists():
1370
+ return HTMLResponse("<h1>dApp files not found. Storage may have been cleared.</h1>", status_code=404)
1371
+
1372
+ index = dapp_root / "index.html"
1373
+ if not index.exists():
1374
+ return HTMLResponse("<h1>dApp index not found</h1>", status_code=404)
1375
+
1376
+ return HTMLResponse(content=index.read_text(encoding="utf-8"))
1377
+
1378
+
1379
+ @app.get("/dapp/{app_id}/{path:path}")
1380
+ def serve_dapp_file(app_id: str, path: str):
1381
+ """Serve any file from a deployed dApp."""
1382
+ entry = _store.get(app_id)
1383
+ if not entry or entry.get("file_type") != "chatgpt":
1384
+ return JSONResponse({"error": "dApp not found"}, status_code=404)
1385
+
1386
+ dapp_root = DAPPS_DIR / app_id
1387
+ if not dapp_root.exists():
1388
+ return JSONResponse({"error": "dApp storage cleared"}, status_code=404)
1389
+
1390
+ target = dapp_root / path
1391
+ try:
1392
+ target.resolve().relative_to(dapp_root.resolve())
1393
+ except ValueError:
1394
+ return JSONResponse({"error": "Access denied"}, status_code=403)
1395
+
1396
+ if not target.exists():
1397
+ return JSONResponse({"error": "File not found"}, status_code=404)
1398
+
1399
+ return FileResponse(path=target)
1400
+
1401
+
1402
+ @app.get("/api/storage/status")
1403
+ def storage_status() -> JSONResponse:
1404
+ """Check if persistent storage is attached."""
1405
+ persistent = str(DATA_DIR).startswith("/data")
1406
+ return JSONResponse({
1407
+ "persistent": persistent,
1408
+ "data_dir": str(DATA_DIR),
1409
+ "has_uploads": UPLOAD_DIR.exists(),
1410
+ "has_extracted": EXTRACT_DIR.exists(),
1411
+ "has_sites": SITES_DIR.exists(),
1412
+ "has_dapps": DAPPS_DIR.exists(),
1413
+ "inference_available": HAS_LLAMA,
1414
+ "artifact_count": len(_store),
1415
+ })
1416
+
1417
+
1418
  @app.delete("/api/apps/{app_id}")
1419
  def delete_app(app_id: str) -> JSONResponse:
1420
  entry = _store.pop(app_id, None)
1421
  if entry:
1422
+ # Clean up uploads (all possible extensions)
1423
+ for suffix in [".dmg", ".gguf", ".zip", ".json"]:
1424
+ f = UPLOAD_DIR / f"{app_id}{suffix}"
1425
+ if f.exists():
1426
+ f.unlink()
1427
+ # Clean up extracted files
1428
  extracted = EXTRACT_DIR / app_id
1429
  if extracted.exists():
1430
  shutil.rmtree(extracted)
1431
+ # Clean up deployed sites
1432
+ site = SITES_DIR / app_id
1433
+ if site.exists():
1434
+ shutil.rmtree(site)
1435
+ # Clean up dapps
1436
+ dapp = DAPPS_DIR / app_id
1437
+ if dapp.exists():
1438
+ shutil.rmtree(dapp)
1439
+ # Unload model from inference cache
1440
+ _llm_cache.pop(app_id, None)
1441
  _save_db()
1442
  return JSONResponse({"ok": True})
requirements.txt CHANGED
@@ -1,5 +1,8 @@
1
- fastapi==0.115.0
2
- uvicorn[standard]==0.34.0
3
- python-multipart==0.0.20
4
- biplist==1.0.3
5
-
 
 
 
 
1
+ fastapi>=0.104.0
2
+ uvicorn[standard]>=0.24.0
3
+ python-multipart>=0.0.6
4
+ biplist>=1.0.3
5
+ # llama-cpp-python enables GGUF inference endpoints.
6
+ # Uncomment to enable /api/inference/* endpoints.
7
+ # On HF Spaces with GPU, use: llama-cpp-python[cuda]
8
+ # llama-cpp-python>=0.2.0
static/index.html CHANGED
@@ -1,1508 +1,393 @@
1
  <!DOCTYPE html>
2
  <html lang="en">
3
-
4
  <head>
5
- <meta charset="UTF-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>⬑ LocalSpace Deployer</title>
8
- <style>
9
- * {
10
- box-sizing: border-box;
11
- }
12
-
13
- :root {
14
- --bg: #0a0a0f;
15
- --surface: #14141a;
16
- --surface-hover: #1c1c24;
17
- --border: #2a2a35;
18
- --accent: #3b82f6;
19
- --accent-hover: #2563eb;
20
- --text: #e2e2e8;
21
- --text-muted: #888898;
22
- --success: #22c55e;
23
- --warn: #f59e0b;
24
- --radius: 12px;
25
- --radius-sm: 8px;
26
- }
27
-
28
- body {
29
- margin: 0;
30
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
31
- background: var(--bg);
32
- color: var(--text);
33
- min-height: 100vh;
34
- }
35
-
36
- .container {
37
- max-width: 1100px;
38
- margin: 0 auto;
39
- padding: 40px 24px;
40
- }
41
-
42
- header {
43
- text-align: center;
44
- margin-bottom: 48px;
45
- position: relative;
46
- min-height: 200px;
47
- }
48
-
49
- header h1 {
50
- margin: 0 0 8px;
51
- font-size: 2.2rem;
52
- font-weight: 700;
53
- letter-spacing: -0.02em;
54
- }
55
-
56
- header p {
57
- margin: 0;
58
- color: var(--text-muted);
59
- font-size: 1.05rem;
60
- }
61
-
62
- /* ─── Landing Carousel ───────────────────────────────── */
63
- .landing-carousel {
64
- position: relative;
65
- overflow: hidden;
66
- min-height: 220px;
67
- }
68
-
69
- .landing-slide {
70
- position: absolute;
71
- inset: 0;
72
- opacity: 0;
73
- transform: translateY(20px);
74
- transition: opacity 1.2s ease, transform 1.2s ease;
75
- pointer-events: none;
76
- display: flex;
77
- flex-direction: column;
78
- align-items: center;
79
- justify-content: center;
80
- padding: 20px 0;
81
- }
82
-
83
- .landing-slide.active {
84
- opacity: 1;
85
- transform: translateY(0);
86
- pointer-events: auto;
87
- }
88
-
89
- .landing-slide .icon {
90
- font-size: 4rem;
91
- margin-bottom: 12px;
92
- line-height: 1;
93
- }
94
-
95
- .landing-slide .slide-title {
96
- font-size: 2.4rem;
97
- font-weight: 700;
98
- margin: 0 0 10px;
99
- letter-spacing: -0.02em;
100
- }
101
-
102
- .landing-slide .slide-subtitle {
103
- color: var(--text-muted);
104
- font-size: 1.1rem;
105
- max-width: 520px;
106
- line-height: 1.5;
107
- }
108
-
109
- .landing-slide .slide-cta {
110
- margin-top: 16px;
111
- padding: 10px 24px;
112
- background: linear-gradient(135deg, var(--accent), #8b5cf6);
113
- color: #fff;
114
- border: none;
115
- border-radius: var(--radius-sm);
116
- font-size: 0.95rem;
117
- cursor: pointer;
118
- transition: transform 0.2s, box-shadow 0.2s;
119
- }
120
-
121
- .landing-slide .slide-cta:hover {
122
- transform: translateY(-2px);
123
- box-shadow: 0 4px 20px rgba(59, 130, 246, 0.3);
124
- }
125
-
126
- .landing-dots {
127
- display: flex;
128
- gap: 8px;
129
- justify-content: center;
130
- margin-top: 16px;
131
- }
132
-
133
- .landing-dot {
134
- width: 8px;
135
- height: 8px;
136
- border-radius: 50%;
137
- background: var(--border);
138
- cursor: pointer;
139
- transition: background 0.3s, transform 0.3s;
140
- }
141
-
142
- .landing-dot.active {
143
- background: var(--accent);
144
- transform: scale(1.3);
145
- }
146
-
147
- .landing-timer {
148
- position: absolute;
149
- bottom: 0;
150
- left: 50%;
151
- transform: translateX(-50%);
152
- width: 120px;
153
- height: 3px;
154
- background: var(--border);
155
- border-radius: 2px;
156
- overflow: hidden;
157
- }
158
-
159
- .landing-timer .fill {
160
- height: 100%;
161
- background: var(--accent);
162
- width: 0%;
163
- border-radius: 2px;
164
- }
165
-
166
- /* Drop zone */
167
- .dropzone {
168
- border: 2px dashed var(--border);
169
- border-radius: var(--radius);
170
- padding: 64px 32px;
171
- text-align: center;
172
- background: var(--surface);
173
- transition: all 0.2s ease;
174
- cursor: pointer;
175
- position: relative;
176
- }
177
-
178
- .dropzone:hover,
179
- .dropzone.dragover {
180
- border-color: var(--accent);
181
- background: var(--surface-hover);
182
- }
183
-
184
- .dropzone .icon {
185
- font-size: 3rem;
186
- margin-bottom: 16px;
187
- opacity: 0.7;
188
- }
189
-
190
- .dropzone h3 {
191
- margin: 0 0 8px;
192
- font-size: 1.2rem;
193
- }
194
-
195
- .dropzone p {
196
- margin: 0;
197
- color: var(--text-muted);
198
- font-size: 0.9rem;
199
- }
200
-
201
- .dropzone input[type="file"] {
202
- position: absolute;
203
- inset: 0;
204
- width: 100%;
205
- height: 100%;
206
- opacity: 0;
207
- cursor: pointer;
208
- }
209
-
210
- /* Progress */
211
- .progress-wrap {
212
- margin-top: 24px;
213
- display: none;
214
- }
215
-
216
- .progress-wrap.active {
217
- display: block;
218
- }
219
-
220
- .progress-bar {
221
- height: 6px;
222
- background: var(--border);
223
- border-radius: 3px;
224
- overflow: hidden;
225
- }
226
-
227
- .progress-bar .fill {
228
- height: 100%;
229
- background: var(--accent);
230
- width: 0%;
231
- transition: width 0.3s ease;
232
- border-radius: 3px;
233
- }
234
-
235
- .progress-text {
236
- text-align: center;
237
- margin-top: 8px;
238
- font-size: 0.85rem;
239
- color: var(--text-muted);
240
- }
241
-
242
- /* Result */
243
- .result {
244
- margin-top: 24px;
245
- padding: 20px;
246
- background: var(--surface);
247
- border: 1px solid var(--border);
248
- border-radius: var(--radius);
249
- display: none;
250
- }
251
-
252
- .result.active {
253
- display: block;
254
- }
255
-
256
- .result h4 {
257
- margin: 0 0 12px;
258
- color: var(--accent);
259
- }
260
-
261
- .result .field {
262
- display: flex;
263
- justify-content: space-between;
264
- padding: 8px 0;
265
- border-bottom: 1px solid var(--border);
266
- font-size: 0.92rem;
267
- }
268
-
269
- .result .field:last-child {
270
- border-bottom: none;
271
- }
272
-
273
- .result .field .label {
274
- color: var(--text-muted);
275
- }
276
-
277
- .result .field .value {
278
- font-family: ui-monospace, monospace;
279
- font-size: 0.85rem;
280
- }
281
-
282
- .result .actions {
283
- margin-top: 16px;
284
- display: flex;
285
- gap: 12px;
286
- }
287
-
288
- .btn {
289
- padding: 10px 20px;
290
- border-radius: var(--radius-sm);
291
- border: none;
292
- font-size: 0.92rem;
293
- cursor: pointer;
294
- text-decoration: none;
295
- display: inline-flex;
296
- align-items: center;
297
- gap: 6px;
298
- transition: background 0.15s;
299
- }
300
-
301
- .btn-primary {
302
- background: var(--accent);
303
- color: #fff;
304
- }
305
-
306
- .btn-primary:hover {
307
- background: var(--accent-hover);
308
- }
309
-
310
- .btn-secondary {
311
- background: var(--surface-hover);
312
- color: var(--text);
313
- border: 1px solid var(--border);
314
- }
315
-
316
- .btn-secondary:hover {
317
- background: var(--border);
318
- }
319
-
320
- /* App bundles */
321
- .app-bundle {
322
- background: var(--surface);
323
- border: 1px solid var(--border);
324
- border-radius: var(--radius);
325
- padding: 20px;
326
- margin-top: 20px;
327
- }
328
-
329
- .app-bundle .bundle-header {
330
- display: flex;
331
- align-items: center;
332
- gap: 16px;
333
- margin-bottom: 16px;
334
- }
335
-
336
- .app-bundle .bundle-icon {
337
- width: 56px;
338
- height: 56px;
339
- border-radius: var(--radius-sm);
340
- background: var(--surface-hover);
341
- display: flex;
342
- align-items: center;
343
- justify-content: center;
344
- font-size: 1.6rem;
345
- flex-shrink: 0;
346
- }
347
-
348
- .app-bundle .bundle-title {
349
- flex: 1;
350
- min-width: 0;
351
- }
352
-
353
- .app-bundle .bundle-title .name {
354
- font-weight: 600;
355
- font-size: 1.1rem;
356
- margin-bottom: 4px;
357
- }
358
-
359
- .app-bundle .bundle-title .meta {
360
- font-size: 0.82rem;
361
- color: var(--text-muted);
362
- }
363
-
364
- .app-bundle .badge {
365
- display: inline-block;
366
- padding: 4px 10px;
367
- border-radius: 999px;
368
- font-size: 0.75rem;
369
- font-weight: 500;
370
- margin-left: 8px;
371
- }
372
-
373
- .badge-success {
374
- background: rgba(34, 197, 94, 0.15);
375
- color: var(--success);
376
- }
377
-
378
- .badge-warn {
379
- background: rgba(245, 158, 11, 0.15);
380
- color: var(--warn);
381
- }
382
-
383
- /* File tree */
384
- .file-tree {
385
- margin-top: 16px;
386
- padding: 12px;
387
- background: var(--bg);
388
- border-radius: var(--radius-sm);
389
- max-height: 300px;
390
- overflow-y: auto;
391
- }
392
-
393
- .file-tree .item {
394
- padding: 4px 0;
395
- font-size: 0.85rem;
396
- font-family: ui-monospace, monospace;
397
- color: var(--text-muted);
398
- }
399
-
400
- .file-tree .item::before {
401
- content: "πŸ“„ ";
402
- }
403
-
404
- .file-tree .item.dir::before {
405
- content: "πŸ“ ";
406
- }
407
-
408
- /* Section */
409
- .section {
410
- margin-top: 48px;
411
- }
412
-
413
- .section h2 {
414
- margin: 0 0 20px;
415
- font-size: 1.3rem;
416
- }
417
-
418
- .app-grid {
419
- display: grid;
420
- gap: 12px;
421
- }
422
-
423
- .app-card {
424
- background: var(--surface);
425
- border: 1px solid var(--border);
426
- border-radius: var(--radius);
427
- padding: 16px 20px;
428
- display: flex;
429
- align-items: center;
430
- justify-content: space-between;
431
- gap: 16px;
432
- transition: border-color 0.15s;
433
- }
434
-
435
- .app-card:hover {
436
- border-color: var(--accent);
437
- }
438
-
439
- .app-card .info {
440
- flex: 1;
441
- min-width: 0;
442
- }
443
-
444
- .app-card .info .name {
445
- font-weight: 600;
446
- margin-bottom: 4px;
447
- white-space: nowrap;
448
- overflow: hidden;
449
- text-overflow: ellipsis;
450
- }
451
-
452
- .app-card .info .meta {
453
- font-size: 0.82rem;
454
- color: var(--text-muted);
455
- }
456
-
457
- .app-card .actions {
458
- display: flex;
459
- gap: 8px;
460
- flex-shrink: 0;
461
- }
462
-
463
- .app-card .actions a {
464
- padding: 6px 14px;
465
- border-radius: var(--radius-sm);
466
- background: var(--surface-hover);
467
- color: var(--text);
468
- text-decoration: none;
469
- font-size: 0.82rem;
470
- border: 1px solid var(--border);
471
- }
472
-
473
- .app-card .actions a:hover {
474
- background: var(--border);
475
- }
476
-
477
- .empty {
478
- text-align: center;
479
- padding: 40px;
480
- color: var(--text-muted);
481
- }
482
-
483
- /* Toast */
484
- .toast {
485
- position: fixed;
486
- bottom: 24px;
487
- left: 50%;
488
- transform: translateX(-50%) translateY(100px);
489
- background: var(--surface);
490
- border: 1px solid var(--border);
491
- padding: 12px 24px;
492
- border-radius: var(--radius-sm);
493
- font-size: 0.9rem;
494
- opacity: 0;
495
- transition: all 0.3s ease;
496
- z-index: 1000;
497
- }
498
-
499
- .toast.show {
500
- opacity: 1;
501
- transform: translateX(-50%) translateY(0);
502
- }
503
-
504
- .toast.error {
505
- border-color: #ef4444;
506
- color: #fca5a5;
507
- }
508
-
509
- .toast.success {
510
- border-color: #22c55e;
511
- color: #86efac;
512
- }
513
-
514
- /* ─── Context Menu ───────────────────────────────────── */
515
- .ctx-menu {
516
- position: fixed;
517
- background: var(--surface);
518
- border: 1px solid var(--border);
519
- border-radius: var(--radius-sm);
520
- padding: 6px 0;
521
- min-width: 200px;
522
- z-index: 2000;
523
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
524
- display: none;
525
- }
526
-
527
- .ctx-menu-item {
528
- padding: 8px 16px;
529
- font-size: 0.88rem;
530
- color: var(--text);
531
- cursor: pointer;
532
- display: flex;
533
- align-items: center;
534
- gap: 8px;
535
- transition: background 0.1s;
536
- }
537
-
538
- .ctx-menu-item:hover {
539
- background: var(--surface-hover);
540
- }
541
-
542
- .ctx-menu-sep {
543
- height: 1px;
544
- background: var(--border);
545
- margin: 4px 0;
546
- }
547
-
548
- /* ─── Links Table ────────────────────────────────────── */
549
- .links-table {
550
- width: 100%;
551
- border-collapse: collapse;
552
- font-size: 0.9rem;
553
- }
554
-
555
- .links-table th,
556
- .links-table td {
557
- padding: 10px 12px;
558
- text-align: left;
559
- border-bottom: 1px solid var(--border);
560
- }
561
-
562
- .links-table th {
563
- color: var(--text-muted);
564
- font-weight: 500;
565
- font-size: 0.82rem;
566
- text-transform: uppercase;
567
- letter-spacing: 0.03em;
568
- }
569
-
570
- .links-table td {
571
- color: var(--text);
572
- }
573
-
574
- .links-table .link-cell {
575
- display: flex;
576
- align-items: center;
577
- gap: 8px;
578
- }
579
-
580
- .links-table input {
581
- flex: 1;
582
- background: var(--bg);
583
- border: 1px solid var(--border);
584
- color: var(--text);
585
- padding: 6px 10px;
586
- border-radius: var(--radius-sm);
587
- font-size: 0.82rem;
588
- font-family: ui-monospace, monospace;
589
- }
590
-
591
- .links-table .copy-btn {
592
- padding: 5px 10px;
593
- border-radius: var(--radius-sm);
594
- background: var(--surface-hover);
595
- border: 1px solid var(--border);
596
- color: var(--text);
597
- font-size: 0.8rem;
598
- cursor: pointer;
599
- }
600
-
601
- .links-table .copy-btn:hover {
602
- background: var(--border);
603
- }
604
-
605
- /* ─── Microspaces Dashboard ────────────────────────────── */
606
- .view-toggle {
607
- display: flex;
608
- gap: 8px;
609
- justify-content: center;
610
- margin-bottom: 24px;
611
- }
612
-
613
- .view-toggle button {
614
- background: var(--surface);
615
- border: 1px solid var(--border);
616
- color: var(--text);
617
- padding: 8px 18px;
618
- border-radius: var(--radius-sm);
619
- cursor: pointer;
620
- font-size: 0.9rem;
621
- transition: all 0.15s;
622
- }
623
-
624
- .view-toggle button.active {
625
- background: var(--accent);
626
- border-color: var(--accent);
627
- color: #fff;
628
- }
629
-
630
- .microspaces-grid {
631
- display: grid;
632
- grid-template-columns: repeat(3, 1fr);
633
- grid-template-rows: repeat(2, 320px);
634
- gap: 12px;
635
- margin-bottom: 40px;
636
- }
637
-
638
- @media (max-width: 900px) {
639
- .microspaces-grid {
640
- grid-template-columns: repeat(2, 1fr);
641
- grid-template-rows: repeat(3, 280px);
642
- }
643
- }
644
-
645
- @media (max-width: 600px) {
646
- .microspaces-grid {
647
- grid-template-columns: 1fr;
648
- grid-template-rows: repeat(6, 260px);
649
- }
650
- }
651
-
652
- .microspace {
653
- background: var(--surface);
654
- border: 1px solid var(--border);
655
- border-radius: var(--radius-sm);
656
- overflow: hidden;
657
- display: flex;
658
- flex-direction: column;
659
- }
660
-
661
- .microspace-header {
662
- display: flex;
663
- align-items: center;
664
- justify-content: space-between;
665
- padding: 6px 10px;
666
- background: var(--surface-hover);
667
- border-bottom: 1px solid var(--border);
668
- font-size: 0.8rem;
669
- color: var(--text-muted);
670
- }
671
-
672
- .microspace-header select {
673
- background: var(--bg);
674
- border: 1px solid var(--border);
675
- color: var(--text);
676
- font-size: 0.78rem;
677
- padding: 3px 6px;
678
- border-radius: 4px;
679
- max-width: 160px;
680
- }
681
-
682
- .microspace-body {
683
- flex: 1;
684
- position: relative;
685
- overflow: hidden;
686
- }
687
-
688
- .microspace-body iframe {
689
- width: 100%;
690
- height: 100%;
691
- border: none;
692
- display: block;
693
- }
694
-
695
- .microspace-empty {
696
- display: flex;
697
- align-items: center;
698
- justify-content: center;
699
- height: 100%;
700
- color: var(--text-muted);
701
- font-size: 0.85rem;
702
- }
703
-
704
- /* ─── Terminal ───────────────────────────────────────── */
705
- .terminal {
706
- background: #0c0c12;
707
- color: #e2e2e8;
708
- font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
709
- font-size: 0.82rem;
710
- line-height: 1.5;
711
- height: 100%;
712
- overflow-y: auto;
713
- padding: 10px;
714
- display: flex;
715
- flex-direction: column;
716
- }
717
-
718
- .terminal-output {
719
- flex: 1;
720
- overflow-y: auto;
721
- white-space: pre-wrap;
722
- word-break: break-word;
723
- }
724
-
725
- .terminal-output .cmd {
726
- color: #22c55e;
727
- }
728
-
729
- .terminal-output .err {
730
- color: #f87171;
731
- }
732
-
733
- .terminal-output .info {
734
- color: #60a5fa;
735
- }
736
-
737
- .terminal-output .warn {
738
- color: #fbbf24;
739
- }
740
-
741
- .terminal-input-line {
742
- display: flex;
743
- align-items: center;
744
- gap: 6px;
745
- padding-top: 4px;
746
- }
747
-
748
- .terminal-prompt {
749
- color: #22c55e;
750
- white-space: nowrap;
751
- }
752
-
753
- .terminal-input {
754
- flex: 1;
755
- background: transparent;
756
- border: none;
757
- color: #e2e2e8;
758
- font-family: inherit;
759
- font-size: inherit;
760
- outline: none;
761
- padding: 0;
762
- margin: 0;
763
- }
764
- </style>
765
  </head>
766
-
767
  <body>
768
- <div class="container">
769
- <header>
770
- <div class="landing-carousel" id="landingCarousel">
771
- <!-- Slide 1: Deploy -->
772
- <div class="landing-slide active" data-slide="0">
773
- <div class="icon">πŸ“¦</div>
774
- <div class="slide-title">Deploy Any DMG</div>
775
- <div class="slide-subtitle">Drag a macOS app bundle onto the Space. We extract it, inspect it, and serve it
776
- live β€” no setup required.</div>
777
- <button class="slide-cta" onclick="switchView('deploy')">Start Deploying β†’</button>
778
- </div>
779
- <!-- Slide 2: Microspaces -->
780
- <div class="landing-slide" data-slide="1">
781
- <div class="icon">πŸͺŸ</div>
782
- <div class="slide-title">Six Microspaces</div>
783
- <div class="slide-subtitle">Run 5 apps side-by-side in a grid, plus a built-in terminal. Mix web apps and
784
- native previews in one dashboard.</div>
785
- <button class="slide-cta" onclick="switchView('micro')">Open Microspaces β†’</button>
786
- </div>
787
- <!-- Slide 3: Share -->
788
- <div class="landing-slide" data-slide="2">
789
- <div class="icon">πŸ”—</div>
790
- <div class="slide-title">Share Instantly</div>
791
- <div class="slide-subtitle">Every upload gets permanent public URLs. Right-click any app to copy its link.
792
- Shareable app viewers for anyone.</div>
793
- <button class="slide-cta" onclick="switchView('links')">View Links β†’</button>
794
- </div>
795
- </div>
796
- <div class="landing-dots" id="landingDots">
797
- <div class="landing-dot active" onclick="goToSlide(0)"></div>
798
- <div class="landing-dot" onclick="goToSlide(1)"></div>
799
- <div class="landing-dot" onclick="goToSlide(2)"></div>
800
- </div>
801
- <div class="landing-timer">
802
- <div class="fill" id="landingTimerFill"></div>
803
- </div>
804
- </header>
 
 
 
 
 
 
805
 
806
- <div class="view-toggle">
807
- <button id="viewDeployBtn" class="active" onclick="switchView('deploy')">πŸ“¦ Deploy</button>
808
- <button id="viewMicroBtn" onclick="switchView('micro')">πŸͺŸ Microspaces</button>
809
- <button id="viewLinksBtn" onclick="switchView('links')">πŸ”— Links</button>
 
 
 
810
  </div>
811
-
812
- <!-- ─── Microspaces Dashboard ─────────────────────────── -->
813
- <div id="microspacesView" style="display:none;">
814
- <div class="microspaces-grid" id="microGrid">
815
- <!-- 6 microspaces generated by JS -->
816
- </div>
817
  </div>
 
818
 
819
- <!-- ─── Links View ────────────────────────────────────── -->
820
- <div id="linksView" style="display:none;">
821
- <div class="section">
822
- <h2>πŸ”— App Links</h2>
823
- <p style="color:var(--text-muted);margin:-12px 0 20px;">Every app gets permanent shareable URLs. Right-click any
824
- card to copy a link.</p>
825
- <div id="linksTableWrap">
826
- <div class="empty">Loading...</div>
827
- </div>
828
- </div>
829
  </div>
 
 
 
 
 
830
 
831
- <!-- ─── Deploy View (existing content) ────────────────── -->
832
- <div id="deployView">
833
- <div class="dropzone" id="dropzone">
834
- <input type="file" id="fileInput" accept=".dmg">
835
- <div class="icon">πŸ“¦</div>
836
- <h3>Drop a DMG here</h3>
837
- <p>or click to browse. The DMG will be opened and its contents shown below.</p>
838
- </div>
839
-
840
- <div class="progress-wrap" id="progressWrap">
841
- <div class="progress-bar">
842
- <div class="fill" id="progressFill"></div>
843
- </div>
844
- <div class="progress-text" id="progressText">Uploading and opening...</div>
845
- </div>
846
-
847
- <div class="result" id="result">
848
- <h4 id="resTitle">Registered</h4>
849
- <div class="field"><span class="label">Slug</span><span class="value" id="resSlug">-</span></div>
850
- <div class="field"><span class="label">Filename</span><span class="value" id="resFilename">-</span></div>
851
- <div class="field"><span class="label">Size</span><span class="value" id="resSize">-</span></div>
852
- <div class="field"><span class="label">SHA-256</span><span class="value" id="resHash">-</span></div>
853
- <div class="actions">
854
- <a href="#" class="btn btn-primary" id="launchBtn" target="_blank"
855
- style="display:none;background:linear-gradient(135deg,#3b82f6,#8b5cf6);">πŸš€ Launch App</a>
856
- <a href="#" class="btn btn-secondary" id="downloadBtn" target="_blank" style="display:none;">⬇ Download</a>
857
- <button class="btn btn-secondary" id="copyBtn">πŸ“‹ Copy Link</button>
858
- </div>
859
- <div id="bundles"></div>
860
-
861
- <div id="previewWrap" style="margin-top:24px;display:none;">
862
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
863
- <h4 style="margin:0;color:var(--accent)">🌐 Live Preview</h4>
864
- <div style="display:flex;gap:8px;">
865
- <select id="previewFileSelect"
866
- style="padding:6px 10px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:0.85rem;max-width:220px;"></select>
867
- <button class="btn btn-secondary" id="previewNewTabBtn" style="padding:6px 14px;font-size:0.82rem;">β†— New
868
- Tab</button>
869
- <button class="btn btn-secondary" id="previewFullscreenBtn" style="padding:6px 14px;font-size:0.82rem;">β›Ά
870
- Fullscreen</button>
871
- </div>
872
- </div>
873
- <div id="previewContainer"
874
- style="position:relative;border:1px solid var(--border);border-radius:var(--radius-sm);overflow:hidden;background:#fff;">
875
- <iframe id="previewFrame" style="width:100%;height:500px;border:none;display:block;"></iframe>
876
- <div id="previewLoading"
877
- style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(10,10,15,0.9);color:var(--text-muted);">
878
- <span style="animation:pulse 1.4s infinite;">Loading preview...</span>
879
- </div>
880
- </div>
881
- <style>
882
- @keyframes pulse {
883
-
884
- 0%,
885
- 100% {
886
- opacity: 0.4
887
- }
888
 
889
- 50% {
890
- opacity: 1
891
- }
892
- }
893
- </style>
894
- </div>
895
  </div>
896
-
897
- <div class="section">
898
- <h2>πŸ§ͺ Create App from URL</h2>
899
- <p style="color:var(--text-muted);margin-bottom:16px;">Turn any website into a downloadable macOS app bundle.
900
- Enter a URL, get an .app/.dmg.</p>
901
- <div style="display:grid;gap:12px;max-width:600px;">
902
- <input type="text" id="webUrl" placeholder="https://example.com"
903
- style="padding:10px 14px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:0.95rem;">
904
- <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
905
- <input type="text" id="webAppName" placeholder="App Name" value="MyApp"
906
- style="padding:10px 14px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:0.95rem;">
907
- <input type="text" id="webBundleId" placeholder="Bundle ID" value="app.localspace.myapp"
908
- style="padding:10px 14px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:0.95rem;">
909
- </div>
910
- <input type="text" id="webVersion" placeholder="Version" value="1.0.0"
911
- style="padding:10px 14px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:0.95rem;">
912
- <button class="btn btn-primary" id="createWebappBtn" style="justify-self:start;">πŸ“¦ Package as App</button>
913
- </div>
914
- <div id="webappResult" style="margin-top:16px;display:none;" class="result"></div>
915
  </div>
 
 
 
916
 
917
- <div class="section">
918
- <h2>πŸ“¦ Published Apps Gallery</h2>
919
- <p style="color:var(--text-muted);margin:-12px 0 20px;">Every uploaded app with a web preview gets its own
920
- public
921
- URL. Click Launch to open it.</p>
922
- <div class="app-grid" id="appGrid">
923
- <div class="empty">No apps yet. Upload a DMG above to open it.</div>
924
- </div>
925
  </div>
926
  </div>
927
-
928
- <div class="toast" id="toast"></div>
929
-
930
- <script>
931
- const $ = id => document.getElementById(id);
932
- const dropzone = $('dropzone'), fileInput = $('fileInput');
933
- const progressWrap = $('progressWrap'), progressFill = $('progressFill'), progressText = $('progressText');
934
- const result = $('result'), bundlesEl = $('bundles');
935
- const toast = $('toast');
936
-
937
- function showToast(msg, type = 'success') {
938
- toast.textContent = msg; toast.className = 'toast ' + type + ' show';
939
- setTimeout(() => toast.classList.remove('show'), 3000);
940
- }
941
-
942
- ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(e => {
943
- dropzone.addEventListener(e, ev => { ev.preventDefault(); ev.stopPropagation(); });
944
- });
945
- ['dragenter', 'dragover'].forEach(e => dropzone.addEventListener(e, () => dropzone.classList.add('dragover')));
946
- ['dragleave', 'drop'].forEach(e => dropzone.addEventListener(e, () => dropzone.classList.remove('dragover')));
947
-
948
- dropzone.addEventListener('drop', e => { if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); });
949
- fileInput.addEventListener('change', e => { if (fileInput.files.length) handleFile(fileInput.files[0]); });
950
-
951
- async function handleFile(file) {
952
- if (!file.name.toLowerCase().endsWith('.dmg')) { showToast('Only .dmg files.', 'error'); return; }
953
- progressWrap.classList.add('active'); result.classList.remove('active'); progressFill.style.width = '0%';
954
- progressText.textContent = 'Uploading and opening DMG...';
955
-
956
- const form = new FormData(); form.append('file', file);
957
- try {
958
- const xhr = new XMLHttpRequest();
959
- xhr.open('POST', '/api/upload');
960
- xhr.upload.onprogress = e => {
961
- if (e.lengthComputable) {
962
- const pct = Math.round((e.loaded / e.total) * 60);
963
- progressFill.style.width = pct + '%';
964
- progressText.textContent = `Uploading... ${pct}%`;
965
- }
966
- };
967
- xhr.onload = () => {
968
- progressWrap.classList.remove('active');
969
- if (xhr.status === 201) {
970
- const data = JSON.parse(xhr.responseText);
971
- showApp(data.app);
972
- showToast(data.message || 'Opened!');
973
- loadApps();
974
- } else {
975
- const err = JSON.parse(xhr.responseText || '{}');
976
- showToast(err.error || 'Upload failed', 'error');
977
- }
978
- };
979
- xhr.onerror = () => { progressWrap.classList.remove('active'); showToast('Network error', 'error'); };
980
- xhr.send(form);
981
- } catch (err) { progressWrap.classList.remove('active'); showToast(String(err), 'error'); }
982
- }
983
-
984
- function showApp(app) {
985
- $('resTitle').textContent = app.opened ? 'βœ… DMG Opened' : '⚠️ Uploaded (could not open)';
986
- $('resTitle').style.color = app.opened ? 'var(--success)' : 'var(--warn)';
987
- $('resSlug').textContent = app.slug;
988
- $('resFilename').textContent = app.filename;
989
- $('resSize').textContent = app.size.toLocaleString() + ' bytes';
990
- $('resHash').textContent = app.sha256.substring(0, 32) + '...';
991
- $('copyBtn').onclick = () => { navigator.clipboard.writeText(window.location.origin + app.download_url); showToast('Link copied!'); };
992
-
993
- // Launch = primary for web-previewable apps; Download = only for non-preview
994
- const launchBtn = $('launchBtn');
995
- const downloadBtn = $('downloadBtn');
996
- if (app.has_preview) {
997
- launchBtn.href = `/app/${app.app_id}`;
998
- launchBtn.style.display = 'inline-flex';
999
- downloadBtn.style.display = 'none';
1000
- } else {
1001
- launchBtn.style.display = 'none';
1002
- downloadBtn.href = app.download_url;
1003
- downloadBtn.style.display = 'inline-flex';
1004
- }
1005
-
1006
- bundlesEl.innerHTML = '';
1007
- if (app.apps && app.apps.length) {
1008
- app.apps.forEach((bundle, i) => {
1009
- const div = document.createElement('div');
1010
- div.className = 'app-bundle';
1011
- const filesHtml = (bundle.files || []).slice(0, 50).map(f => `<div class="item">${escapeHtml(f)}</div>`).join('');
1012
- div.innerHTML = `
1013
- <div class="bundle-header">
1014
- <div class="bundle-icon">πŸš€</div>
1015
- <div class="bundle-title">
1016
- <div class="name">${escapeHtml(bundle.display_name || bundle.bundle_name)} <span class="badge badge-success">v${escapeHtml(bundle.version || '?')}</span></div>
1017
- <div class="meta">${escapeHtml(bundle.bundle_id || '')} Β· ${bundle.file_count} files Β· executable: ${escapeHtml(bundle.executable || 'N/A')}</div>
1018
- </div>
1019
- </div>
1020
- ${filesHtml ? `<div class="file-tree">${filesHtml}</div>` : ''}
1021
- `;
1022
- bundlesEl.appendChild(div);
1023
- });
1024
- } else if (app.opened) {
1025
- bundlesEl.innerHTML = '<p style="color:var(--text-muted);margin-top:12px;">No .app bundles found inside this DMG.</p>';
1026
- }
1027
-
1028
- // Show iframe preview for web content
1029
- const previewWrap = $('previewWrap');
1030
- const previewFrame = $('previewFrame');
1031
- const previewLoading = $('previewLoading');
1032
- const previewFileSelect = $('previewFileSelect');
1033
- previewWrap.style.display = 'none';
1034
- previewFileSelect.innerHTML = '';
1035
-
1036
- if (app.has_preview && app.html_files && app.html_files.length) {
1037
- previewWrap.style.display = 'block';
1038
- previewLoading.style.display = 'flex';
1039
-
1040
- // Populate file selector
1041
- app.html_files.forEach((hf, idx) => {
1042
- const opt = document.createElement('option');
1043
- opt.value = hf.path;
1044
- opt.textContent = hf.path;
1045
- if (hf.path === app.preview_entry) opt.selected = true;
1046
- previewFileSelect.appendChild(opt);
1047
- });
1048
-
1049
- // Load the default entry point
1050
- const entryPath = app.preview_entry || app.html_files[0].path;
1051
- previewFrame.src = `/api/preview/${app.app_id}/${entryPath}`;
1052
- previewFrame.onload = () => { previewLoading.style.display = 'none'; };
1053
-
1054
- // File selector change
1055
- previewFileSelect.onchange = () => {
1056
- previewLoading.style.display = 'flex';
1057
- previewFrame.src = `/api/preview/${app.app_id}/${previewFileSelect.value}`;
1058
- };
1059
-
1060
- // New tab button
1061
- $('previewNewTabBtn').onclick = () => {
1062
- window.open(`/api/preview/${app.app_id}/${previewFileSelect.value || entryPath}`, '_blank');
1063
- };
1064
-
1065
- // Fullscreen button
1066
- $('previewFullscreenBtn').onclick = () => {
1067
- const container = $('previewContainer');
1068
- if (container.requestFullscreen) container.requestFullscreen();
1069
- else if (container.webkitRequestFullscreen) container.webkitRequestFullscreen();
1070
- };
1071
- }
1072
-
1073
- result.classList.add('active');
1074
- }
1075
-
1076
- async function loadApps() {
1077
- try {
1078
- const res = await fetch('/api/apps');
1079
- const data = await res.json();
1080
- const grid = $('appGrid');
1081
- if (!data.apps || !data.apps.length) {
1082
- grid.innerHTML = '<div class="empty">No apps yet. Upload a DMG above to open it.</div>';
1083
- return;
1084
- }
1085
- grid.innerHTML = '';
1086
- data.apps.forEach(a => {
1087
- const date = new Date(a.created_at * 1000).toLocaleString();
1088
- let status;
1089
- if (a.is_webapp) {
1090
- status = `<span class="badge" style="background:rgba(59,130,246,0.15);color:var(--accent);">Web App</span>`;
1091
- } else if (a.opened) {
1092
- status = `<span class="badge badge-success">Opened</span>`;
1093
- } else {
1094
- status = `<span class="badge badge-warn">Closed</span>`;
1095
- }
1096
- const apps = (a.apps || []).map(b => `<div style="font-size:0.82rem;color:var(--text-muted);margin-top:4px;">πŸš€ ${escapeHtml(b.display_name || b.bundle_name)} ${b.version ? 'v' + b.version : ''}</div>`).join('');
1097
- const sourceUrl = a.source_url ? `<div style="font-size:0.82rem;color:var(--text-muted);margin-top:4px;">πŸ”— ${escapeHtml(a.source_url)}</div>` : '';
1098
- const launchLink = a.has_preview
1099
- ? `<a href="/app/${a.app_id}" target="_blank" style="background:linear-gradient(135deg,#3b82f6,#8b5cf6);color:#fff;border:none;">πŸš€ Launch</a>`
1100
- : `<a href="${a.download_url}" download>⬇ Download</a>`;
1101
- const card = document.createElement('div');
1102
- card.className = 'app-card';
1103
- card.innerHTML = `
1104
- <div class="info">
1105
- <div class="name">${escapeHtml(a.filename)} ${status}</div>
1106
- <div class="meta">${escapeHtml(a.slug)} Β· ${a.size.toLocaleString()} bytes Β· ${date}</div>
1107
- ${apps}${sourceUrl}
1108
- </div>
1109
- <div class="actions">${launchLink}</div>`;
1110
- attachContextMenu(card, a.app_id);
1111
- grid.appendChild(card);
1112
- });
1113
- } catch (e) { console.error(e); }
1114
- }
1115
-
1116
- function escapeHtml(text) {
1117
- const div = document.createElement('div');
1118
- div.textContent = text || '';
1119
- return div.innerHTML;
1120
- }
1121
-
1122
- // ─── Create Web App from URL ───────────────────────────────────────────
1123
- $('createWebappBtn').addEventListener('click', async () => {
1124
- const url = $('webUrl').value.trim();
1125
- const appName = $('webAppName').value.trim();
1126
- const bundleId = $('webBundleId').value.trim();
1127
- const version = $('webVersion').value.trim();
1128
- if (!url) { showToast('Enter a URL.', 'error'); return; }
1129
- if (!url.startsWith('http')) { showToast('URL must start with http:// or https://', 'error'); return; }
1130
- $('createWebappBtn').textContent = 'Packaging...';
1131
- $('createWebappBtn').disabled = true;
1132
- try {
1133
- const res = await fetch('/api/create-webapp', {
1134
- method: 'POST',
1135
- headers: { 'Content-Type': 'application/json' },
1136
- body: JSON.stringify({ url, app_name: appName, bundle_id: bundleId, version }),
1137
- });
1138
- const data = await res.json();
1139
- const resultDiv = $('webappResult');
1140
- if (res.status === 201) {
1141
- const app = data.app;
1142
- resultDiv.innerHTML = `
1143
- <h4 style="margin:0 0 12px;color:var(--success)">βœ… ${escapeHtml(app.app_name)} Packaged</h4>
1144
- <div class="field"><span class="label">Source URL</span><span class="value">${escapeHtml(app.source_url)}</span></div>
1145
- <div class="field"><span class="label">Bundle ID</span><span class="value">${escapeHtml(app.bundle_id)}</span></div>
1146
- <div class="field"><span class="label">Version</span><span class="value">${escapeHtml(app.version)}</span></div>
1147
- <div class="field"><span class="label">Download</span><span class="value"><a href="${app.download_url}">${escapeHtml(app.filename)}</a></span></div>
1148
- <p style="margin-top:12px;color:var(--text-muted);font-size:0.85rem;">${escapeHtml(data.message)}</p>
1149
- `;
1150
- resultDiv.style.display = 'block';
1151
- showToast('App packaged!');
1152
- loadApps();
1153
- } else {
1154
- showToast(data.error || 'Failed to package app.', 'error');
1155
- }
1156
- } catch (err) {
1157
- showToast(String(err), 'error');
1158
- } finally {
1159
- $('createWebappBtn').textContent = 'πŸ“¦ Package as App';
1160
- $('createWebappBtn').disabled = false;
1161
- }
1162
- });
1163
-
1164
- // ─── Microspaces & Terminal ───────────────────────────────
1165
- let _appsCache = [];
1166
- let _kvStore = {};
1167
-
1168
- function switchView(view) {
1169
- $('deployView').style.display = view === 'deploy' ? 'block' : 'none';
1170
- $('microspacesView').style.display = view === 'micro' ? 'block' : 'none';
1171
- $('linksView').style.display = view === 'links' ? 'block' : 'none';
1172
- $('viewDeployBtn').classList.toggle('active', view === 'deploy');
1173
- $('viewMicroBtn').classList.toggle('active', view === 'micro');
1174
- $('viewLinksBtn').classList.toggle('active', view === 'links');
1175
- if (view === 'micro') renderMicrospaces();
1176
- if (view === 'links') renderLinks();
1177
- }
1178
-
1179
- // ─── Links View ─────────────────────────────────────────
1180
- async function renderLinks() {
1181
- const wrap = $('linksTableWrap');
1182
- try {
1183
- const res = await fetch('/api/apps');
1184
- const data = await res.json();
1185
- const apps = data.apps || [];
1186
- if (!apps.length) {
1187
- wrap.innerHTML = '<div class="empty">No apps yet. Upload a DMG to generate links.</div>';
1188
- return;
1189
- }
1190
- const origin = window.location.origin;
1191
- let html = `<table class="links-table"><thead><tr><th>App</th><th>App Viewer</th><th>Preview</th><th>Download</th></tr></thead><tbody>`;
1192
- apps.forEach(a => {
1193
- const appUrl = a.has_preview ? `${origin}/app/${a.app_id}` : '';
1194
- const previewUrl = a.has_preview && a.preview_entry ? `${origin}/api/preview/${a.app_id}/${a.preview_entry}` : (a.has_preview ? `${origin}/api/preview/${a.app_id}` : '');
1195
- const downloadUrl = `${origin}${a.download_url}`;
1196
- html += `<tr><td><strong>${escapeHtml(a.filename)}</strong><div style="font-size:0.78rem;color:var(--text-muted);margin-top:2px;">${a.slug}</div></td>`;
1197
- html += `<td>${appUrl ? `<div class="link-cell"><input value="${appUrl}" readonly onclick="this.select()"><button class="copy-btn" onclick="navigator.clipboard.writeText('${appUrl}');showToast('Copied!')">Copy</button></div>` : '<span style="color:var(--text-muted)">β€”</span>'}</td>`;
1198
- html += `<td>${previewUrl ? `<div class="link-cell"><input value="${previewUrl}" readonly onclick="this.select()"><button class="copy-btn" onclick="navigator.clipboard.writeText('${previewUrl}');showToast('Copied!')">Copy</button></div>` : '<span style="color:var(--text-muted)">β€”</span>'}</td>`;
1199
- html += `<td><div class="link-cell"><input value="${downloadUrl}" readonly onclick="this.select()"><button class="copy-btn" onclick="navigator.clipboard.writeText('${downloadUrl}');showToast('Copied!')">Copy</button></div></td></tr>`;
1200
- });
1201
- html += '</tbody></table>';
1202
- wrap.innerHTML = html;
1203
- } catch (e) { wrap.innerHTML = '<div class="empty">Error loading links.</div>'; console.error(e); }
1204
- }
1205
-
1206
- // ─── Context Menu ───────────────────────────────────────
1207
- let _ctxAppId = null;
1208
- document.addEventListener('click', () => { $('ctxMenu').style.display = 'none'; });
1209
- document.addEventListener('scroll', () => { $('ctxMenu').style.display = 'none'; }, true);
1210
-
1211
- function attachContextMenu(el, appId) {
1212
- el.addEventListener('contextmenu', e => {
1213
- e.preventDefault();
1214
- _ctxAppId = appId;
1215
- const menu = $('ctxMenu');
1216
- menu.style.display = 'block';
1217
- const x = Math.min(e.clientX, window.innerWidth - 220);
1218
- const y = Math.min(e.clientY, window.innerHeight - 200);
1219
- menu.style.left = x + 'px';
1220
- menu.style.top = y + 'px';
1221
- });
1222
- }
1223
- function ctxOpenApp() { if (_ctxAppId) window.location.href = `/app/${_ctxAppId}`; }
1224
- function ctxLaunchNewTab() { if (_ctxAppId) window.open(`/app/${_ctxAppId}`, '_blank'); }
1225
- function ctxCopyLink(type) {
1226
- if (!_ctxAppId) return;
1227
- const app = _appsCache.find(a => a.app_id === _ctxAppId); if (!app) return;
1228
- const origin = window.location.origin;
1229
- let url = '';
1230
- if (type === 'app') url = `${origin}/app/${_ctxAppId}`;
1231
- else if (type === 'preview') url = app.preview_entry ? `${origin}/api/preview/${_ctxAppId}/${app.preview_entry}` : `${origin}/api/preview/${_ctxAppId}`;
1232
- else if (type === 'download') url = `${origin}${app.download_url}`;
1233
- navigator.clipboard.writeText(url); showToast(`${type} link copied!`); $('ctxMenu').style.display = 'none';
1234
- }
1235
- function ctxCopyId() { if (_ctxAppId) { navigator.clipboard.writeText(_ctxAppId); showToast('App ID copied!'); $('ctxMenu').style.display = 'none'; } }
1236
-
1237
- async function renderMicrospaces() {
1238
- const grid = $('microGrid');
1239
- // Build 6 panels if not already built
1240
- if (!grid.dataset.built) {
1241
- grid.innerHTML = '';
1242
- for (let i = 0; i < 6; i++) {
1243
- const isTerminal = i === 5; // last cell = terminal
1244
- const div = document.createElement('div');
1245
- div.className = 'microspace';
1246
- div.id = `micro-${i}`;
1247
- div.innerHTML = `
1248
- <div class="microspace-header">
1249
- <span>πŸͺŸ Space ${i + 1}</span>
1250
- ${isTerminal ? '<span>⌨️ Terminal</span>' : `<select id="microSel-${i}" onchange="updateMicrospace(${i})"><option value="">β€” Select app β€”</option></select>`}
1251
- </div>
1252
- <div class="microspace-body" id="microBody-${i}">
1253
- ${isTerminal ? buildTerminalHTML() : '<div class="microspace-empty">Select an app above</div>'}
1254
- </div>
1255
- `;
1256
- grid.appendChild(div);
1257
- if (isTerminal) initTerminal();
1258
- }
1259
- grid.dataset.built = '1';
1260
- }
1261
- // Refresh selectors with current apps
1262
- try {
1263
- const res = await fetch('/api/apps');
1264
- const data = await res.json();
1265
- _appsCache = data.apps || [];
1266
- const previewApps = _appsCache.filter(a => a.has_preview);
1267
- for (let i = 0; i < 5; i++) {
1268
- const sel = $(`microSel-${i}`);
1269
- if (!sel) continue;
1270
- const prevVal = sel.value;
1271
- sel.innerHTML = '<option value="">β€” Select app β€”</option>';
1272
- previewApps.forEach(a => {
1273
- const opt = document.createElement('option');
1274
- opt.value = a.app_id;
1275
- opt.textContent = a.filename;
1276
- sel.appendChild(opt);
1277
- });
1278
- if (prevVal) sel.value = prevVal;
1279
- }
1280
- } catch (e) { console.error(e); }
1281
- }
1282
-
1283
- function updateMicrospace(idx) {
1284
- const sel = $(`microSel-${idx}`);
1285
- const body = $(`microBody-${idx}`);
1286
- const appId = sel.value;
1287
- if (!appId) {
1288
- body.innerHTML = '<div class="microspace-empty">Select an app above</div>';
1289
- return;
1290
- }
1291
- const app = _appsCache.find(a => a.app_id === appId);
1292
- const entryPath = app && app.preview_entry ? app.preview_entry : '';
1293
- const src = entryPath ? `/api/preview/${appId}/${entryPath}` : `/api/preview/${appId}`;
1294
- body.innerHTML = `<iframe src="${src}" loading="lazy"></iframe>`;
1295
- }
1296
-
1297
- function buildTerminalHTML() {
1298
- return `
1299
- <div class="terminal" id="termContainer" onclick="document.getElementById('termInput').focus()">
1300
- <div class="terminal-output" id="termOutput"></div>
1301
- <div class="terminal-input-line">
1302
- <span class="terminal-prompt">localspace$</span>
1303
- <input type="text" class="terminal-input" id="termInput" autocomplete="off" spellcheck="false" onkeydown="handleTerminalKey(event)">
1304
- </div>
1305
- </div>
1306
- `;
1307
- }
1308
-
1309
- function initTerminal() {
1310
- const out = $('termOutput');
1311
- if (!out) return;
1312
- out.innerHTML = '';
1313
- termPrint('LocalSpace Terminal v1.0', 'info');
1314
- termPrint('Type \'help\' for available commands.', 'info');
1315
- termPrint('');
1316
- }
1317
-
1318
- function termPrint(text, cls = '') {
1319
- const out = $('termOutput');
1320
- if (!out) return;
1321
- const line = document.createElement('div');
1322
- if (cls) line.className = cls;
1323
- line.textContent = text;
1324
- out.appendChild(line);
1325
- out.scrollTop = out.scrollHeight;
1326
- }
1327
-
1328
- async function handleTerminalKey(e) {
1329
- if (e.key !== 'Enter') return;
1330
- const input = $('termInput');
1331
- const cmd = input.value.trim();
1332
- input.value = '';
1333
- termPrint(`localspace$ ${cmd}`, 'cmd');
1334
- if (!cmd) return;
1335
- await runTerminalCommand(cmd);
1336
- }
1337
-
1338
- async function runTerminalCommand(cmd) {
1339
- const parts = cmd.split(/\s+/);
1340
- const action = parts[0].toLowerCase();
1341
-
1342
- // ─── KV Memory (session-only, per-tab) ──────────────────
1343
- if (action === 'set') {
1344
- if (parts.length < 3) { termPrint('Usage: set <key> <value>', 'err'); return; }
1345
- const key = parts[1]; const val = parts.slice(2).join(' ');
1346
- if (!_kvStore) _kvStore = {};
1347
- _kvStore[key] = val;
1348
- termPrint(`Set "${key}" = "${val}"`, 'success');
1349
- return;
1350
- }
1351
- if (action === 'get') {
1352
- if (parts.length < 2) { termPrint('Usage: get <key>', 'err'); return; }
1353
- const val = _kvStore?.[parts[1]];
1354
- if (val === undefined) termPrint(`Key "${parts[1]}" not found.`, 'warn');
1355
- else termPrint(`${parts[1]} = ${val}`, 'info');
1356
- return;
1357
- }
1358
- if (action === 'del') {
1359
- if (parts.length < 2) { termPrint('Usage: del <key>', 'err'); return; }
1360
- if (_kvStore) delete _kvStore[parts[1]];
1361
- termPrint(`Deleted "${parts[1]}"`, 'success');
1362
- return;
1363
- }
1364
- if (action === 'keys') {
1365
- const keys = _kvStore ? Object.keys(_kvStore) : [];
1366
- if (!keys.length) { termPrint('No KV entries.', 'warn'); return; }
1367
- termPrint('KV Keys:', 'info');
1368
- keys.forEach(k => termPrint(` ${k} = ${_kvStore[k]}`, 'muted'));
1369
- termPrint(`${keys.length} entries.`);
1370
- return;
1371
- }
1372
- if (action === 'clear_kv') {
1373
- _kvStore = {};
1374
- termPrint('KV memory cleared.', 'success');
1375
- return;
1376
- }
1377
-
1378
- if (action === 'help' || action === 'h') {
1379
- termPrint('Commands:', 'info');
1380
- termPrint(' help Show this message');
1381
- termPrint(' apps List all apps with previews');
1382
- termPrint(' open <id> Open app preview URL');
1383
- termPrint(' launch <id> Launch app in new tab');
1384
- termPrint(' set <k> <v> Store KV pair (session)');
1385
- termPrint(' get <k> Retrieve KV value');
1386
- termPrint(' del <k> Delete KV pair');
1387
- termPrint(' keys List all KV keys');
1388
- termPrint(' clear_kv Clear KV memory');
1389
- termPrint(' status Server status');
1390
- termPrint(' clear Clear terminal');
1391
- termPrint(' reload Reload app list');
1392
- return;
1393
- }
1394
-
1395
- if (action === 'clear') {
1396
- $('termOutput').innerHTML = '';
1397
- return;
1398
- }
1399
-
1400
- if (action === 'status') {
1401
- termPrint('Server: ONLINE', 'success');
1402
- termPrint(`Apps cached: ${_appsCache.length}`);
1403
- const previewCount = _appsCache.filter(a => a.has_preview).length;
1404
- termPrint(`Apps with preview: ${previewCount}`);
1405
- return;
1406
- }
1407
-
1408
- if (action === 'apps') {
1409
- const previewApps = _appsCache.filter(a => a.has_preview);
1410
- if (!previewApps.length) { termPrint('No apps with web preview.', 'warn'); return; }
1411
- previewApps.forEach(a => {
1412
- termPrint(` ${a.app_id.substring(0, 8)}… ${a.filename}`, 'info');
1413
- });
1414
- return;
1415
- }
1416
-
1417
- if (action === 'reload') {
1418
- await renderMicrospaces();
1419
- termPrint('App list reloaded.', 'success');
1420
- return;
1421
- }
1422
-
1423
- if (action === 'open' || action === 'launch') {
1424
- const id = parts[1];
1425
- if (!id) { termPrint('Usage: open <app-id>', 'err'); return; }
1426
- const app = _appsCache.find(a => a.app_id.startsWith(id) || a.app_id === id);
1427
- if (!app) { termPrint(`App not found: ${id}`, 'err'); return; }
1428
- const entryPath = app.preview_entry || '';
1429
- const url = entryPath ? `/api/preview/${app.app_id}/${entryPath}` : `/api/preview/${app.app_id}`;
1430
- if (action === 'launch') {
1431
- window.open(url, '_blank');
1432
- termPrint(`Launched ${app.filename} in new tab.`, 'success');
1433
- } else {
1434
- termPrint(`Preview URL: ${window.location.origin}${url}`, 'info');
1435
- }
1436
- return;
1437
- }
1438
-
1439
- termPrint(`Unknown command: ${action}. Type 'help' for commands.`, 'err');
1440
- }
1441
-
1442
- // ─── Landing Carousel ───────────────────────────────────
1443
- let _currentSlide = 0;
1444
- const _slideInterval = 40000; // 40 seconds
1445
- let _slideTimer = null;
1446
- let _timerStart = 0;
1447
-
1448
- function goToSlide(idx) {
1449
- const slides = document.querySelectorAll('.landing-slide');
1450
- const dots = document.querySelectorAll('.landing-dot');
1451
- slides.forEach((s, i) => s.classList.toggle('active', i === idx));
1452
- dots.forEach((d, i) => d.classList.toggle('active', i === idx));
1453
- _currentSlide = idx;
1454
- _timerStart = Date.now();
1455
- }
1456
-
1457
- function nextSlide() {
1458
- const total = document.querySelectorAll('.landing-slide').length;
1459
- goToSlide((_currentSlide + 1) % total);
1460
- }
1461
-
1462
- function startCarousel() {
1463
- if (_slideTimer) clearInterval(_slideTimer);
1464
- _timerStart = Date.now();
1465
- _slideTimer = setInterval(nextSlide, _slideInterval);
1466
- animateTimerBar();
1467
- }
1468
-
1469
- function animateTimerBar() {
1470
- const fill = $('landingTimerFill');
1471
- if (!fill) return;
1472
- function tick() {
1473
- if (!_timerStart) return;
1474
- const elapsed = Date.now() - _timerStart;
1475
- const pct = Math.min((elapsed / _slideInterval) * 100, 100);
1476
- fill.style.width = pct + '%';
1477
- if (pct < 100) requestAnimationFrame(tick);
1478
- }
1479
- requestAnimationFrame(tick);
1480
- }
1481
-
1482
- // Pause carousel when user interacts, resume after
1483
- document.querySelectorAll('.landing-slide').forEach(s => {
1484
- s.addEventListener('mouseenter', () => { if (_slideTimer) clearInterval(_slideTimer); });
1485
- s.addEventListener('mouseleave', () => startCarousel());
1486
- });
1487
-
1488
- startCarousel();
1489
-
1490
- loadApps();
1491
- </script>
1492
- </div> <!-- /deployView -->
1493
- </div> <!-- /container -->
1494
-
1495
- <!-- ─── Context Menu ─────────────────────────────────── -->
1496
- <div class="ctx-menu" id="ctxMenu">
1497
- <div class="ctx-menu-item" onclick="ctxOpenApp()">πŸš€ Open App</div>
1498
- <div class="ctx-menu-item" onclick="ctxLaunchNewTab()">β†— Open in New Tab</div>
1499
- <div class="ctx-menu-sep"></div>
1500
- <div class="ctx-menu-item" onclick="ctxCopyLink('app')">πŸ“‹ Copy App Link</div>
1501
- <div class="ctx-menu-item" onclick="ctxCopyLink('preview')">🌐 Copy Preview Link</div>
1502
- <div class="ctx-menu-item" onclick="ctxCopyLink('download')">⬇ Copy Download Link</div>
1503
- <div class="ctx-menu-sep"></div>
1504
- <div class="ctx-menu-item" onclick="ctxCopyId()"># Copy App ID</div>
1505
  </div>
1506
- </body>
1507
 
1508
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  <!DOCTYPE html>
2
  <html lang="en">
 
3
  <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LocalSpace Deployer β€” DMG, GGUF, Sites & Inference</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0a0a0f; --bg-card: #14141a; --bg-hover: #1c1c24; --border: #2a2a35;
10
+ --text: #e2e2e8; --text-dim: #888898; --text-muted: #5a5a68;
11
+ --accent: #6366f1; --accent-hover: #818cf8; --accent-dim: rgba(99,102,241,0.15);
12
+ --green: #22c55e; --green-dim: rgba(34,197,94,0.15);
13
+ --red: #ef4444; --red-dim: rgba(239,68,68,0.15);
14
+ --yellow: #eab300; --yellow-dim: rgba(234,179,0,0.15);
15
+ --cyan: #06b6d4; --cyan-dim: rgba(6,182,212,0.15);
16
+ --radius: 12px; --radius-sm: 8px;
17
+ }
18
+ * { box-sizing: border-box; margin: 0; padding: 0; }
19
+ body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; min-height: 100vh; line-height: 1.6; }
20
+ a { color: var(--accent-hover); text-decoration: none; }
21
+ a:hover { text-decoration: underline; }
22
+ nav { position: sticky; top: 0; z-index: 100; background: rgba(10,10,15,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); padding: 14px 24px; display: flex; align-items: center; justify-content: space-between; }
23
+ nav .logo { font-weight: 700; font-size: 1.1rem; display: flex; align-items: center; gap: 8px; }
24
+ nav .logo span { color: var(--accent); }
25
+ nav .links { display: flex; gap: 20px; align-items: center; }
26
+ nav .links a { color: var(--text-dim); font-size: 0.9rem; }
27
+ nav .links a:hover { color: var(--text); text-decoration: none; }
28
+ nav .cta { background: var(--accent); color: #fff; padding: 7px 16px; border-radius: var(--radius-sm); font-size: 0.85rem; font-weight: 600; }
29
+ nav .cta:hover { background: var(--accent-hover); text-decoration: none; }
30
+ .hero { text-align: center; padding: 80px 24px 60px; max-width: 720px; margin: 0 auto; }
31
+ .hero h1 { font-size: 2.8rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 16px; background: linear-gradient(135deg, #e2e2e8 0%, #818cf8 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
32
+ .hero p { font-size: 1.15rem; color: var(--text-dim); margin-bottom: 32px; }
33
+ .hero .badges { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-bottom: 40px; }
34
+ .hero .badge { background: var(--bg-card); border: 1px solid var(--border); padding: 6px 14px; border-radius: 999px; font-size: 0.82rem; color: var(--text-dim); }
35
+ .hero .badge strong { color: var(--text); }
36
+ .features { max-width: 900px; margin: 0 auto 60px; display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; padding: 0 24px; }
37
+ .feature { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; transition: border-color 0.2s; }
38
+ .feature:hover { border-color: var(--accent); }
39
+ .feature .icon { font-size: 1.6rem; margin-bottom: 12px; }
40
+ .feature h3 { font-size: 1rem; font-weight: 600; margin-bottom: 6px; }
41
+ .feature p { font-size: 0.88rem; color: var(--text-dim); }
42
+ .section { max-width: 900px; margin: 0 auto 80px; padding: 0 24px; }
43
+ .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
44
+ .section-header h2 { font-size: 1.4rem; font-weight: 700; }
45
+ .section-header .count { background: var(--bg-card); border: 1px solid var(--border); padding: 4px 12px; border-radius: 999px; font-size: 0.82rem; color: var(--text-dim); }
46
+ .tabs { display: flex; gap: 4px; margin-bottom: 24px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 4px; }
47
+ .tab { flex: 1; padding: 10px 16px; border-radius: var(--radius-sm); font-size: 0.88rem; font-weight: 500; color: var(--text-dim); cursor: pointer; border: none; background: none; text-align: center; }
48
+ .tab:hover { color: var(--text); }
49
+ .tab.active { background: var(--accent); color: #fff; }
50
+ .tab-content { display: none; }
51
+ .tab-content.active { display: block; }
52
+ .dropzone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 48px 24px; text-align: center; cursor: pointer; transition: all 0.2s; background: var(--bg-card); }
53
+ .dropzone:hover, .dropzone.drag { border-color: var(--accent); background: var(--accent-dim); }
54
+ .dropzone .dz-icon { font-size: 2.5rem; margin-bottom: 12px; }
55
+ .dropzone .dz-text { font-size: 1.05rem; font-weight: 600; margin-bottom: 4px; }
56
+ .dropzone .dz-sub { font-size: 0.85rem; color: var(--text-dim); }
57
+ .dropzone .dz-formats { margin-top: 16px; display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
58
+ .dropzone .dz-format { background: var(--bg); border: 1px solid var(--border); padding: 4px 10px; border-radius: 6px; font-size: 0.78rem; color: var(--text-dim); }
59
+ .dropzone input[type=file] { display: none; }
60
+ .upload-progress { margin-top: 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; display: none; }
61
+ .upload-progress.active { display: block; }
62
+ .upload-progress .bar-wrap { background: var(--bg); border-radius: 999px; height: 6px; overflow: hidden; margin-top: 8px; }
63
+ .upload-progress .bar { height: 100%; background: var(--accent); border-radius: 999px; transition: width 0.2s; width: 0%; }
64
+ .upload-progress .status { font-size: 0.88rem; color: var(--text-dim); }
65
+ .creator-box { margin-top: 24px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; }
66
+ .creator-box h3 { font-size: 1rem; font-weight: 600; margin-bottom: 4px; }
67
+ .creator-box p { font-size: 0.85rem; color: var(--text-dim); margin-bottom: 16px; }
68
+ .creator-box .row { display: flex; gap: 10px; flex-wrap: wrap; }
69
+ .creator-box input { flex: 1; min-width: 180px; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 10px 14px; border-radius: var(--radius-sm); font-size: 0.9rem; outline: none; }
70
+ .creator-box input:focus { border-color: var(--accent); }
71
+ .creator-box button { background: var(--accent); color: #fff; border: none; padding: 10px 20px; border-radius: var(--radius-sm); font-size: 0.9rem; font-weight: 600; cursor: pointer; }
72
+ .creator-box button:hover { background: var(--accent-hover); }
73
+ .creator-box button:disabled { opacity: 0.5; }
74
+ .inference-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; }
75
+ .inference-panel .inf-status { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; font-size: 0.88rem; }
76
+ .inference-panel .inf-status .dot { width: 8px; height: 8px; border-radius: 50%; }
77
+ .inference-panel .inf-status .dot.on { background: var(--green); }
78
+ .inference-panel .inf-status .dot.off { background: var(--red); }
79
+ .inference-panel .model-select { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
80
+ .inference-panel select { flex: 1; min-width: 200px; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 10px 14px; border-radius: var(--radius-sm); font-size: 0.9rem; outline: none; }
81
+ .inference-panel .chat-box { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; min-height: 200px; max-height: 400px; overflow-y: auto; margin-bottom: 12px; }
82
+ .inference-panel .chat-msg { margin-bottom: 12px; }
83
+ .inference-panel .chat-msg .role { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; margin-bottom: 2px; }
84
+ .inference-panel .chat-msg .role.user { color: var(--accent); }
85
+ .inference-panel .chat-msg .role.assistant { color: var(--green); }
86
+ .inference-panel .chat-msg .bubble { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; font-size: 0.88rem; white-space: pre-wrap; }
87
+ .inference-panel .chat-input { display: flex; gap: 10px; }
88
+ .inference-panel .chat-input input { flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 10px 14px; border-radius: var(--radius-sm); font-size: 0.9rem; outline: none; }
89
+ .inference-panel .chat-input button { background: var(--accent); color: #fff; border: none; padding: 10px 20px; border-radius: var(--radius-sm); font-size: 0.9rem; font-weight: 600; cursor: pointer; }
90
+ .inference-panel .chat-input button:disabled { opacity: 0.5; }
91
+ .inference-panel .inf-hint { font-size: 0.82rem; color: var(--text-muted); margin-top: 12px; padding: 10px; background: var(--bg); border-radius: 8px; border: 1px solid var(--border); }
92
+ .apps-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; margin-top: 24px; }
93
+ .app-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; transition: border-color 0.2s, transform 0.15s; }
94
+ .app-card:hover { border-color: var(--accent); transform: translateY(-2px); }
95
+ .app-card .card-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 12px; }
96
+ .app-card .card-title { font-weight: 600; font-size: 0.95rem; word-break: break-all; }
97
+ .app-card .card-type { font-size: 0.72rem; padding: 2px 8px; border-radius: 999px; font-weight: 600; white-space: nowrap; margin-left: 8px; }
98
+ .app-card .card-type.dmg { background: var(--accent-dim); color: var(--accent-hover); }
99
+ .app-card .card-type.gguf { background: var(--green-dim); color: var(--green); }
100
+ .app-card .card-type.webapp { background: var(--yellow-dim); color: var(--yellow); }
101
+ .app-card .card-type.site { background: var(--cyan-dim); color: var(--cyan); }
102
+ .app-card .card-type.chatgpt { background: var(--green-dim); color: var(--green); }
103
+ .app-card .card-meta { display: flex; flex-wrap: wrap; gap: 12px; font-size: 0.8rem; color: var(--text-dim); margin-bottom: 12px; }
104
+ .app-card .card-status { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8rem; margin-bottom: 12px; }
105
+ .app-card .card-status.ok { color: var(--green); }
106
+ .app-card .card-status.err { color: var(--red); }
107
+ .app-card .card-status .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
108
+ .app-card .card-actions { display: flex; gap: 8px; flex-wrap: wrap; }
109
+ .app-card .card-actions a, .app-card .card-actions button { background: var(--bg-hover); border: 1px solid var(--border); color: var(--text); padding: 6px 12px; border-radius: var(--radius-sm); font-size: 0.82rem; text-decoration: none; cursor: pointer; }
110
+ .app-card .card-actions a:hover, .app-card .card-actions button:hover { background: var(--border); text-decoration: none; }
111
+ .app-card .card-actions .danger:hover { background: var(--red-dim); border-color: var(--red); }
112
+ .app-card .card-details { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; font-size: 0.82rem; color: var(--text-dim); }
113
+ .app-card .card-details .detail-row { display: flex; justify-content: space-between; padding: 2px 0; }
114
+ .app-card .card-details .detail-row strong { color: var(--text); }
115
+ .app-card .gguf-meta { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; font-size: 0.8rem; color: var(--text-dim); max-height: 200px; overflow-y: auto; }
116
+ .app-card .gguf-meta .meta-row { display: flex; justify-content: space-between; padding: 2px 0; }
117
+ .app-card .gguf-meta .meta-row strong { color: var(--text); }
118
+ .empty { text-align: center; padding: 48px 24px; color: var(--text-muted); }
119
+ .empty .icon { font-size: 2rem; margin-bottom: 8px; }
120
+ footer { border-top: 1px solid var(--border); padding: 32px 24px; text-align: center; font-size: 0.82rem; color: var(--text-muted); }
121
+ .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: none; align-items: center; justify-content: center; z-index: 200; padding: 24px; }
122
+ .modal-overlay.active { display: flex; }
123
+ .modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); max-width: 700px; width: 100%; max-height: 80vh; overflow-y: auto; padding: 28px; }
124
+ .modal h2 { font-size: 1.2rem; margin-bottom: 16px; }
125
+ .modal .close { float: right; cursor: pointer; color: var(--text-dim); font-size: 1.3rem; }
126
+ .modal .close:hover { color: var(--text); }
127
+ .modal pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; overflow-x: auto; font-size: 0.82rem; color: var(--text-dim); }
128
+ @media (max-width: 640px) { .hero h1 { font-size: 2rem; } .hero p { font-size: 1rem; } .features { grid-template-columns: 1fr; } .apps-grid { grid-template-columns: 1fr; } nav .links a:not(.cta) { display: none; } .tab { font-size: 0.78rem; padding: 8px 8px; } }
129
+ .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; }
130
+ @keyframes spin { to { transform: rotate(360deg); } }
131
+ </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  </head>
 
133
  <body>
134
+ <nav>
135
+ <div class="logo">⬑ <span>LocalSpace</span> Deployer</div>
136
+ <div class="links">
137
+ <a href="#features">Features</a>
138
+ <a href="#deploy">Deploy</a>
139
+ <a href="https://huggingface.co/spaces/josephrw/localspace-deployer" target="_blank">HF Space</a>
140
+ <a href="#deploy" class="cta">Get Started</a>
141
+ </div>
142
+ </nav>
143
+
144
+ <section class="hero">
145
+ <h1>Drag a DMG, GGUF, or ZIP.<br>It gets opened on the server.</h1>
146
+ <p>Upload macOS disk images, GGUF model files, or static site ZIPs. LocalSpace extracts, inspects, serves, and runs inference β€” all in one Hugging Face Space. No Gradio. No API keys. Pure FastAPI.</p>
147
+ <div class="badges">
148
+ <div class="badge"><strong>DMG</strong> extraction</div>
149
+ <div class="badge"><strong>GGUF</strong> inference</div>
150
+ <div class="badge"><strong>Tiny Netlify</strong> site deploy</div>
151
+ <div class="badge"><strong>ChatGPT export</strong> β†’ dApp</div>
152
+ <div class="badge"><strong>Persistent</strong> storage</div>
153
+ </div>
154
+ </section>
155
+
156
+ <section class="features" id="features">
157
+ <div class="feature"><div class="icon">πŸ“¦</div><h3>DMG Extraction</h3><p>Upload .dmg β†’ 7z extracts server-side, finds .app bundles, reads Info.plist, lists files, SHA-256 hashes.</p></div>
158
+ <div class="feature"><div class="icon">🧠</div><h3>GGUF Inference</h3><p>Upload .gguf β†’ parse metadata, load with llama-cpp-python, serve OpenAI-compatible /api/inference/chat endpoint.</p></div>
159
+ <div class="feature"><div class="icon">🌐</div><h3>Tiny Netlify</h3><p>Upload a static site ZIP β†’ get a hosted URL. Base-tag injection, full MIME support, file tree browsing.</p></div>
160
+ <div class="feature"><div class="icon">πŸ’¬</div><h3>ChatGPT β†’ dApp</h3><p>Upload ChatGPT export (conversations.json or .zip) β†’ get a browsable dApp with sidebar, search, and message viewer.</p></div>
161
+ <div class="feature"><div class="icon">πŸ”—</div><h3>Web App β†’ DMG</h3><p>Paste any URL β†’ get a macOS .app bundle wrapper. Creates Info.plist, executable, and HTML fallback.</p></div>
162
+ <div class="feature"><div class="icon">πŸ’Ύ</div><h3>Persistent Storage</h3><p>Auto-detects /data mount on HF Spaces. Falls back to local storage. All artifacts survive restarts when mounted.</p></div>
163
+ </section>
164
+
165
+ <section class="section" id="deploy">
166
+ <div class="section-header">
167
+ <h2>Artifact Deployer</h2>
168
+ <div class="count" id="app-count">0 artifacts</div>
169
+ </div>
170
+ <div class="tabs">
171
+ <button class="tab active" onclick="switchTab(event,'dmg-gguf')">πŸ“¦ DMG / GGUF</button>
172
+ <button class="tab" onclick="switchTab(event,'site')">🌐 Deploy Site</button>
173
+ <button class="tab" onclick="switchTab(event,'chatgpt')">πŸ’¬ ChatGPT Export</button>
174
+ <button class="tab" onclick="switchTab(event,'inference')">🧠 Inference</button>
175
+ <button class="tab" onclick="switchTab(event,'webapp')">πŸ”— Web App</button>
176
+ </div>
177
 
178
+ <div class="tab-content active" id="tab-dmg-gguf">
179
+ <div class="dropzone" id="dropzone">
180
+ <div class="dz-icon">⬆️</div>
181
+ <div class="dz-text">Drop a DMG or GGUF file here</div>
182
+ <div class="dz-sub">or click to browse</div>
183
+ <div class="dz-formats"><div class="dz-format">.dmg</div><div class="dz-format">.gguf</div></div>
184
+ <input type="file" id="file-input" accept=".dmg,.gguf">
185
  </div>
186
+ <div class="upload-progress" id="upload-progress">
187
+ <div class="status" id="upload-status">Uploading...</div>
188
+ <div class="bar-wrap"><div class="bar" id="upload-bar"></div></div>
 
 
 
189
  </div>
190
+ </div>
191
 
192
+ <div class="tab-content" id="tab-site">
193
+ <div class="dropzone" id="site-dropzone">
194
+ <div class="dz-icon">🌐</div>
195
+ <div class="dz-text">Drop a static site ZIP here</div>
196
+ <div class="dz-sub">index.html will be served at /site/{id}/</div>
197
+ <div class="dz-formats"><div class="dz-format">.zip</div></div>
198
+ <input type="file" id="site-file-input" accept=".zip">
 
 
 
199
  </div>
200
+ <div class="upload-progress" id="site-upload-progress">
201
+ <div class="status" id="site-upload-status">Deploying...</div>
202
+ <div class="bar-wrap"><div class="bar" id="site-upload-bar"></div></div>
203
+ </div>
204
+ </div>
205
 
206
+ <div class="tab-content" id="tab-chatgpt">
207
+ <div class="dropzone" id="chatgpt-dropzone">
208
+ <div class="dz-icon">πŸ’¬</div>
209
+ <div class="dz-text">Drop a ChatGPT export here</div>
210
+ <div class="dz-sub">conversations.json or export ZIP β†’ browsable dApp</div>
211
+ <div class="dz-formats"><div class="dz-format">.json</div><div class="dz-format">.zip</div></div>
212
+ <input type="file" id="chatgpt-file-input" accept=".json,.zip">
213
+ </div>
214
+ <div class="upload-progress" id="chatgpt-upload-progress">
215
+ <div class="status" id="chatgpt-upload-status">Importing...</div>
216
+ <div class="bar-wrap"><div class="bar" id="chatgpt-upload-bar"></div></div>
217
+ </div>
218
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
+ <div class="tab-content" id="tab-inference">
221
+ <div class="inference-panel">
222
+ <div class="inf-status"><span class="dot" id="inf-dot"></span><span id="inf-status-text">Checking...</span></div>
223
+ <div class="model-select">
224
+ <select id="inf-model-select"><option value="">Select a GGUF model...</option></select>
225
+ <button onclick="loadModel()" id="inf-load-btn">Load Model</button>
226
  </div>
227
+ <div class="chat-box" id="inf-chat-box"><div style="color:var(--text-muted);text-align:center;padding:40px">Load a GGUF model to start chatting</div></div>
228
+ <div class="chat-input">
229
+ <input type="text" id="inf-chat-input" placeholder="Type a message..." onkeydown="if(event.key==='Enter')sendChat()" disabled>
230
+ <button onclick="sendChat()" id="inf-send-btn" disabled>Send</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  </div>
232
+ <div class="inf-hint" id="inf-hint"></div>
233
+ </div>
234
+ </div>
235
 
236
+ <div class="tab-content" id="tab-webapp">
237
+ <div class="creator-box">
238
+ <h3>πŸ”— Create macOS App from URL</h3>
239
+ <p>Paste a web app URL to generate a .app bundle wrapper that opens it on macOS.</p>
240
+ <div class="row">
241
+ <input type="text" id="webapp-url" placeholder="https://your-web-app-url.com">
242
+ <input type="text" id="webapp-name" placeholder="App name" style="max-width:180px">
243
+ <button id="webapp-btn" onclick="createWebapp()">Create App</button>
244
  </div>
245
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  </div>
 
247
 
248
+ <div class="apps-grid" id="apps-grid"></div>
249
+ </section>
250
+
251
+ <div class="modal-overlay" id="modal-overlay" onclick="if(event.target===this)closeModal()">
252
+ <div class="modal"><span class="close" onclick="closeModal()">&times;</span><h2 id="modal-title">Details</h2><div id="modal-body"></div></div>
253
+ </div>
254
+
255
+ <footer>
256
+ LocalSpace Deployer β€” DMG, GGUF, Sites & Inference on Hugging Face Spaces<br>
257
+ <a href="https://huggingface.co/spaces/josephrw/localspace-deployer" target="_blank">huggingface.co/spaces/josephrw/localspace-deployer</a>
258
+ </footer>
259
+
260
+ <script>
261
+ let apps = [];
262
+ let loadedModelId = null;
263
+
264
+ document.addEventListener('DOMContentLoaded', () => { loadApps(); setupDropzones(); checkInference(); });
265
+
266
+ function switchTab(e, name) {
267
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
268
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
269
+ e.target.classList.add('active');
270
+ document.getElementById('tab-' + name).classList.add('active');
271
+ }
272
+
273
+ function setupDropzones() {
274
+ setupDropzone('dropzone', 'file-input', uploadDmgGguf);
275
+ setupDropzone('site-dropzone', 'site-file-input', uploadSite);
276
+ setupDropzone('chatgpt-dropzone', 'chatgpt-file-input', uploadChatGpt);
277
+ }
278
+ function setupDropzone(dzId, inputId, handler) {
279
+ const dz = document.getElementById(dzId); const input = document.getElementById(inputId);
280
+ if (!dz || !input) return;
281
+ dz.addEventListener('click', () => input.click());
282
+ input.addEventListener('change', e => { if (e.target.files[0]) handler(e.target.files[0]); });
283
+ dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag'); });
284
+ dz.addEventListener('dragleave', () => dz.classList.remove('drag'));
285
+ dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('drag'); if (e.dataTransfer.files[0]) handler(e.dataTransfer.files[0]); });
286
+ }
287
+
288
+ function uploadFile(file, url, progId, barId, statusId, okMsg) {
289
+ const prog = document.getElementById(progId); const bar = document.getElementById(barId); const status = document.getElementById(statusId);
290
+ prog.classList.add('active'); bar.style.width = '0%'; status.textContent = `Uploading ${file.name}...`;
291
+ const fd = new FormData(); fd.append('file', file);
292
+ const xhr = new XMLHttpRequest(); xhr.open('POST', url);
293
+ xhr.upload.onprogress = e => { if (e.lengthComputable) { const pct = Math.round((e.loaded/e.total)*100); bar.style.width = pct+'%'; status.textContent = `Uploading ${file.name}... ${pct}%`; } };
294
+ xhr.onload = () => {
295
+ if (xhr.status === 201) { const d = JSON.parse(xhr.responseText); status.textContent = `βœ“ ${d.message||okMsg}`; bar.style.width = '100%'; setTimeout(() => prog.classList.remove('active'), 2500); loadApps(); }
296
+ else { let m='Upload failed'; try{m=JSON.parse(xhr.responseText).error||m;}catch{} status.textContent = `βœ— ${m}`; setTimeout(() => prog.classList.remove('active'), 4000); }
297
+ };
298
+ xhr.onerror = () => { status.textContent = 'βœ— Network error'; setTimeout(() => prog.classList.remove('active'), 3000); };
299
+ xhr.send(fd);
300
+ }
301
+ function uploadDmgGguf(f) { const e=f.name.toLowerCase().split('.').pop(); if(e!=='dmg'&&e!=='gguf'){alert('Only .dmg and .gguf');return;} uploadFile(f,'/api/upload','upload-progress','upload-bar','upload-status','Uploaded.'); }
302
+ function uploadSite(f) { if(!f.name.toLowerCase().endsWith('.zip')){alert('Only .zip');return;} uploadFile(f,'/api/deploy-site','site-upload-progress','site-upload-bar','site-upload-status','Site deployed.'); }
303
+ function uploadChatGpt(f) { const e=f.name.toLowerCase().split('.').pop(); if(e!=='json'&&e!=='zip'){alert('Only .json or .zip');return;} uploadFile(f,'/api/import/chatgpt','chatgpt-upload-progress','chatgpt-upload-bar','chatgpt-upload-status','dApp deployed.'); }
304
+
305
+ async function loadApps() {
306
+ try { const r = await fetch('/api/apps'); const d = await r.json(); apps = d.apps||[]; renderApps(); updateModelSelect(); } catch(e) { console.error(e); }
307
+ }
308
+ function renderApps() {
309
+ const g = document.getElementById('apps-grid'); const c = document.getElementById('app-count');
310
+ c.textContent = `${apps.length} artifact${apps.length!==1?'s':''}`;
311
+ if (!apps.length) { g.innerHTML = `<div class="empty" style="grid-column:1/-1"><div class="icon">πŸ“¦</div><div>No artifacts yet. Upload something to get started.</div></div>`; return; }
312
+ g.innerHTML = apps.map(renderCard).join('');
313
+ }
314
+ function renderCard(a) {
315
+ const t = a.file_type||'dmg';
316
+ const tc = ['dmg','gguf','webapp','site','chatgpt'].includes(t)?t:'dmg';
317
+ const tl = {dmg:'DMG',gguf:'GGUF',webapp:'WEB APP',site:'SITE',chatgpt:'CHATGPT'}[t]||t.toUpperCase();
318
+ const ok = a.opened||a.is_webapp;
319
+ const st = a.opened ? (t==='gguf'?'Inspected':'Opened') : (a.is_webapp?'Packaged':(a.error||'Failed'));
320
+ const sc = ok?'ok':'err';
321
+ let det = '';
322
+ if (t==='dmg'&&a.apps&&a.apps.length) { const app=a.apps[0]; det=`<div class="card-details"><div class="detail-row"><span>Bundle</span><strong>${esc(app.bundle_name||'β€”')}</strong></div><div class="detail-row"><span>Bundle ID</span><strong>${esc(app.bundle_id||'β€”')}</strong></div><div class="detail-row"><span>Version</span><strong>${esc(app.version||'β€”')}</strong></div><div class="detail-row"><span>Files</span><strong>${app.file_count||0}</strong></div></div>`; }
323
+ if (t==='site') { det=`<div class="card-details"><div class="detail-row"><span>Files</span><strong>${a.file_count||0}</strong></div><div class="detail-row"><span>Total size</span><strong>${a.total_size_human||'β€”'}</strong></div><div class="detail-row"><span>Index</span><strong>${esc(a.index_file||'index.html')}</strong></div></div>`; }
324
+ if (t==='chatgpt') { det=`<div class="card-details"><div class="detail-row"><span>Conversations</span><strong>${a.conversation_count||0}</strong></div><div class="detail-row"><span>Messages</span><strong>${a.total_messages||0}</strong></div></div>`; }
325
+ let gm = '';
326
+ if (t==='gguf'&&a.gguf&&a.gguf.valid) { const g=a.gguf; const mr=Object.entries(g.metadata||{}).slice(0,15).map(([k,v])=>`<div class="meta-row"><span>${esc(k)}</span><strong>${esc(String(v.value)).substring(0,60)}</strong></div>`).join(''); gm=`<div class="gguf-meta"><div class="meta-row"><span>Architecture</span><strong>${esc(g.architecture||'β€”')}</strong></div><div class="meta-row"><span>Version</span><strong>GGUF v${g.version}</strong></div><div class="meta-row"><span>Tensors</span><strong>${g.tensor_count}</strong></div><div class="meta-row"><span>KV pairs</span><strong>${g.kv_count}</strong></div>${g.context_length?`<div class="meta-row"><span>Context</span><strong>${g.context_length}</strong></div>`:''}${mr}</div>`; }
327
+ let pl = '';
328
+ if (a.has_preview) { if (t==='site') pl=`<a href="/site/${a.app_id}/" target="_blank">🌐 Visit</a>`; else if (t==='chatgpt') pl=`<a href="/dapp/${a.app_id}/" target="_blank">πŸ’¬ Open dApp</a>`; else pl=`<a href="/app/${a.app_id}" target="_blank">β–Ά Preview</a>`; }
329
+ const dl=`<a href="/api/download/${a.app_id}" download>⬇ Download</a>`;
330
+ const db=(t==='dmg'||t==='gguf'||t==='site')?`<button onclick="showDetails('${a.app_id}')">πŸ“‹ Details</button>`:'';
331
+ const lb=(t==='gguf')?`<button onclick="loadModelById('${a.app_id}')">🧠 Load</button>`:'';
332
+ const xb=`<button class="danger" onclick="deleteApp('${a.app_id}')">πŸ—‘ Delete</button>`;
333
+ return `<div class="app-card"><div class="card-header"><div class="card-title">${esc(a.filename||a.app_name||'Unknown')}</div><div class="card-type ${tc}">${tl}</div></div><div class="card-meta"><span>πŸ“ ${a.size_human||formatBytes(a.size)}</span><span>πŸ• ${timeAgo(a.created_at)}</span></div><div class="card-status ${sc}"><span class="dot"></span>${esc(st)}</div>${det}${gm}<div class="card-actions">${pl}${dl}${lb}${db}${xb}</div></div>`;
334
+ }
335
+
336
+ async function checkInference() {
337
+ try {
338
+ const r = await fetch('/api/inference/status'); const d = await r.json();
339
+ const dot = document.getElementById('inf-dot'); const txt = document.getElementById('inf-status-text'); const hint = document.getElementById('inf-hint');
340
+ if (d.available) { dot.className='dot on'; txt.textContent=`Inference available β€” ${d.loaded_models} model(s) loaded`; hint.textContent='Select a GGUF model and click Load Model.'; }
341
+ else { dot.className='dot off'; txt.textContent='Inference unavailable β€” llama-cpp-python not installed'; hint.textContent='Add llama-cpp-python to requirements.txt to enable inference.'; }
342
+ updateModelSelect();
343
+ } catch(e) { document.getElementById('inf-dot').className='dot off'; document.getElementById('inf-status-text').textContent='Failed to check'; }
344
+ }
345
+ function updateModelSelect() {
346
+ const sel = document.getElementById('inf-model-select'); if (!sel) return;
347
+ const gguf = apps.filter(a => a.file_type==='gguf');
348
+ sel.innerHTML = '<option value="">Select a GGUF model...</option>' + gguf.map(a => `<option value="${a.app_id}">${esc(a.filename)}</option>`).join('');
349
+ }
350
+ async function loadModelById(appId) {
351
+ try { const r = await fetch(`/api/inference/load/${appId}`, { method: 'POST' }); const d = await r.json();
352
+ if (r.ok) { loadedModelId = appId; document.getElementById('inf-model-select').value = appId; document.getElementById('inf-chat-input').disabled = false; document.getElementById('inf-send-btn').disabled = false; document.getElementById('inf-chat-box').innerHTML = `<div style="color:var(--green);text-align:center;padding:20px">βœ“ ${d.message}</div>`; }
353
+ else { alert(d.error||'Failed to load'); }
354
+ } catch(e) { alert('Error: '+e.message); }
355
+ }
356
+ async function loadModel() { const sel = document.getElementById('inf-model-select'); if (!sel.value) { alert('Select a model'); return; } await loadModelById(sel.value); }
357
+ async function sendChat() {
358
+ const inp = document.getElementById('inf-chat-input'); const msg = inp.value.trim(); if (!msg||!loadedModelId) return;
359
+ inp.value = ''; const box = document.getElementById('inf-chat-box');
360
+ box.innerHTML += `<div class="chat-msg"><div class="role user">You</div><div class="bubble">${esc(msg)}</div></div>`; box.scrollTop = box.scrollHeight;
361
+ const btn = document.getElementById('inf-send-btn'); btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>';
362
+ try { const r = await fetch(`/api/inference/chat/${loadedModelId}`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ messages: [{role:'user',content:msg}], max_tokens: 512 }) });
363
+ const d = await r.json();
364
+ if (r.ok && d.choices && d.choices[0]) { box.innerHTML += `<div class="chat-msg"><div class="role assistant">Assistant</div><div class="bubble">${esc(d.choices[0].message.content)}</div></div>`; }
365
+ else { box.innerHTML += `<div class="chat-msg"><div class="role assistant">Error</div><div class="bubble">${esc(d.error||'Failed')}</div></div>`; }
366
+ box.scrollTop = box.scrollHeight;
367
+ } catch(e) { box.innerHTML += `<div class="chat-msg"><div class="role assistant">Error</div><div class="bubble">${esc(e.message)}</div></div>`; }
368
+ btn.disabled = false; btn.textContent = 'Send';
369
+ }
370
+
371
+ async function createWebapp() {
372
+ const url = document.getElementById('webapp-url').value.trim(); const name = document.getElementById('webapp-name').value.trim()||'WebApp'; const btn = document.getElementById('webapp-btn');
373
+ if (!url) { alert('URL required'); return; }
374
+ btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Creating...';
375
+ try { const r = await fetch('/api/create-webapp', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ url, app_name: name }) });
376
+ const d = await r.json(); if (r.ok) { document.getElementById('webapp-url').value=''; document.getElementById('webapp-name').value=''; loadApps(); } else { alert(d.error||'Failed'); }
377
+ } catch(e) { alert('Error: '+e.message); }
378
+ btn.disabled = false; btn.textContent = 'Create App';
379
+ }
380
+
381
+ function showDetails(appId) { const a = apps.find(x=>x.app_id===appId); if(!a) return; document.getElementById('modal-title').textContent = a.filename||a.app_name||'Details';
382
+ let b=''; if (a.gguf) b=`<pre>${esc(JSON.stringify(a.gguf,null,2))}</pre>`; else if (a.apps&&a.apps.length) b=`<pre>${esc(JSON.stringify(a.apps,null,2))}</pre>`; else if (a.tree) b=`<pre>${esc(JSON.stringify(a.tree,null,2))}</pre>`; else b=`<pre>${esc(JSON.stringify(a,null,2))}</pre>`;
383
+ document.getElementById('modal-body').innerHTML = b; document.getElementById('modal-overlay').classList.add('active');
384
+ }
385
+ function closeModal() { document.getElementById('modal-overlay').classList.remove('active'); }
386
+ async function deleteApp(appId) { if(!confirm('Delete?')) return; try { await fetch(`/api/apps/${appId}`,{method:'DELETE'}); loadApps(); } catch(e) { alert('Failed: '+e.message); } }
387
+
388
+ function esc(s) { if(!s) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
389
+ function formatBytes(n) { if(!n) return '0 B'; const u=['B','KB','MB','GB','TB']; let i=0; while(n>=1024&&i<u.length-1){n/=1024;i++;} return n.toFixed(1)+' '+u[i]; }
390
+ function timeAgo(ts) { if(!ts) return 'β€”'; const d=Date.now()/1000-ts; if(d<60) return 'just now'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; }
391
+ </script>
392
+ </body>
393
+ </html>