- {downloadInfo.statusText}
+
+
+ {downloadInfo.statusText}
+
+ {#if isLoading}
+
+ Loading model into memory for fast
+ inference...
+
+ {:else if isReady || isRunning}
+
+ Ready to chat! Type a message below.
+
+ {/if}
{#if downloadInfo.isFailed && downloadInfo.errorMessage}
[volume-name]
+#
+# Example:
+# ./packaging/dmg/create-dmg.sh output/EXO.app EXO-1.0.0.dmg "EXO"
+#
+# Creates a DMG with:
+# - Custom background image with drag-to-Applications arrow
+# - App icon on left, Applications alias on right
+# - Proper window size and icon positioning
+set -euo pipefail
+
+APP_PATH="${1:?Usage: create-dmg.sh [volume-name]}"
+OUTPUT_DMG="${2:?Usage: create-dmg.sh [volume-name]}"
+VOLUME_NAME="${3:-EXO}"
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+BACKGROUND_SCRIPT="${SCRIPT_DIR}/generate-background.py"
+TEMP_DIR="$(mktemp -d)"
+DMG_STAGING="${TEMP_DIR}/dmg-root"
+TEMP_DMG="${TEMP_DIR}/temp.dmg"
+BACKGROUND_PNG="${TEMP_DIR}/background.png"
+
+cleanup() { rm -rf "$TEMP_DIR"; }
+trap cleanup EXIT
+
+echo "==> Creating DMG installer for ${VOLUME_NAME}"
+
+# ── Step 1: Generate background image ────────────────────────────────────────
+if command -v python3 &>/dev/null; then
+ python3 "$BACKGROUND_SCRIPT" "$BACKGROUND_PNG"
+ echo " Background image generated"
+else
+ echo " Warning: python3 not found, skipping custom background"
+ BACKGROUND_PNG=""
+fi
+
+# ── Step 2: Prepare staging directory ─────────────────────────────────────────
+mkdir -p "$DMG_STAGING"
+cp -R "$APP_PATH" "$DMG_STAGING/"
+ln -s /Applications "$DMG_STAGING/Applications"
+
+# ── Step 3: Create writable DMG ──────────────────────────────────────────────
+# Calculate required size (app size + 20MB headroom)
+APP_SIZE_KB=$(du -sk "$APP_PATH" | cut -f1)
+DMG_SIZE_KB=$((APP_SIZE_KB + 20480))
+
+hdiutil create \
+ -volname "$VOLUME_NAME" \
+ -size "${DMG_SIZE_KB}k" \
+ -fs HFS+ \
+ -layout SPUD \
+ "$TEMP_DMG"
+
+# ── Step 4: Mount and configure ──────────────────────────────────────────────
+MOUNT_DIR=$(hdiutil attach "$TEMP_DMG" -readwrite -noverify | awk '/Apple_HFS/ {print substr($0, index($0, "/"))}')
+echo " Mounted at: $MOUNT_DIR"
+
+# Copy contents
+cp -R "$DMG_STAGING/"* "$MOUNT_DIR/"
+
+# Add background image
+if [[ -n $BACKGROUND_PNG && -f $BACKGROUND_PNG ]]; then
+ mkdir -p "$MOUNT_DIR/.background"
+ cp "$BACKGROUND_PNG" "$MOUNT_DIR/.background/background.png"
+fi
+
+# ── Step 5: Configure window appearance via AppleScript ──────────────────────
+# Window: 660×400, icons at 128px, app on left, Applications on right
+APP_NAME="$(basename "$APP_PATH")"
+
+osascript < DMG created: $OUTPUT_DMG"
+echo " Size: $(du -h "$OUTPUT_DMG" | cut -f1)"
diff --git a/packaging/dmg/generate-background.py b/packaging/dmg/generate-background.py
new file mode 100644
index 000000000..f501f97b3
--- /dev/null
+++ b/packaging/dmg/generate-background.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+"""Generate a DMG background image for the EXO installer.
+
+Creates a 660x400 PNG with:
+- Dark gradient background matching the EXO brand
+- Right-pointing arrow between app and Applications
+- "Drag to install" instruction text
+
+Usage:
+ python3 generate-background.py output.png
+"""
+
+from __future__ import annotations
+
+import struct
+import sys
+import zlib
+from pathlib import Path
+
+
+def _png_chunk(chunk_type: bytes, data: bytes) -> bytes:
+ """Build a single PNG chunk (type + data + CRC)."""
+ raw = chunk_type + data
+ return (
+ struct.pack(">I", len(data))
+ + raw
+ + struct.pack(">I", zlib.crc32(raw) & 0xFFFFFFFF)
+ )
+
+
+def _create_png(
+ width: int, height: int, pixels: list[list[tuple[int, int, int, int]]]
+) -> bytes:
+ """Create a minimal RGBA PNG from pixel data."""
+ signature = b"\x89PNG\r\n\x1a\n"
+
+ # IHDR
+ ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) # 8-bit RGBA
+ ihdr = _png_chunk(b"IHDR", ihdr_data)
+
+ # IDAT — build raw scanlines then deflate
+ raw_lines = bytearray()
+ for row in pixels:
+ raw_lines.append(0) # filter: None
+ for r, g, b, a in row:
+ raw_lines.extend((r, g, b, a))
+ idat = _png_chunk(b"IDAT", zlib.compress(bytes(raw_lines), 9))
+
+ # IEND
+ iend = _png_chunk(b"IEND", b"")
+
+ return signature + ihdr + idat + iend
+
+
+def _lerp(a: int, b: int, t: float) -> int:
+ return max(0, min(255, int(a + (b - a) * t)))
+
+
+def _draw_arrow(
+ pixels: list[list[tuple[int, int, int, int]]],
+ cx: int,
+ cy: int,
+ color: tuple[int, int, int, int],
+) -> None:
+ """Draw a simple right-pointing arrow at (cx, cy)."""
+ # Shaft: horizontal line
+ shaft_len = 60
+ shaft_thickness = 3
+ for dx in range(-shaft_len, shaft_len + 1):
+ for dy in range(-shaft_thickness, shaft_thickness + 1):
+ y = cy + dy
+ x = cx + dx
+ if 0 <= y < len(pixels) and 0 <= x < len(pixels[0]):
+ pixels[y][x] = color
+
+ # Arrowhead: triangle pointing right
+ head_size = 20
+ for i in range(head_size):
+ spread = int(i * 1.2)
+ x = cx + shaft_len + i
+ for dy in range(-spread, spread + 1):
+ y = cy + dy
+ if 0 <= y < len(pixels) and 0 <= x < len(pixels[0]):
+ pixels[y][x] = color
+
+
+def _draw_text_pixel(
+ pixels: list[list[tuple[int, int, int, int]]],
+ x: int,
+ y: int,
+ text: str,
+ color: tuple[int, int, int, int],
+ scale: int = 1,
+) -> None:
+ """Draw simple pixel text. Limited to the phrase 'Drag to install'."""
+ # 5x7 pixel font for uppercase + lowercase letters we need
+ glyphs: dict[str, list[str]] = {
+ "D": ["1110 ", "1 01", "1 01", "1 01", "1 01", "1 01", "1110 "],
+ "r": [" ", " ", " 110 ", "1 ", "1 ", "1 ", "1 "],
+ "a": [" ", " ", " 111 ", " 01", " 1111", "1 01", " 1111"],
+ "g": [" ", " ", " 1111", "1 01", " 1111", " 01", " 110 "],
+ "t": [" ", " 1 ", "1111 ", " 1 ", " 1 ", " 1 ", " 11 "],
+ "o": [" ", " ", " 110 ", "1 01", "1 01", "1 01", " 110 "],
+ "i": [" ", " 1 ", " ", " 1 ", " 1 ", " 1 ", " 1 "],
+ "n": [" ", " ", "1 10 ", "11 01", "1 01", "1 01", "1 01"],
+ "s": [" ", " ", " 111 ", "1 ", " 11 ", " 01", "111 "],
+ "l": [" ", " 1 ", " 1 ", " 1 ", " 1 ", " 1 ", " 1 "],
+ " ": [" ", " ", " ", " ", " ", " ", " "],
+ }
+
+ cursor_x = x
+ for ch in text:
+ glyph = glyphs.get(ch)
+ if glyph is None:
+ cursor_x += 6 * scale
+ continue
+ for row_idx, row_str in enumerate(glyph):
+ for col_idx, pixel_ch in enumerate(row_str):
+ if pixel_ch == "1":
+ for sy in range(scale):
+ for sx in range(scale):
+ py = y + row_idx * scale + sy
+ px = cursor_x + col_idx * scale + sx
+ if 0 <= py < len(pixels) and 0 <= px < len(pixels[0]):
+ pixels[py][px] = color
+ cursor_x += (len(glyph[0]) + 1) * scale
+
+
+def generate_background(output_path: str) -> None:
+ """Generate the DMG background image."""
+ width, height = 660, 400
+
+ # Build gradient background: dark gray to slightly darker
+ top_color = (30, 30, 30) # #1e1e1e — matches exo-dark-gray
+ bottom_color = (18, 18, 18) # #121212 — matches exo-black
+
+ pixels: list[list[tuple[int, int, int, int]]] = []
+ for y in range(height):
+ t = y / (height - 1)
+ r = _lerp(top_color[0], bottom_color[0], t)
+ g = _lerp(top_color[1], bottom_color[1], t)
+ b = _lerp(top_color[2], bottom_color[2], t)
+ pixels.append([(r, g, b, 255)] * width)
+
+ # Draw subtle grid lines (matches the exo dashboard grid)
+ grid_color = (40, 40, 40, 255)
+ for y in range(0, height, 40):
+ for x in range(width):
+ pixels[y][x] = grid_color
+ for x in range(0, width, 40):
+ for y in range(height):
+ pixels[y][x] = grid_color
+
+ # Draw the arrow in the center (between app icon at x=155 and Applications at x=505)
+ arrow_color = (200, 180, 50, 255) # EXO yellow
+ _draw_arrow(pixels, width // 2, 200, arrow_color)
+
+ # Draw instruction text below the arrow
+ text_color = (150, 150, 150, 200)
+ _draw_text_pixel(pixels, 268, 310, "Drag to install", text_color, scale=2)
+
+ # Write PNG
+ png_data = _create_png(width, height, pixels)
+ Path(output_path).write_bytes(png_data)
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print(f"Usage: {sys.argv[0]} ", file=sys.stderr)
+ sys.exit(1)
+ generate_background(sys.argv[1])
+ print(f"Background image written to {sys.argv[1]}")
diff --git a/src/exo/utils/banner.py b/src/exo/utils/banner.py
index ffdb54582..9fca88e4f 100644
--- a/src/exo/utils/banner.py
+++ b/src/exo/utils/banner.py
@@ -1,8 +1,26 @@
+import logging
import sys
+import webbrowser
+
+from exo.shared.constants import EXO_CONFIG_HOME
+
+logger = logging.getLogger(__name__)
+
+_FIRST_RUN_MARKER = EXO_CONFIG_HOME / ".dashboard_opened"
+
+
+def _is_first_run() -> bool:
+ return not _FIRST_RUN_MARKER.exists()
+
+
+def _mark_first_run_done() -> None:
+ _FIRST_RUN_MARKER.parent.mkdir(parents=True, exist_ok=True)
+ _FIRST_RUN_MARKER.touch()
def print_startup_banner(port: int) -> None:
dashboard_url = f"http://localhost:{port}"
+ first_run = _is_first_run()
banner = f"""
╔═══════════════════════════════════════════════════════════════════════╗
║ ║
@@ -30,3 +48,11 @@ def print_startup_banner(port: int) -> None:
"""
print(banner, file=sys.stderr)
+
+ if first_run:
+ try:
+ webbrowser.open(dashboard_url)
+ logger.info("First run detected — opening dashboard in browser")
+ except Exception:
+ logger.debug("Could not auto-open browser", exc_info=True)
+ _mark_first_run_done()