Files
dashcaddy/dashcaddy-installer/src/renderer/wizard.js
T
Sami f61e85d9a7 Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.
2026-03-05 02:26:12 -08:00

1415 lines
44 KiB
JavaScript

/**
* DashCaddy Installer Wizard
* A step-by-step installation wizard for DashCaddy
*/
// Wizard State
const state = {
currentStep: 0,
platform: null,
paths: {
install: '',
dockerData: '',
caddyConfig: ''
},
dependencies: {
docker: { installed: false, version: null, checking: true },
caddy: { installed: false, version: null, checking: true }
},
// Tier selection
tier: 'intermediate',
// DNS configuration
dns: {
enabled: false,
serverUrl: '',
username: '',
password: '',
token: '',
testResult: null
},
// Dashboard branding
branding: {
name: 'DashCaddy',
title: 'DashCaddy Dashboard',
primaryColor: '#6366f1',
logoSourcePath: '',
dashboardPort: 8080,
apiPort: 3001
},
// Access mode
domainMode: 'local', // 'local' | 'public' | 'custom-tld'
domain: {
publicDomain: '',
tld: '.home',
email: '',
caName: 'DashCaddy Local CA'
},
// Detected network IPs
network: {
lanIP: '',
tailscaleIP: '',
detected: false
},
installation: {
status: 'pending', // pending, running, complete, error
progress: 0,
currentTask: '',
completedTasks: [],
error: null
},
result: {
dashboardUrl: '',
installPath: '',
health: null
},
// Uninstall mode
uninstallMode: false,
uninstall: {
step: 'confirm', // 'confirm' | 'progress' | 'complete'
installPath: '',
config: null,
detected: false,
detecting: false,
preserveSettings: true,
preserveCA: true,
status: 'pending', // pending, running, complete, error
progress: 0,
currentTask: '',
completedTasks: [],
error: null,
settingsPath: null
}
};
// Step definitions (8 steps)
const steps = [
{ id: 'welcome', title: 'Welcome' },
{ id: 'dependencies', title: 'Dependencies' },
{ id: 'folder', title: 'Install Path' },
{ id: 'tier', title: 'Tier' },
{ id: 'access', title: 'Access' },
{ id: 'dns', title: 'DNS' },
{ id: 'dashboard', title: 'Dashboard' },
{ id: 'install', title: 'Install' },
{ id: 'complete', title: 'Complete' }
];
// DOM Elements
let root;
// Initialize wizard
async function initWizard() {
root = document.getElementById('root');
// Check platform
try {
const result = await window.electronAPI.checkPlatform();
if (result.success) {
state.platform = result.data;
setDefaultPaths();
}
} catch (err) {
console.error('Platform detection failed:', err);
}
// Detect network IPs
try {
const netResult = await window.electronAPI.detectNetwork();
if (netResult.success) {
state.network.lanIP = netResult.data.lanIP || '';
state.network.tailscaleIP = netResult.data.tailscaleIP || '';
state.network.detected = true;
}
} catch (err) {
console.warn('Network detection failed:', err);
}
// Setup event listeners for installation progress
setupEventListeners();
// Render initial state
render();
}
// Set default paths based on platform
function setDefaultPaths() {
const os = state.platform?.os || 'windows';
if (os === 'windows') {
state.paths.install = 'C:\\DashCaddy';
state.paths.dockerData = 'C:\\DashCaddy\\docker-data';
state.paths.caddyConfig = 'C:\\DashCaddy\\caddy';
} else if (os === 'macos') {
state.paths.install = '/Applications/DashCaddy';
state.paths.dockerData = '/Applications/DashCaddy/docker-data';
state.paths.caddyConfig = '/Applications/DashCaddy/caddy';
} else {
state.paths.install = '/opt/dashcaddy';
state.paths.dockerData = '/opt/dashcaddy/docker-data';
state.paths.caddyConfig = '/opt/dashcaddy/caddy';
}
}
// Setup IPC event listeners
function setupEventListeners() {
window.electronAPI.onStepProgress((data) => {
state.installation.progress = data.progress;
state.installation.currentTask = data.task;
render();
});
window.electronAPI.onStepComplete((data) => {
state.installation.completedTasks.push(data.step);
render();
});
window.electronAPI.onStepError((data) => {
state.installation.status = 'error';
state.installation.error = data.error;
render();
});
window.electronAPI.onInstallationComplete((data) => {
state.installation.status = 'complete';
state.result.dashboardUrl = data.dashboardUrl;
state.result.installPath = data.installPath;
state.result.health = data.health || null;
state.currentStep = 8; // Move to complete step
render();
});
// Uninstall progress listeners
window.electronAPI.onUninstallProgress((data) => {
state.uninstall.progress = data.progress;
state.uninstall.currentTask = data.task;
render();
});
window.electronAPI.onUninstallStepComplete((data) => {
state.uninstall.completedTasks.push(data.step);
render();
});
window.electronAPI.onUninstallComplete((data) => {
state.uninstall.status = 'complete';
state.uninstall.step = 'complete';
state.uninstall.settingsPath = data.settingsPath || null;
render();
});
window.electronAPI.onUninstallError((data) => {
state.uninstall.status = 'error';
state.uninstall.error = data.error;
render();
});
}
// Navigation
function nextStep() {
if (state.currentStep < steps.length - 1) {
state.currentStep++;
// Trigger actions on step enter
switch (state.currentStep) {
case 1: // Dependencies
checkDependencies();
break;
case 7: // Installation
startInstallation();
break;
}
render();
}
}
function prevStep() {
if (state.currentStep > 0) {
state.currentStep--;
render();
}
}
// Check dependencies
async function checkDependencies() {
state.dependencies.docker.checking = true;
state.dependencies.caddy.checking = true;
render();
try {
// Pass user-configured caddy path so the checker can find caddy.exe there
const caddySearchPaths = [];
if (state.paths.caddyConfig) {
caddySearchPaths.push(state.paths.caddyConfig);
}
if (state.paths.install) {
const sep = state.platform?.os === 'windows' ? '\\' : '/';
caddySearchPaths.push(state.paths.install + sep + 'caddy');
caddySearchPaths.push(state.paths.install);
}
const result = await window.electronAPI.checkDependencies({ caddySearchPaths });
if (result.success) {
state.dependencies.docker = {
installed: result.data.docker.installed,
version: result.data.docker.version,
checking: false
};
state.dependencies.caddy = {
installed: result.data.caddy.installed,
version: result.data.caddy.version,
checking: false
};
}
} catch (err) {
console.error('Dependency check failed:', err);
state.dependencies.docker.checking = false;
state.dependencies.caddy.checking = false;
}
render();
}
// Install dependency
async function installDependency(type) {
state.dependencies[type].checking = true;
render();
try {
if (type === 'docker') {
await window.electronAPI.installDocker();
} else {
await window.electronAPI.installCaddy({
targetPath: state.paths.caddyConfig || null
});
}
await checkDependencies();
} catch (err) {
console.error(`${type} installation failed:`, err);
state.dependencies[type].checking = false;
render();
}
}
// Select folder
async function selectFolder(pathKey) {
try {
const result = await window.electronAPI.selectFolder();
if (result.success && result.path) {
state.paths[pathKey] = result.path;
// Auto-set child paths if main install path changes
if (pathKey === 'install') {
const sep = state.platform?.os === 'windows' ? '\\' : '/';
state.paths.dockerData = result.path + sep + 'docker-data';
state.paths.caddyConfig = result.path + sep + 'caddy';
}
render();
}
} catch (err) {
console.error('Folder selection failed:', err);
}
}
// Tier selection
function selectTier(tierId) {
state.tier = tierId;
// Reset DNS if downgrading from advanced
if (tierId !== 'advanced') {
state.dns.enabled = false;
}
render();
}
// Access mode functions
function selectDomainMode(mode) {
state.domainMode = mode;
render();
}
function updateDomain(field, value) {
state.domain[field] = value;
if (field === 'tld' && value && !value.startsWith('.')) {
state.domain.tld = '.' + value;
}
}
// DNS functions
function toggleDNS(enabled) {
state.dns.enabled = enabled;
render();
}
function updateDNS(field, value) {
state.dns[field] = value;
}
async function testDNSConnection() {
state.dns.testResult = null;
render();
try {
const result = await window.electronAPI.testDNSConnection({
server: state.dns.serverUrl,
username: state.dns.username,
password: state.dns.password,
token: state.dns.token
});
state.dns.testResult = {
success: result.success && result.data?.connected,
message: result.data?.message || (result.success ? 'Connected' : result.error)
};
} catch (err) {
state.dns.testResult = { success: false, message: err.message };
}
render();
}
// Branding functions
function updateBranding(field, value) {
state.branding[field] = value;
// Re-render for color to sync both inputs
if (field === 'primaryColor') render();
}
async function selectLogo() {
try {
const result = await window.electronAPI.selectFile({
title: 'Select Dashboard Logo',
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'svg'] }]
});
if (result.success && result.path) {
state.branding.logoSourcePath = result.path;
render();
}
} catch (err) {
console.error('Logo selection failed:', err);
}
}
// Start installation
async function startInstallation() {
state.installation.status = 'running';
state.installation.progress = 0;
state.installation.completedTasks = [];
state.installation.error = null;
render();
try {
await window.electronAPI.runInstallation({
installPath: state.paths.install,
dockerDataPath: state.paths.dockerData,
caddyConfigPath: state.paths.caddyConfig,
tier: state.tier,
dashboardPort: state.branding.dashboardPort,
apiPort: state.branding.apiPort,
domainMode: state.domainMode,
domain: state.domain,
lanIP: state.network.lanIP,
tailscaleIP: state.network.tailscaleIP,
branding: {
name: state.branding.name,
title: state.branding.title,
primaryColor: state.branding.primaryColor,
logoSourcePath: state.branding.logoSourcePath || null
},
dns: state.dns.enabled ? {
server: state.dns.serverUrl,
username: state.dns.username,
password: state.dns.password,
token: state.dns.token
} : null,
autoStart: true
});
} catch (err) {
state.installation.status = 'error';
state.installation.error = err.message;
render();
}
}
// Open dashboard
async function openDashboard() {
try {
await window.electronAPI.openDashboard(state.branding.dashboardPort || 8080);
} catch (err) {
console.error('Failed to open dashboard:', err);
}
}
// Render functions
function render() {
root.innerHTML = renderContainer();
}
function renderContainer() {
if (state.uninstallMode) {
return `
<div class="installer-container">
<div class="step-content">
${renderUninstallView()}
</div>
</div>
`;
}
return `
<div class="installer-container">
${renderStepIndicator()}
<div class="step-content">
${renderCurrentStep()}
</div>
${renderFooter()}
</div>
`;
}
function renderStepIndicator() {
return `
<div class="step-indicator">
${steps.map((step, index) => `
<div class="step-item ${index === state.currentStep ? 'active' : ''} ${index < state.currentStep ? 'completed' : ''}">
<div class="step-circle">${index < state.currentStep ? '\u2713' : index + 1}</div>
<span class="step-label">${step.title}</span>
</div>
${index < steps.length - 1 ? '<div class="step-connector"></div>' : ''}
`).join('')}
</div>
`;
}
function renderCurrentStep() {
switch (state.currentStep) {
case 0: return renderWelcome();
case 1: return renderDependencies();
case 2: return renderFolderSelection();
case 3: return renderTierSelection();
case 4: return renderAccessMode();
case 5: return renderDNSConfiguration();
case 6: return renderDashboardSetup();
case 7: return renderInstallation();
case 8: return renderComplete();
default: return '';
}
}
function renderWelcome() {
const platformName = {
'windows': 'Windows',
'macos': 'macOS',
'linux': 'Linux'
}[state.platform?.os] || 'Unknown';
return `
<div class="welcome-content">
<div class="logo-container">
<img src="../../assets/dashcaddy logo light.png" alt="DashCaddy Logo" />
</div>
<h1>Welcome to DashCaddy</h1>
<p>The unified management platform for your home lab. This wizard will guide you through the installation process.</p>
<ul class="feature-list">
<li>Manage all your Docker containers from one dashboard</li>
<li>Automatic HTTPS with Caddy reverse proxy</li>
<li>DNS integration for local domains</li>
<li>50+ pre-configured app templates</li>
<li>Real-time service health monitoring</li>
</ul>
<div class="platform-badge">
<span>Detected Platform:</span>
<strong>${platformName} ${state.platform?.arch || ''}</strong>
</div>
<div class="uninstall-link">
<a href="#" onclick="enterUninstallMode(); return false;">Uninstall an existing installation</a>
</div>
</div>
`;
}
function renderDependencies() {
const docker = state.dependencies.docker;
const caddy = state.dependencies.caddy;
return `
<div>
<h2>System Requirements</h2>
<p>DashCaddy requires Docker and Caddy to be installed. We'll check and install them for you.</p>
<div class="dependency-cards">
<div class="dependency-card ${docker.checking ? 'checking' : docker.installed ? 'installed' : 'missing'}">
<div class="dependency-icon">D</div>
<div class="dependency-info">
<h3>Docker</h3>
<p>${docker.checking ? 'Checking...' : docker.installed ? `Version ${docker.version}` : 'Not installed'}</p>
</div>
<div class="dependency-status">
${docker.checking ?
'<div class="spinner spinner-small"></div>' :
docker.installed ?
'<span class="status-icon installed"></span>' :
`<button class="btn-install" onclick="installDependency('docker')">Install</button>`
}
</div>
</div>
<div class="dependency-card ${caddy.checking ? 'checking' : caddy.installed ? 'installed' : 'missing'}">
<div class="dependency-icon">C</div>
<div class="dependency-info">
<h3>Caddy</h3>
<p>${caddy.checking ? 'Checking...' : caddy.installed ? `Version ${caddy.version}` : 'Not installed'}</p>
</div>
<div class="dependency-status">
${caddy.checking ?
'<div class="spinner spinner-small"></div>' :
caddy.installed ?
'<span class="status-icon installed"></span>' :
`<button class="btn-install" onclick="installDependency('caddy')">Install</button>`
}
</div>
</div>
</div>
${!docker.checking && !caddy.checking && (!docker.installed || !caddy.installed) ? `
<div class="error-message">
Please install the missing dependencies before continuing.
</div>
` : ''}
</div>
`;
}
function renderFolderSelection() {
return `
<div>
<h2>Choose Installation Folders</h2>
<p>Select where DashCaddy and its components should be installed.</p>
<div class="folder-inputs">
<div class="folder-input">
<label>DashCaddy Installation Path</label>
<div class="input-row">
<input type="text" value="${escapeHtml(state.paths.install)}" readonly placeholder="Select installation folder...">
<button class="btn-browse" onclick="selectFolder('install')">Browse...</button>
</div>
<p class="hint">Main folder where DashCaddy will be installed</p>
</div>
<div class="folder-input">
<label>Docker Data Path</label>
<div class="input-row">
<input type="text" value="${escapeHtml(state.paths.dockerData)}" readonly placeholder="Select Docker data folder...">
<button class="btn-browse" onclick="selectFolder('dockerData')">Browse...</button>
</div>
<p class="hint">Where Docker containers will store their data (volumes)</p>
</div>
<div class="folder-input">
<label>Caddy Configuration Path</label>
<div class="input-row">
<input type="text" value="${escapeHtml(state.paths.caddyConfig)}" readonly placeholder="Select Caddy config folder...">
<button class="btn-browse" onclick="selectFolder('caddyConfig')">Browse...</button>
</div>
<p class="hint">Where Caddyfile and SSL certificates will be stored</p>
</div>
</div>
</div>
`;
}
function renderTierSelection() {
const tiers = [
{
id: 'basic',
name: 'Basic',
description: 'Caddy serves the DashCaddy dashboard as a static site.',
includes: ['Caddy reverse proxy', 'Dashboard UI', 'Static file serving']
},
{
id: 'intermediate',
name: 'Standard',
description: 'Adds the DashCaddy API server running in Docker for container management.',
includes: ['Everything in Basic', 'Docker API server', 'Container management', '50+ app templates'],
recommended: true
},
{
id: 'advanced',
name: 'Full Stack',
description: 'Complete setup with DNS management via Technitium DNS integration.',
includes: ['Everything in Standard', 'DNS management', 'Automatic DNS records', 'Local domain support']
}
];
return `
<div>
<h2>Choose Deployment Tier</h2>
<p>Select the level of functionality you need. You can upgrade later.</p>
<div class="tier-cards">
${tiers.map(tier => `
<div class="tier-card ${state.tier === tier.id ? 'selected' : ''}"
onclick="selectTier('${tier.id}')">
${tier.recommended ? '<div class="tier-badge">Recommended</div>' : ''}
<div class="tier-header">
<h3>${tier.name}</h3>
</div>
<p class="tier-description">${tier.description}</p>
<ul class="tier-features">
${tier.includes.map(f => `<li>${f}</li>`).join('')}
</ul>
</div>
`).join('')}
</div>
</div>
`;
}
function renderAccessMode() {
const modes = [
{
id: 'local',
name: 'Local Only',
icon: '127',
description: 'Access your dashboard via IP address or localhost. No domain names or HTTPS required.',
note: 'Best for: Testing, single-machine use, simple setups'
},
{
id: 'public',
name: 'Public Domain',
icon: 'WWW',
description: 'Use a real domain name with automatic Let\'s Encrypt HTTPS certificates.',
note: 'Requires: Ports 80 + 443 open, DNS pointing to this server'
},
{
id: 'custom-tld',
name: 'Custom TLD',
icon: 'CA',
description: 'Use a custom top-level domain like .home or .lab with an internal Certificate Authority.',
note: 'Requires: A local DNS server (configured in the next step)',
badge: 'Expert'
}
];
return `
<div>
<h2>How will you access DashCaddy?</h2>
<p>Choose how you want to reach your dashboard and services on the network.</p>
<div class="tier-cards">
${modes.map(mode => `
<div class="tier-card ${state.domainMode === mode.id ? 'selected' : ''}"
onclick="selectDomainMode('${mode.id}')">
${mode.badge ? `<div class="tier-badge">${mode.badge}</div>` : ''}
<div class="access-icon">${mode.icon}</div>
<div class="tier-header">
<h3>${mode.name}</h3>
</div>
<p class="tier-description">${mode.description}</p>
<p class="access-note">${mode.note}</p>
</div>
`).join('')}
</div>
${state.domainMode === 'public' ? `
<div class="folder-inputs" style="margin-top: 24px;">
<div class="folder-input">
<label>Domain Name</label>
<div class="input-row">
<input type="text"
value="${escapeHtml(state.domain.publicDomain)}"
placeholder="dashboard.example.com"
oninput="updateDomain('publicDomain', this.value)">
</div>
<p class="hint">The domain where your dashboard will be accessible</p>
</div>
<div class="folder-input">
<label>Email for Let's Encrypt</label>
<div class="input-row">
<input type="email"
value="${escapeHtml(state.domain.email)}"
placeholder="you@example.com"
oninput="updateDomain('email', this.value)">
</div>
<p class="hint">Used for certificate expiry notifications (required by Let's Encrypt)</p>
</div>
</div>
` : ''}
${state.domainMode === 'custom-tld' ? `
<div class="folder-inputs" style="margin-top: 24px;">
<div class="folder-input">
<label>Top-Level Domain</label>
<div class="input-row">
<input type="text"
value="${escapeHtml(state.domain.tld)}"
placeholder=".home"
oninput="updateDomain('tld', this.value)">
</div>
<p class="hint">Your dashboard will be at <strong>dashcaddy${escapeHtml(state.domain.tld)}</strong>, services at <strong>&lt;name&gt;${escapeHtml(state.domain.tld)}</strong></p>
</div>
<div class="folder-input">
<label>Certificate Authority Name</label>
<div class="input-row">
<input type="text"
value="${escapeHtml(state.domain.caName)}"
placeholder="DashCaddy Local CA"
oninput="updateDomain('caName', this.value)">
</div>
<p class="hint">Name for your internal Certificate Authority</p>
</div>
<div class="info-message">
Caddy will run an internal Certificate Authority to issue HTTPS certificates for your ${escapeHtml(state.domain.tld)} domains. You'll need a local DNS server to resolve these names — configure it in the next step.
</div>
</div>
` : ''}
${state.network.detected && (state.network.lanIP || state.network.tailscaleIP) ? `
<div class="info-message" style="margin-top: 16px;">
<strong>Detected Network:</strong>
${state.network.lanIP ? `LAN: ${escapeHtml(state.network.lanIP)}` : ''}
${state.network.lanIP && state.network.tailscaleIP ? ' | ' : ''}
${state.network.tailscaleIP ? `Tailscale: ${escapeHtml(state.network.tailscaleIP)}` : ''}
</div>
` : ''}
</div>
`;
}
function renderDNSConfiguration() {
// Local mode: DNS not needed
if (state.domainMode === 'local') {
return `
<div>
<h2>DNS Configuration</h2>
<div class="info-message">
DNS is not needed for local/IP-based access. You can skip this step.
</div>
<p>If you add a DNS server later, you can configure it from the DashCaddy dashboard.</p>
</div>
`;
}
// Public domain: DNS managed externally
if (state.domainMode === 'public') {
return `
<div>
<h2>DNS Configuration</h2>
<div class="info-message">
Your domain's DNS is managed by your registrar or DNS provider.
Make sure <strong>${escapeHtml(state.domain.publicDomain)}</strong> points to this server's IP address.
</div>
<p>DashCaddy can optionally manage a local DNS server for additional services. You can configure this later.</p>
</div>
`;
}
// Custom TLD but not Full Stack tier
if (state.domainMode === 'custom-tld' && state.tier !== 'advanced') {
return `
<div>
<h2>DNS Configuration</h2>
<div class="info-message">
Your custom TLD <strong>${escapeHtml(state.domain.tld)}</strong> requires a DNS server to resolve domain names.
DNS management is available with the Full Stack tier — you selected ${state.tier === 'basic' ? 'Basic' : 'Standard'}.
</div>
<p>You'll need to manually configure your DNS server to resolve <strong>*${escapeHtml(state.domain.tld)}</strong> domains to this machine's IP address.</p>
</div>
`;
}
// Custom TLD + Full Stack tier: full DNS form
return `
<div>
<h2>DNS Configuration</h2>
<p>Configure your Technitium DNS server connection for automatic DNS record management.</p>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox"
${state.dns.enabled ? 'checked' : ''}
onchange="toggleDNS(this.checked)">
<span>Enable DNS integration</span>
</label>
</div>
${state.dns.enabled ? `
<div class="folder-inputs">
<div class="folder-input">
<label>DNS Server URL</label>
<div class="input-row">
<input type="text"
value="${escapeHtml(state.dns.serverUrl)}"
placeholder="http://192.168.1.1:5380"
oninput="updateDNS('serverUrl', this.value)">
</div>
<p class="hint">Technitium DNS server address including port</p>
</div>
<div class="folder-input">
<label>Username</label>
<div class="input-row">
<input type="text"
value="${escapeHtml(state.dns.username)}"
placeholder="admin"
oninput="updateDNS('username', this.value)">
</div>
</div>
<div class="folder-input">
<label>Password</label>
<div class="input-row">
<input type="password"
value="${escapeHtml(state.dns.password)}"
placeholder="Enter password"
oninput="updateDNS('password', this.value)">
</div>
<p class="hint">Stored encrypted on disk</p>
</div>
<div class="folder-input">
<label>API Token (optional)</label>
<div class="input-row">
<input type="password"
value="${escapeHtml(state.dns.token)}"
placeholder="Enter API token"
oninput="updateDNS('token', this.value)">
</div>
<p class="hint">Alternative to username/password authentication</p>
</div>
<button class="btn btn-secondary" onclick="testDNSConnection()"
${!state.dns.serverUrl ? 'disabled' : ''}>
Test Connection
</button>
${state.dns.testResult !== null ? `
<div class="${state.dns.testResult.success ? 'success-message' : 'error-message'}">
${escapeHtml(state.dns.testResult.message)}
</div>
` : ''}
</div>
` : ''}
</div>
`;
}
function renderDashboardSetup() {
return `
<div>
<h2>Dashboard Setup</h2>
<p>Customize your DashCaddy dashboard appearance and ports.</p>
<div class="folder-inputs">
<div class="folder-input">
<label>Dashboard Name</label>
<div class="input-row">
<input type="text"
value="${escapeHtml(state.branding.name)}"
placeholder="DashCaddy"
oninput="updateBranding('name', this.value)">
</div>
<p class="hint">Displayed in the dashboard header</p>
</div>
<div class="folder-input">
<label>Primary Color</label>
<div class="input-row">
<input type="color"
value="${state.branding.primaryColor}"
oninput="updateBranding('primaryColor', this.value)"
style="width: 60px; height: 40px; padding: 2px; cursor: pointer;">
<input type="text"
value="${escapeHtml(state.branding.primaryColor)}"
placeholder="#6366f1"
oninput="updateBranding('primaryColor', this.value)"
style="flex: 1;">
</div>
</div>
<div class="folder-input">
<label>Custom Logo (optional)</label>
<div class="input-row">
<input type="text"
value="${escapeHtml(state.branding.logoSourcePath)}"
readonly
placeholder="No custom logo selected">
<button class="btn-browse" onclick="selectLogo()">Browse...</button>
</div>
<p class="hint">PNG or SVG, recommended 200x120px. Leave empty for default logo.</p>
</div>
<div class="folder-input">
<label>Dashboard Port</label>
<div class="input-row">
<input type="number"
value="${state.branding.dashboardPort}"
min="1024" max="65535"
oninput="updateBranding('dashboardPort', parseInt(this.value) || 8080)">
</div>
<p class="hint">Port where the dashboard will be accessible (default: 8080)</p>
</div>
${state.tier !== 'basic' ? `
<div class="folder-input">
<label>API Server Port</label>
<div class="input-row">
<input type="number"
value="${state.branding.apiPort}"
min="1024" max="65535"
oninput="updateBranding('apiPort', parseInt(this.value) || 3001)">
</div>
<p class="hint">Port for the DashCaddy API server (default: 3001)</p>
</div>
` : ''}
</div>
</div>
`;
}
function renderInstallation() {
const { status, progress, currentTask, completedTasks, error } = state.installation;
if (error) {
return `
<div class="installation-progress">
<h2>Installation Failed</h2>
<div class="error-message">${escapeHtml(error)}</div>
<button class="btn btn-primary" onclick="startInstallation()">Retry</button>
</div>
`;
}
return `
<div class="installation-progress">
<h2>Installing DashCaddy</h2>
<p>Please wait while we set up DashCaddy on your system.</p>
<div class="progress-percentage">${progress}%</div>
<div class="progress-container">
<div class="progress-bar-bg">
<div class="progress-bar" style="width: ${progress}%"></div>
</div>
<p class="progress-text">${currentTask || 'Starting installation...'}</p>
</div>
<div class="task-log">
${completedTasks.map(task => `
<div class="task-log-item completed">&#10003; ${escapeHtml(task)}</div>
`).join('')}
${currentTask && !completedTasks.includes(currentTask) ? `
<div class="task-log-item current">
<div class="spinner spinner-small"></div>
${escapeHtml(currentTask)}
</div>
` : ''}
</div>
</div>
`;
}
function renderComplete() {
const port = state.branding.dashboardPort || 8080;
const tierLabels = { basic: 'Basic', intermediate: 'Standard', advanced: 'Full Stack' };
const modeLabels = { 'local': 'Local/IP', 'public': 'Public Domain', 'custom-tld': 'Custom TLD' };
return `
<div class="complete-content">
<div class="success-icon">&#10003;</div>
<h1>Installation Complete!</h1>
<p>${escapeHtml(state.branding.name)} has been successfully installed on your system.</p>
<div class="install-summary">
<h3>Installation Details</h3>
<div class="summary-item">
<span class="summary-label">Tier</span>
<span class="summary-value">${tierLabels[state.tier] || state.tier}</span>
</div>
<div class="summary-item">
<span class="summary-label">Access Mode</span>
<span class="summary-value">${modeLabels[state.domainMode] || state.domainMode}${state.domainMode === 'custom-tld' ? ' (' + escapeHtml(state.domain.tld) + ')' : ''}</span>
</div>
<div class="summary-item">
<span class="summary-label">Dashboard URL</span>
<span class="summary-value">${escapeHtml(state.result.dashboardUrl || 'http://localhost:' + port)}</span>
</div>
<div class="summary-item">
<span class="summary-label">Install Path</span>
<span class="summary-value">${escapeHtml(state.result.installPath || state.paths.install)}</span>
</div>
${state.tier !== 'basic' ? `
<div class="summary-item">
<span class="summary-label">API Server</span>
<span class="summary-value">http://localhost:${state.branding.apiPort || 3001}</span>
</div>
` : ''}
${state.dns.enabled ? `
<div class="summary-item">
<span class="summary-label">DNS Server</span>
<span class="summary-value">${escapeHtml(state.dns.serverUrl)}</span>
</div>
` : ''}
</div>
${state.result.health ? `
<div class="install-summary" style="margin-top: 16px;">
<h3>Service Status</h3>
<div class="summary-item">
<span class="summary-label">Caddy</span>
<span class="summary-value">${state.result.health.caddy ? '<span style="color:#22c55e">Running</span>' : '<span style="color:#ef4444">Not responding</span>'}</span>
</div>
</div>
` : ''}
<button class="btn btn-success" onclick="openDashboard()">
Open Dashboard
</button>
</div>
`;
}
function renderFooter() {
const isFirst = state.currentStep === 0;
const isLast = state.currentStep === steps.length - 1;
const isInstalling = state.currentStep === 7 && state.installation.status === 'running';
// Determine if user can proceed
let canProceed = true;
switch (state.currentStep) {
case 1: // Dependencies
canProceed = state.dependencies.docker.installed && state.dependencies.caddy.installed;
break;
case 2: // Folder
canProceed = !!state.paths.install;
break;
case 4: // Access mode
if (state.domainMode === 'public') {
canProceed = !!state.domain.publicDomain && !!state.domain.email;
} else if (state.domainMode === 'custom-tld') {
canProceed = !!state.domain.tld;
}
break;
}
if (isLast) {
return `
<div class="step-footer">
<div></div>
<button class="btn btn-primary" onclick="window.close()">Close Installer</button>
</div>
`;
}
if (isInstalling) {
return `
<div class="step-footer">
<div></div>
<button class="btn btn-primary" disabled>Installing...</button>
</div>
`;
}
// Button text
let nextLabel = 'Next';
if (state.currentStep === 6) nextLabel = 'Install';
return `
<div class="step-footer">
${isFirst ? '<div></div>' : '<button class="btn btn-secondary" onclick="prevStep()">Back</button>'}
<button class="btn btn-primary" onclick="nextStep()" ${!canProceed ? 'disabled' : ''}>
${nextLabel}
</button>
</div>
`;
}
// ── Uninstall Mode ────────────────────────────────────────────────────────
async function enterUninstallMode() {
state.uninstallMode = true;
state.uninstall.detecting = true;
state.uninstall.step = 'confirm';
render();
// Try to auto-detect existing installation
try {
const result = await window.electronAPI.detectInstallation();
if (result.success && result.data.found) {
state.uninstall.installPath = result.data.installPath;
state.uninstall.config = result.data.config;
state.uninstall.detected = true;
}
} catch (err) {
console.warn('Installation detection failed:', err);
}
state.uninstall.detecting = false;
render();
}
function exitUninstallMode() {
state.uninstallMode = false;
// Reset uninstall state
state.uninstall = {
step: 'confirm',
installPath: '',
config: null,
detected: false,
detecting: false,
preserveSettings: true,
preserveCA: true,
status: 'pending',
progress: 0,
currentTask: '',
completedTasks: [],
error: null,
settingsPath: null
};
render();
}
async function selectUninstallPath() {
try {
const result = await window.electronAPI.selectFolder();
if (result.success && result.path) {
state.uninstall.installPath = result.path;
state.uninstall.detected = false;
// Try to load config from selected path
const configResult = await window.electronAPI.loadConfig(result.path);
if (configResult.success && configResult.data?.config) {
state.uninstall.config = configResult.data.config;
state.uninstall.detected = true;
} else {
state.uninstall.config = null;
}
render();
}
} catch (err) {
console.error('Folder selection failed:', err);
}
}
function togglePreserveSettings(checked) {
state.uninstall.preserveSettings = checked;
render();
}
function togglePreserveCA(checked) {
state.uninstall.preserveCA = checked;
render();
}
async function startUninstallation() {
state.uninstall.status = 'running';
state.uninstall.step = 'progress';
state.uninstall.progress = 0;
state.uninstall.completedTasks = [];
state.uninstall.error = null;
render();
try {
await window.electronAPI.runUninstallation({
installPath: state.uninstall.installPath,
preserveSettings: state.uninstall.preserveSettings,
preserveCA: state.uninstall.preserveCA
});
} catch (err) {
state.uninstall.status = 'error';
state.uninstall.error = err.message;
render();
}
}
function renderUninstallView() {
switch (state.uninstall.step) {
case 'confirm': return renderUninstallConfirm();
case 'progress': return renderUninstallProgress();
case 'complete': return renderUninstallComplete();
default: return '';
}
}
function renderUninstallConfirm() {
const u = state.uninstall;
const tierLabels = { basic: 'Basic', intermediate: 'Standard', advanced: 'Full Stack' };
return `
<div class="uninstall-content">
<h1>Uninstall DashCaddy</h1>
<p>This will remove DashCaddy from your system. Services will be stopped and files removed.</p>
${u.detecting ? `
<div class="detecting-box">
<div class="spinner spinner-small"></div>
<span>Searching for existing installation...</span>
</div>
` : ''}
<div class="form-group">
<label>Installation Path</label>
<div class="path-input-row">
<input type="text" class="input" value="${escapeHtml(u.installPath)}"
onchange="state.uninstall.installPath = this.value" placeholder="Select installation folder..." />
<button class="btn btn-secondary" onclick="selectUninstallPath()">Browse</button>
</div>
${u.detected ? `
<div class="detected-badge">Installation detected</div>
` : u.installPath && !u.detecting ? `
<div class="not-detected-badge">No installation found at this path</div>
` : ''}
</div>
${u.config ? `
<div class="install-summary" style="margin-top: 16px;">
<h3>Detected Installation</h3>
<div class="summary-item">
<span class="summary-label">Tier</span>
<span class="summary-value">${tierLabels[u.config.tier] || u.config.tier || 'Unknown'}</span>
</div>
${u.config.domainMode ? `
<div class="summary-item">
<span class="summary-label">Domain Mode</span>
<span class="summary-value">${u.config.domainMode}${u.config.tld ? ' (' + escapeHtml(u.config.tld) + ')' : ''}</span>
</div>
` : ''}
${u.config.installedAt ? `
<div class="summary-item">
<span class="summary-label">Installed</span>
<span class="summary-value">${new Date(u.config.installedAt).toLocaleDateString()}</span>
</div>
` : ''}
</div>
` : ''}
<div class="preserve-options">
<h3>Preserve for Reinstall</h3>
<p class="preserve-description">Keep these files so a fresh install can pick up where you left off.</p>
<label class="checkbox-row">
<input type="checkbox" ${u.preserveSettings ? 'checked' : ''}
onchange="togglePreserveSettings(this.checked)" />
<div>
<strong>User settings</strong>
<span class="checkbox-hint">Config, credentials, branding, services list, encryption key</span>
</div>
</label>
<label class="checkbox-row">
<input type="checkbox" ${u.preserveCA ? 'checked' : ''}
onchange="togglePreserveCA(this.checked)" />
<div>
<strong>CA certificates</strong>
<span class="checkbox-hint">Root and intermediate certificates. Without these, every device that trusted your CA would need to re-trust a new one.</span>
</div>
</label>
</div>
<div class="step-footer">
<button class="btn btn-secondary" onclick="exitUninstallMode()">Cancel</button>
<button class="btn btn-danger" onclick="startUninstallation()"
${!u.detected ? 'disabled' : ''}>
Uninstall
</button>
</div>
</div>
`;
}
function renderUninstallProgress() {
const u = state.uninstall;
return `
<div class="uninstall-content">
<h1>Uninstalling DashCaddy</h1>
<p>Please wait while DashCaddy is being removed...</p>
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" style="width: ${u.progress}%"></div>
</div>
<div class="progress-text">${u.progress}%</div>
</div>
<div class="current-task">${escapeHtml(u.currentTask)}</div>
${u.completedTasks.length > 0 ? `
<div class="completed-tasks">
${u.completedTasks.map(task => `
<div class="task-item completed">
<span class="task-check">&#10003;</span>
<span>${escapeHtml(task)}</span>
</div>
`).join('')}
</div>
` : ''}
${u.status === 'error' ? `
<div class="error-message">
<strong>Error:</strong> ${escapeHtml(u.error)}
</div>
<div class="step-footer">
<button class="btn btn-secondary" onclick="exitUninstallMode()">Close</button>
</div>
` : ''}
</div>
`;
}
function renderUninstallComplete() {
const u = state.uninstall;
const preserved = [];
if (u.preserveSettings) preserved.push('user settings');
if (u.preserveCA) preserved.push('CA certificates');
return `
<div class="uninstall-content">
<div class="success-icon" style="color: #6366f1;">&#10003;</div>
<h1>Uninstall Complete</h1>
<p>DashCaddy has been removed from your system.</p>
${preserved.length > 0 ? `
<div class="install-summary" style="margin-top: 16px;">
<h3>Preserved Data</h3>
<div class="summary-item">
<span class="summary-label">Saved</span>
<span class="summary-value">${preserved.join(', ')}</span>
</div>
${u.settingsPath ? `
<div class="summary-item">
<span class="summary-label">Location</span>
<span class="summary-value">${escapeHtml(u.settingsPath)}</span>
</div>
` : ''}
<p class="preserve-note">These will be automatically restored when you reinstall to the same path.</p>
</div>
` : ''}
<div class="step-footer">
<button class="btn btn-secondary" onclick="exitUninstallMode()">Back to Installer</button>
<button class="btn btn-primary" onclick="window.close()">Close</button>
</div>
</div>
`;
}
// Utility functions
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWizard);
} else {
initWizard();
}