Compare commits

..

2 Commits

Author SHA1 Message Date
Alex Cheema
b72e0ebe09 fix: parse hdiutil mount point correctly in create-dmg.sh
The awk command was matching the first '/' in the line (from /dev/diskXsY)
instead of the mount path (/Volumes/...). Use tab field separator and
extract the last field to get the correct mount directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:15:20 -08:00
Alex Cheema
a215b7d57f feat: better onboarding UX for new users
- Auto-open dashboard in browser on first launch
- Welcome overlay with "Choose a Model" button when no model is running
- Tutorial progress messages during download/loading/ready stages
- Fix conversation sidebar text contrast (white text on dark background)
- Hide sharding/instance type/min nodes behind "Advanced Options" toggle
- Polished DMG installer with drag-to-Applications layout and custom background
- Simplify technical jargon: rename labels, hide Strategy behind debug mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:48:26 -08:00
6 changed files with 655 additions and 219 deletions

View File

@@ -303,11 +303,8 @@ jobs:
SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$BUILD_KEYCHAIN_PATH" | awk -F '"' '{print $2}')
/usr/bin/codesign --deep --force --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" EXO.app
mkdir -p dmg-root
cp -R EXO.app dmg-root/
ln -s /Applications dmg-root/Applications
DMG_NAME="EXO-${RELEASE_VERSION}.dmg"
hdiutil create -volname "EXO" -srcfolder dmg-root -ov -format UDZO "$DMG_NAME"
bash "$GITHUB_WORKSPACE/packaging/dmg/create-dmg.sh" EXO.app "$DMG_NAME" "EXO"
/usr/bin/codesign --force --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$DMG_NAME"
if [[ -n "$APPLE_NOTARIZATION_USERNAME" ]]; then

View File

@@ -307,7 +307,7 @@
<div class="py-2">
<div class="px-4 py-2">
<span
class="text-sm text-white/70 font-mono tracking-wider uppercase"
class="text-xs text-exo-light-gray font-mono tracking-wider uppercase"
>
{searchQuery ? "SEARCH RESULTS" : "CONVERSATIONS"}
</span>
@@ -376,39 +376,37 @@
onkeydown={(e) =>
e.key === "Enter" &&
handleSelectConversation(conversation.id)}
class="group w-full flex items-center justify-between p-2 rounded mb-1 transition-all text-left cursor-pointer
class="group w-full flex items-center justify-between p-2.5 rounded-lg mb-1 transition-all text-left cursor-pointer
{activeId === conversation.id
? 'bg-transparent border border-exo-yellow/30'
: 'hover:border-exo-yellow/20 border border-transparent'}"
? 'bg-exo-yellow/5 border border-exo-yellow/30'
: 'hover:bg-white/[0.03] hover:border-white/10 border border-transparent'}"
>
<div class="flex-1 min-w-0 pr-2">
<div
class="text-sm truncate {activeId === conversation.id
class="text-sm font-medium truncate {activeId ===
conversation.id
? 'text-exo-yellow'
: 'text-white/90'}"
: 'text-white'}"
>
{conversation.name}
</div>
<div class="text-sm text-white/50 mt-0.5">
<div class="text-xs text-white/60 mt-0.5">
{formatDate(conversation.updatedAt)}
</div>
<div class="text-sm text-white/70 truncate">
<div class="text-xs text-exo-light-gray truncate">
{info.modelLabel}
</div>
<div class="text-xs text-white/60 font-mono">
Strategy: <span class="text-white/80"
>{info.strategyLabel}</span
>
</div>
{#if stats}
<div class="text-xs text-white/60 font-mono mt-1">
{#if stats.ttftMs}<span class="text-white/40">TTFT</span>
{stats.ttftMs.toFixed(
0,
)}ms{/if}{#if stats.ttftMs && stats.tps}<span
class="text-white/30 mx-1.5"></span
>{/if}{#if stats.tps}{stats.tps.toFixed(1)}
<span class="text-white/40">tok/s</span>{/if}
<div class="text-xs text-white/70 font-mono mt-1">
{#if stats.ttftMs}<span class="text-white/50">TTFT</span>
<span class="text-exo-yellow/80"
>{stats.ttftMs.toFixed(0)}ms</span
>{/if}{#if stats.ttftMs && stats.tps}<span
class="text-white/30 mx-1.5">·</span
>{/if}{#if stats.tps}<span class="text-exo-yellow/80"
>{stats.tps.toFixed(1)}</span
>
<span class="text-white/50">tok/s</span>{/if}
</div>
{/if}
</div>

View File

@@ -298,6 +298,9 @@
// Model picker modal state
let isModelPickerOpen = $state(false);
// Advanced options toggle (hides technical jargon for new users)
let showAdvancedOptions = $state(false);
// Favorites state (reactive)
const favoritesSet = $derived(getFavoritesSet());
@@ -2166,6 +2169,47 @@
onNodeClick={togglePreviewNodeFilter}
/>
<!-- Welcome overlay - shown when no instances are running -->
{#if instanceCount === 0}
<div
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="text-center pointer-events-auto max-w-md px-6">
<div class="mb-4">
<div
class="text-2xl font-mono text-exo-yellow font-bold tracking-wide mb-2"
>
Welcome to exo
</div>
<p class="text-sm text-white/70 leading-relaxed">
Your devices are connected. Choose a model to start
running AI locally.
</p>
</div>
<button
type="button"
onclick={() => (isModelPickerOpen = true)}
class="inline-flex items-center gap-2 px-6 py-3 bg-exo-yellow text-exo-black font-mono text-sm font-bold tracking-wider uppercase rounded-lg hover:bg-exo-yellow-darker hover:shadow-[0_0_30px_rgba(255,215,0,0.3)] transition-all duration-200 cursor-pointer"
>
<svg
class="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v16m8-8H4"
/>
</svg>
Choose a Model
</button>
</div>
</div>
{/if}
{@render clusterWarnings()}
<!-- TB5 RDMA Available Info -->
@@ -2278,8 +2322,18 @@
<!-- Chat Input - Below topology -->
<div class="px-4 pt-6 pb-8">
<div class="max-w-3xl mx-auto">
{#if instanceCount === 0}
<!-- No model loaded prompt -->
<div class="text-center mb-6">
<p class="text-sm text-white/60 font-mono">
No model loaded yet. Select a model to get started.
</p>
</div>
{/if}
<ChatForm
placeholder="Ask anything"
placeholder={instanceCount === 0
? "Choose a model above to start chatting"
: "Ask anything"}
showHelperText={false}
showModelSelector={true}
modelTasks={modelTasks()}
@@ -2450,11 +2504,11 @@
>
{getInstanceModelId(instance)}
</div>
<div class="text-white/60 text-xs font-mono">
Strategy: <span class="text-white/80"
>{instanceInfo.sharding} ({instanceInfo.instanceType})</span
>
</div>
{#if debugEnabled}
<div class="text-white/60 text-xs font-mono">
{instanceInfo.sharding} · {instanceInfo.instanceType}
</div>
{/if}
{#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
<a
class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
@@ -2697,18 +2751,41 @@
{/each}
</div>
{/if}
<div
class="text-xs text-blue-400 font-mono tracking-wider mt-1"
>
DOWNLOADING
<div class="mt-2 space-y-1">
<div
class="text-xs text-blue-400 font-mono tracking-wider"
>
DOWNLOADING
</div>
<p
class="text-[11px] text-white/50 leading-relaxed"
>
Downloading model files. This runs locally on your
device and needs to finish before you can chat.
</p>
</div>
{:else}
<div
class="text-xs {getStatusColor(
downloadInfo.statusText,
)} font-mono tracking-wider mt-1"
>
{downloadInfo.statusText}
<div class="mt-1 space-y-1">
<div
class="text-xs {getStatusColor(
downloadInfo.statusText,
)} font-mono tracking-wider"
>
{downloadInfo.statusText}
</div>
{#if isLoading}
<p
class="text-[11px] text-white/50 leading-relaxed"
>
Loading model into memory for fast inference...
</p>
{:else if isReady || isRunning}
<p
class="text-[11px] text-green-400/70 leading-relaxed"
>
Ready to chat! Type a message below.
</p>
{/if}
</div>
{#if downloadInfo.isFailed && downloadInfo.errorMessage}
<div
@@ -2799,172 +2876,201 @@
</button>
</div>
<!-- Configuration Options -->
<div class="flex-shrink-0 mb-4 space-y-3">
<!-- Sharding -->
<div>
<div class="text-xs text-white/70 font-mono mb-2">
Sharding:
</div>
<div class="flex gap-2">
<button
onclick={() => {
selectedSharding = "Pipeline";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding ===
'Pipeline'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding ===
'Pipeline'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedSharding === "Pipeline"}
<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
{/if}
</span>
Pipeline
</button>
<button
onclick={() => {
selectedSharding = "Tensor";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding ===
'Tensor'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding ===
'Tensor'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedSharding === "Tensor"}
<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
{/if}
</span>
Tensor
</button>
</div>
</div>
<!-- Instance Type -->
<div>
<div class="text-xs text-white/70 font-mono mb-2">
Instance Type:
</div>
<div class="flex gap-2">
<button
onclick={() => {
selectedInstanceType = "MlxRing";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
'MlxRing'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
'MlxRing'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedInstanceType === "MlxRing"}
<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
{/if}
</span>
MLX Ring
</button>
<button
onclick={() => {
selectedInstanceType = "MlxIbv";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
'MlxIbv'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
'MlxIbv'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedInstanceType === "MlxIbv"}
<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
{/if}
</span>
MLX RDMA
</button>
</div>
</div>
<!-- Minimum Nodes (discrete slider with drag support) -->
<div>
<div class="text-xs text-white/70 font-mono mb-2">
Minimum Nodes:
</div>
<!-- Discrete slider track with drag support -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={sliderTrackElement}
class="relative h-16 cursor-pointer select-none px-2 pr-6"
onmousedown={handleSliderMouseDown}
ontouchstart={handleSliderTouchStart}
<!-- Advanced Options Toggle -->
<div class="flex-shrink-0 mb-4">
<button
type="button"
onclick={() => (showAdvancedOptions = !showAdvancedOptions)}
class="flex items-center gap-2 text-xs text-white/50 hover:text-white/70 font-mono tracking-wider uppercase transition-colors cursor-pointer py-1"
>
<svg
class="w-3 h-3 transition-transform duration-200 {showAdvancedOptions
? 'rotate-90'
: ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<!-- Track background - extends full width to align with edge dots -->
<div
class="absolute top-6 left-0 right-0 h-2 bg-exo-medium-gray/50 rounded-full"
></div>
<!-- Active track (fills up to selected) -->
{#if availableMinNodes > 1}
<div
class="absolute top-6 left-0 h-2 bg-white/30 rounded-full transition-all pointer-events-none"
style="width: {((selectedMinNodes - 1) /
(availableMinNodes - 1)) *
100}%"
></div>
{/if}
<!-- Dots and labels for each node count -->
{#each Array.from({ length: availableMinNodes }, (_, i) => i + 1) as n}
{@const isValid = validMinNodeCounts().has(n)}
{@const isSelected = selectedMinNodes === n}
{@const position =
availableMinNodes > 1
? ((n - 1) / (availableMinNodes - 1)) * 100
: 50}
<div
class="absolute flex flex-col items-center pointer-events-none"
style="left: {position}%; top: 0; transform: translateX(-50%);"
>
<!-- Dot -->
<span
class="rounded-full transition-all {isSelected
? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_rgba(255,215,0,0.6)]'
: isValid
? 'w-4 h-4 bg-exo-light-gray/70 mt-1'
: 'w-3 h-3 bg-exo-medium-gray/50 mt-1.5'}"
></span>
<!-- Number label below dot -->
<span
class="text-sm font-mono mt-1.5 tabular-nums transition-colors {isSelected
? 'text-exo-yellow font-bold'
: isValid
? 'text-white/70'
: 'text-white/30'}">{n}</span
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
Advanced Options
</button>
{#if showAdvancedOptions}
<div class="mt-3 space-y-3 pl-1" in:fade={{ duration: 150 }}>
<!-- Sharding Strategy -->
<div>
<div class="text-xs text-white/50 font-mono mb-2">
Splitting Strategy:
</div>
{/each}
<div class="flex gap-2">
<button
onclick={() => {
selectedSharding = "Pipeline";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding ===
'Pipeline'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedSharding ===
'Pipeline'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedSharding === "Pipeline"}
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
></span>
{/if}
</span>
Pipeline
</button>
<button
onclick={() => {
selectedSharding = "Tensor";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding ===
'Tensor'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedSharding ===
'Tensor'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedSharding === "Tensor"}
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
></span>
{/if}
</span>
Tensor
</button>
</div>
</div>
<!-- Runtime -->
<div>
<div class="text-xs text-white/50 font-mono mb-2">
Runtime:
</div>
<div class="flex gap-2">
<button
onclick={() => {
selectedInstanceType = "MlxRing";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
'MlxRing'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
'MlxRing'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedInstanceType === "MlxRing"}
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
></span>
{/if}
</span>
Standard
</button>
<button
onclick={() => {
selectedInstanceType = "MlxIbv";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
'MlxIbv'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
'MlxIbv'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedInstanceType === "MlxIbv"}
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
></span>
{/if}
</span>
RDMA (Fast)
</button>
</div>
</div>
<!-- Minimum Devices -->
<div>
<div class="text-xs text-white/50 font-mono mb-2">
Minimum Devices:
</div>
<!-- Discrete slider track with drag support -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={sliderTrackElement}
class="relative h-16 cursor-pointer select-none px-2 pr-6"
onmousedown={handleSliderMouseDown}
ontouchstart={handleSliderTouchStart}
>
<!-- Track background -->
<div
class="absolute top-6 left-0 right-0 h-2 bg-exo-medium-gray/50 rounded-full"
></div>
<!-- Active track (fills up to selected) -->
{#if availableMinNodes > 1}
<div
class="absolute top-6 left-0 h-2 bg-white/30 rounded-full transition-all pointer-events-none"
style="width: {((selectedMinNodes - 1) /
(availableMinNodes - 1)) *
100}%"
></div>
{/if}
<!-- Dots and labels for each device count -->
{#each Array.from({ length: availableMinNodes }, (_, i) => i + 1) as n}
{@const isValid = validMinNodeCounts().has(n)}
{@const isSelected = selectedMinNodes === n}
{@const position =
availableMinNodes > 1
? ((n - 1) / (availableMinNodes - 1)) * 100
: 50}
<div
class="absolute flex flex-col items-center pointer-events-none"
style="left: {position}%; top: 0; transform: translateX(-50%);"
>
<span
class="rounded-full transition-all {isSelected
? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_rgba(255,215,0,0.6)]'
: isValid
? 'w-4 h-4 bg-exo-light-gray/70 mt-1'
: 'w-3 h-3 bg-exo-medium-gray/50 mt-1.5'}"
></span>
<span
class="text-sm font-mono mt-1.5 tabular-nums transition-colors {isSelected
? 'text-exo-yellow font-bold'
: isValid
? 'text-white/70'
: 'text-white/30'}">{n}</span
>
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Selected Model Preview -->
@@ -3263,11 +3369,11 @@
>
{getInstanceModelId(instance)}
</div>
<div class="text-white/60 text-xs font-mono">
Strategy: <span class="text-white/80"
>{instanceInfo.sharding} ({instanceInfo.instanceType})</span
>
</div>
{#if debugEnabled}
<div class="text-white/60 text-xs font-mono">
{instanceInfo.sharding} · {instanceInfo.instanceType}
</div>
{/if}
{#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
<a
class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
@@ -3520,18 +3626,43 @@
{/each}
</div>
{/if}
<div
class="text-xs text-blue-400 font-mono tracking-wider mt-1"
>
DOWNLOADING
<div class="mt-2 space-y-1">
<div
class="text-xs text-blue-400 font-mono tracking-wider"
>
DOWNLOADING
</div>
<p
class="text-[11px] text-white/50 leading-relaxed"
>
Downloading model files. This runs locally on
your device and needs to finish before you can
chat.
</p>
</div>
{:else}
<div
class="text-xs {getStatusColor(
downloadInfo.statusText,
)} font-mono tracking-wider mt-1"
>
{downloadInfo.statusText}
<div class="mt-1 space-y-1">
<div
class="text-xs {getStatusColor(
downloadInfo.statusText,
)} font-mono tracking-wider"
>
{downloadInfo.statusText}
</div>
{#if isLoading}
<p
class="text-[11px] text-white/50 leading-relaxed"
>
Loading model into memory for fast
inference...
</p>
{:else if isReady || isRunning}
<p
class="text-[11px] text-green-400/70 leading-relaxed"
>
Ready to chat! Type a message below.
</p>
{/if}
</div>
{#if downloadInfo.isFailed && downloadInfo.errorMessage}
<div

110
packaging/dmg/create-dmg.sh Executable file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# create-dmg.sh — Build a polished macOS DMG installer for EXO
#
# Usage:
# ./packaging/dmg/create-dmg.sh <app-path> <output-dmg> [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 <app-path> <output-dmg> [volume-name]}"
OUTPUT_DMG="${2:?Usage: create-dmg.sh <app-path> <output-dmg> [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 -F'\t' '/Apple_HFS/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $NF); print $NF}')
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 <<APPLESCRIPT
tell application "Finder"
tell disk "$VOLUME_NAME"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set bounds of container window to {200, 200, 860, 600}
set opts to icon view options of container window
set icon size of opts to 128
set arrangement of opts to not arranged
if exists file ".background:background.png" then
set background picture of opts to file ".background:background.png"
end if
set position of item "$APP_NAME" of container window to {155, 200}
set position of item "Applications" of container window to {505, 200}
close
open
update without registering applications
delay 1
close
end tell
end tell
APPLESCRIPT
echo " Window layout configured"
# Ensure Finder updates are flushed
sync
# ── Step 6: Finalise ─────────────────────────────────────────────────────────
hdiutil detach "$MOUNT_DIR" -quiet
hdiutil convert "$TEMP_DMG" -format UDZO -imagekey zlib-level=9 -o "$OUTPUT_DMG"
echo "==> DMG created: $OUTPUT_DMG"
echo " Size: $(du -h "$OUTPUT_DMG" | cut -f1)"

View File

@@ -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]} <output.png>", file=sys.stderr)
sys.exit(1)
generate_background(sys.argv[1])
print(f"Background image written to {sys.argv[1]}")

View File

@@ -1,6 +1,26 @@
import logging
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:
"""Print a prominent startup banner with API endpoint information."""
dashboard_url = f"http://localhost:{port}"
first_run = _is_first_run()
banner = f"""
╔═══════════════════════════════════════════════════════════════════════╗
║ ║
@@ -28,3 +48,11 @@ def print_startup_banner(port: int) -> None:
"""
print(banner)
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()