feat: Electron desktop app + onboarding wizard

- electron/main.ts: native window, tray icon, gateway detection, IPC handlers
- electron/preload.ts: secure IPC bridge for renderer
- electron/onboarding/index.html: 3-step setup wizard
  - Connect Existing Gateway (auto-detect localhost:18789)
  - Local New Install (npm install -g openclaw in background)
  - Cloud (waitlist for managed hosting)
- electron-builder.config.js: Mac/Win/Linux packaging, GitHub auto-update
- package.json: electron scripts (electron:dev, electron:build:mac/win)
This commit is contained in:
outsourc-e
2026-03-05 22:53:28 -05:00
parent 58007f1cd5
commit f59d9e2611
8 changed files with 872 additions and 8 deletions

215
electron/main.ts Normal file
View File

@@ -0,0 +1,215 @@
/**
* ClawSuite Electron Main Process
* Wraps the Vite-built web app in a native desktop window
*/
import { app, BrowserWindow, shell, Menu, Tray, nativeImage, ipcMain } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { spawn, execSync } from 'child_process'
// Prevent multiple instances
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
}
let mainWindow: BrowserWindow | null = null
let tray: Tray | null = null
let gatewayProcess: ReturnType<typeof spawn> | null = null
// Gateway detection
const DEFAULT_GATEWAY_PORT = 18789
const DEV_PORT = 3000
function getGatewayUrl(): string | null {
try {
// Check if gateway is already running
execSync(`curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${DEFAULT_GATEWAY_PORT}/api/health`, {
timeout: 3000,
})
return `http://127.0.0.1:${DEFAULT_GATEWAY_PORT}`
} catch {
return null
}
}
function isOpenClawInstalled(): boolean {
try {
execSync('which openclaw || where openclaw', { timeout: 5000 })
return true
} catch {
return false
}
}
function getAppUrl(): string {
// In dev, use Vite dev server
if (process.env.NODE_ENV === 'development') {
return `http://localhost:${DEV_PORT}`
}
// In production, serve the built files
return `file://${join(__dirname, '../dist/client/index.html')}`
}
function createWindow() {
const iconPath = join(__dirname, '../assets/icon.png')
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
title: 'ClawSuite',
icon: existsSync(iconPath) ? iconPath : undefined,
titleBarStyle: 'hiddenInset', // macOS native title bar
trafficLightPosition: { x: 16, y: 16 },
backgroundColor: '#0a0a0f',
show: false, // Show after ready-to-show
webPreferences: {
preload: join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
})
// Graceful show
mainWindow.once('ready-to-show', () => {
mainWindow?.show()
mainWindow?.focus()
})
// Check if we need onboarding or go straight to dashboard
const gatewayUrl = getGatewayUrl()
if (gatewayUrl) {
// Gateway found — load the app directly
const appUrl = getAppUrl()
mainWindow.loadURL(appUrl)
} else {
// No gateway — show onboarding wizard
mainWindow.loadFile(join(__dirname, '../electron/onboarding/index.html'))
}
// Open external links in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http')) {
shell.openExternal(url)
}
return { action: 'deny' }
})
// Cleanup
mainWindow.on('closed', () => {
mainWindow = null
})
}
function createTray() {
const iconPath = join(__dirname, '../assets/tray-icon.png')
if (!existsSync(iconPath)) return
tray = new Tray(nativeImage.createFromPath(iconPath))
tray.setToolTip('ClawSuite')
const contextMenu = Menu.buildFromTemplate([
{ label: 'Open ClawSuite', click: () => mainWindow?.show() },
{ type: 'separator' },
{ label: 'Gateway Status', enabled: false },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
])
tray.setContextMenu(contextMenu)
tray.on('click', () => mainWindow?.show())
}
// IPC handlers for onboarding wizard
ipcMain.handle('gateway:check', () => {
return { url: getGatewayUrl(), installed: isOpenClawInstalled() }
})
ipcMain.handle('gateway:install', async () => {
return new Promise((resolve, reject) => {
try {
const install = spawn('npm', ['install', '-g', 'openclaw'], {
shell: true,
stdio: 'pipe',
})
let output = ''
install.stdout?.on('data', (data) => { output += data.toString() })
install.stderr?.on('data', (data) => { output += data.toString() })
install.on('close', (code) => {
if (code === 0) resolve({ success: true, output })
else reject(new Error(`Install failed with code ${code}: ${output}`))
})
} catch (err) {
reject(err)
}
})
})
ipcMain.handle('gateway:start', async () => {
return new Promise((resolve) => {
gatewayProcess = spawn('openclaw', ['gateway', 'start'], {
shell: true,
stdio: 'pipe',
detached: true,
})
// Give it a few seconds to boot
setTimeout(() => {
const url = getGatewayUrl()
resolve({ success: !!url, url })
}, 5000)
})
})
ipcMain.handle('gateway:connect', async (_event, url: string) => {
try {
execSync(`curl -s -o /dev/null -w "%{http_code}" ${url}/api/health`, { timeout: 3000 })
return { success: true, url }
} catch {
return { success: false, error: 'Could not connect to gateway' }
}
})
ipcMain.handle('onboarding:complete', async (_event, config: { mode: string; gatewayUrl: string }) => {
// Store config and load the main app
if (mainWindow) {
const appUrl = getAppUrl()
// Pass gateway URL as query param
const url = new URL(appUrl)
url.searchParams.set('gateway', config.gatewayUrl)
mainWindow.loadURL(url.toString())
}
})
// App lifecycle
app.whenReady().then(() => {
createWindow()
createTray()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('before-quit', () => {
// Don't kill gateway — it should persist
tray?.destroy()
})
// Set app name
app.setName('ClawSuite')

View File

@@ -0,0 +1,533 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ClawSuite — Setup</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a0f;
--surface: #12121a;
--surface-hover: #1a1a25;
--border: #1e1e2e;
--text: #e4e4e7;
--text-muted: #71717a;
--accent: #6366f1;
--accent-hover: #818cf8;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--radius: 12px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
-webkit-app-region: drag;
overflow: hidden;
}
.wizard {
width: 520px;
-webkit-app-region: no-drag;
}
.logo {
text-align: center;
margin-bottom: 40px;
}
.logo svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
}
.logo h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
.logo h1 span { font-weight: 400; opacity: 0.7; }
.logo p {
color: var(--text-muted);
font-size: 14px;
margin-top: 8px;
}
/* Steps */
.step { display: none; animation: fadeIn 0.3s ease; }
.step.active { display: block; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Option cards */
.options {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
}
.option {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: flex-start;
gap: 16px;
}
.option:hover {
background: var(--surface-hover);
border-color: var(--accent);
}
.option.selected {
border-color: var(--accent);
background: rgba(99, 102, 241, 0.08);
}
.option-icon {
font-size: 28px;
flex-shrink: 0;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(99, 102, 241, 0.1);
border-radius: 10px;
}
.option-content h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.option-content p {
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
}
.badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 6px;
margin-left: 8px;
}
.badge-free { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.badge-pro { background: rgba(99, 102, 241, 0.15); color: var(--accent); }
.badge-recommended { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
/* Input */
.input-group {
margin-top: 20px;
}
.input-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 8px;
}
.input-group input {
width: 100%;
padding: 12px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.input-group input:focus {
border-color: var(--accent);
}
.input-group input::placeholder {
color: var(--text-muted);
}
/* Buttons */
.actions {
display: flex;
justify-content: space-between;
margin-top: 32px;
gap: 12px;
}
.btn {
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: white;
flex: 1;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--surface-hover); }
/* Progress bar */
.progress {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 32px;
}
.progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
transition: all 0.3s;
}
.progress-dot.active { background: var(--accent); width: 24px; border-radius: 4px; }
.progress-dot.done { background: var(--success); }
/* Status messages */
.status {
margin-top: 16px;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
display: none;
}
.status.show { display: flex; align-items: center; gap: 8px; }
.status-loading { background: rgba(99, 102, 241, 0.1); color: var(--accent); }
.status-success { background: rgba(34, 197, 94, 0.1); color: var(--success); }
.status-error { background: rgba(239, 68, 68, 0.1); color: var(--error); }
.spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Terminal output */
.terminal {
background: #000;
border-radius: 8px;
padding: 16px;
margin-top: 16px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
color: #22c55e;
max-height: 200px;
overflow-y: auto;
display: none;
line-height: 1.6;
}
.terminal.show { display: block; }
</style>
</head>
<body>
<div class="wizard">
<div class="logo">
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="16" fill="#6366f1"/>
<path d="M20 44V20h8l8 12 8-12h0v24" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="32" cy="32" r="4" fill="white" opacity="0.6"/>
</svg>
<h1>Claw<span>Suite</span></h1>
<p>Your AI command center</p>
</div>
<div class="progress">
<div class="progress-dot active" id="dot-1"></div>
<div class="progress-dot" id="dot-2"></div>
<div class="progress-dot" id="dot-3"></div>
</div>
<!-- Step 1: Choose Setup Mode -->
<div class="step active" id="step-1">
<div class="options">
<div class="option" onclick="selectMode('existing')">
<div class="option-icon">🔗</div>
<div class="option-content">
<h3>Connect Existing Gateway <span class="badge badge-recommended">Recommended</span></h3>
<p>Already have OpenClaw running? Connect to your local or remote gateway instantly.</p>
</div>
</div>
<div class="option" onclick="selectMode('local')">
<div class="option-icon">🖥️</div>
<div class="option-content">
<h3>Local — New Install <span class="badge badge-free">Free</span></h3>
<p>Install OpenClaw on this machine. Full control, self-hosted, runs locally.</p>
</div>
</div>
<div class="option" onclick="selectMode('cloud')">
<div class="option-icon">☁️</div>
<div class="option-content">
<h3>Cloud <span class="badge badge-pro">Pro</span></h3>
<p>Managed hosting. We run the gateway — you just build. Starting at $20/mo.</p>
</div>
</div>
</div>
</div>
<!-- Step 2: Configure -->
<div class="step" id="step-2">
<!-- Content injected by JS based on mode -->
</div>
<!-- Step 3: Ready -->
<div class="step" id="step-3">
<div style="text-align: center; padding: 20px 0;">
<div style="font-size: 48px; margin-bottom: 16px;">🚀</div>
<h2 style="font-size: 22px; margin-bottom: 8px;">You're all set!</h2>
<p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px;">
ClawSuite is connected and ready to go.
</p>
<div id="connection-info" style="background: var(--surface); border-radius: 8px; padding: 16px; text-align: left; font-size: 13px;">
<!-- Filled by JS -->
</div>
</div>
<div class="actions">
<button class="btn btn-primary" onclick="finishSetup()">Open ClawSuite →</button>
</div>
</div>
</div>
<script>
let selectedMode = null
let gatewayUrl = null
function selectMode(mode) {
selectedMode = mode
document.querySelectorAll('.option').forEach(o => o.classList.remove('selected'))
event.currentTarget.classList.add('selected')
setTimeout(() => goToStep(2), 300)
}
function goToStep(step) {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'))
document.getElementById(`step-${step}`).classList.add('active')
document.querySelectorAll('.progress-dot').forEach((d, i) => {
d.classList.remove('active', 'done')
if (i + 1 < step) d.classList.add('done')
if (i + 1 === step) d.classList.add('active')
})
if (step === 2) renderStep2()
}
function renderStep2() {
const container = document.getElementById('step-2')
if (selectedMode === 'existing') {
container.innerHTML = `
<h2 style="font-size: 18px; margin-bottom: 4px;">Connect to Gateway</h2>
<p style="color: var(--text-muted); font-size: 13px;">Enter your OpenClaw gateway URL</p>
<div class="input-group">
<label>Gateway URL</label>
<input type="text" id="gateway-url" placeholder="http://localhost:18789" value="http://localhost:18789" />
</div>
<div class="status" id="connect-status"></div>
<div class="actions">
<button class="btn btn-secondary" onclick="goToStep(1)">← Back</button>
<button class="btn btn-primary" onclick="connectGateway()">Connect</button>
</div>
`
// Auto-detect
detectGateway()
} else if (selectedMode === 'local') {
container.innerHTML = `
<h2 style="font-size: 18px; margin-bottom: 4px;">Install OpenClaw</h2>
<p style="color: var(--text-muted); font-size: 13px;">We'll install OpenClaw and start the gateway</p>
<div class="status" id="install-status"></div>
<div class="terminal" id="install-terminal"></div>
<div class="actions">
<button class="btn btn-secondary" onclick="goToStep(1)">← Back</button>
<button class="btn btn-primary" id="install-btn" onclick="installOpenClaw()">Install & Start</button>
</div>
`
} else if (selectedMode === 'cloud') {
container.innerHTML = `
<h2 style="font-size: 18px; margin-bottom: 4px;">Cloud Setup</h2>
<p style="color: var(--text-muted); font-size: 13px;">Create your account to get started</p>
<div class="input-group">
<label>Email</label>
<input type="email" id="cloud-email" placeholder="you@example.com" />
</div>
<div class="status show status-loading" style="margin-top: 16px;">
<span>☁️</span>
<span>Cloud hosting is coming soon. Join the waitlist!</span>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="goToStep(1)">← Back</button>
<button class="btn btn-primary" onclick="joinWaitlist()">Join Waitlist</button>
</div>
`
}
}
async function detectGateway() {
const status = document.getElementById('connect-status')
status.className = 'status show status-loading'
status.innerHTML = '<div class="spinner"></div> Checking for gateway...'
try {
const result = await window.clawsuite.gateway.check()
if (result.url) {
document.getElementById('gateway-url').value = result.url
status.className = 'status show status-success'
status.innerHTML = '✓ Gateway detected at ' + result.url
} else {
status.className = 'status show status-loading'
status.innerHTML = '⚠️ No gateway detected. Enter URL manually or go back and choose "New Install".'
}
} catch (e) {
status.style.display = 'none'
}
}
async function connectGateway() {
const url = document.getElementById('gateway-url').value.trim()
const status = document.getElementById('connect-status')
status.className = 'status show status-loading'
status.innerHTML = '<div class="spinner"></div> Connecting...'
try {
const result = await window.clawsuite.gateway.connect(url)
if (result.success) {
gatewayUrl = url
status.className = 'status show status-success'
status.innerHTML = '✓ Connected!'
setTimeout(() => {
document.getElementById('connection-info').innerHTML = `
<div><strong>Mode:</strong> Local Gateway</div>
<div style="margin-top: 8px;"><strong>URL:</strong> ${gatewayUrl}</div>
<div style="margin-top: 8px;"><strong>Status:</strong> <span style="color: var(--success);">● Connected</span></div>
`
goToStep(3)
}, 1000)
} else {
status.className = 'status show status-error'
status.innerHTML = '✗ Could not connect. Check the URL and try again.'
}
} catch (e) {
status.className = 'status show status-error'
status.innerHTML = '✗ Connection failed: ' + e.message
}
}
async function installOpenClaw() {
const status = document.getElementById('install-status')
const terminal = document.getElementById('install-terminal')
const btn = document.getElementById('install-btn')
btn.disabled = true
btn.textContent = 'Installing...'
status.className = 'status show status-loading'
status.innerHTML = '<div class="spinner"></div> Installing OpenClaw...'
terminal.classList.add('show')
terminal.textContent = '$ npm install -g openclaw\n'
try {
const result = await window.clawsuite.gateway.install()
terminal.textContent += result.output + '\n✓ Installed!\n\n'
status.innerHTML = '<div class="spinner"></div> Starting gateway...'
terminal.textContent += '$ openclaw gateway start\n'
const startResult = await window.clawsuite.gateway.start()
if (startResult.success) {
gatewayUrl = startResult.url
status.className = 'status show status-success'
status.innerHTML = '✓ Gateway running!'
terminal.textContent += '✓ Gateway started at ' + gatewayUrl + '\n'
setTimeout(() => {
document.getElementById('connection-info').innerHTML = `
<div><strong>Mode:</strong> Local (Fresh Install)</div>
<div style="margin-top: 8px;"><strong>URL:</strong> ${gatewayUrl}</div>
<div style="margin-top: 8px;"><strong>Status:</strong> <span style="color: var(--success);">● Running</span></div>
`
goToStep(3)
}, 1500)
}
} catch (e) {
status.className = 'status show status-error'
status.innerHTML = '✗ Installation failed. Try running manually: npm install -g openclaw'
terminal.textContent += '✗ Error: ' + e.message + '\n'
btn.disabled = false
btn.textContent = 'Retry'
}
}
async function joinWaitlist() {
const email = document.getElementById('cloud-email').value.trim()
if (!email) return
// TODO: Submit to waitlist API
const status = document.querySelector('#step-2 .status')
status.className = 'status show status-success'
status.innerHTML = '✓ You\'re on the list! We\'ll email you when Cloud is ready.'
}
async function finishSetup() {
if (gatewayUrl) {
await window.clawsuite.onboarding.complete({
mode: selectedMode,
gatewayUrl: gatewayUrl,
})
}
}
</script>
</body>
</html>

29
electron/preload.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* ClawSuite Electron Preload Script
* Exposes safe IPC bridge to renderer
*/
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('clawsuite', {
// Gateway management
gateway: {
check: () => ipcRenderer.invoke('gateway:check'),
install: () => ipcRenderer.invoke('gateway:install'),
start: () => ipcRenderer.invoke('gateway:start'),
connect: (url: string) => ipcRenderer.invoke('gateway:connect', url),
},
// Onboarding
onboarding: {
complete: (config: { mode: string; gatewayUrl: string }) =>
ipcRenderer.invoke('onboarding:complete', config),
},
// App info
app: {
version: process.env.npm_package_version || '3.2.0',
platform: process.platform,
isElectron: true,
},
})