diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js
index ca751ef..3476b14 100644
--- a/dashcaddy-api/src/app.js
+++ b/dashcaddy-api/src/app.js
@@ -28,6 +28,7 @@ const auditLogger = require('./security/audit-logger');
const portLockManager = require('./managers/port-lock-manager');
const resourceMonitor = require('./managers/resource-monitor');
const backupManager = require('./utilities/backup-manager');
+require("./utilities/nesting-guard")();
const healthChecker = require('./monitoring/health-checker');
const updateManager = require('./managers/update-manager');
const selfUpdater = require('./docker/self-updater');
diff --git a/dashcaddy-api/src/utilities/i18n.js b/dashcaddy-api/src/utilities/i18n.js
index 2e363e1..41916ca 100644
--- a/dashcaddy-api/src/utilities/i18n.js
+++ b/dashcaddy-api/src/utilities/i18n.js
@@ -135,6 +135,44 @@ const TRANSLATIONS = {
'error.disk_full': 'Espace disque critique',
},
+ zh: {
+ 'dashboard.title': '仪表盘',
+ 'dashboard.services': '服务',
+ 'dashboard.containers': '容器',
+ 'dashboard.health': '健康',
+ 'dashboard.settings': '设置',
+ 'dashboard.backups': '备份',
+ 'dashboard.monitoring': '监控',
+ 'dashboard.security': '安全',
+
+ 'service.status.healthy': '健康',
+ 'service.status.degraded': '降级',
+ 'service.status.down': '宕机',
+ 'service.status.unknown': '未知',
+ 'service.status.pending': '待处理',
+
+ 'action.start': '启动',
+ 'action.stop': '停止',
+ 'action.restart': '重启',
+ 'action.delete': '删除',
+ 'action.update': '更新',
+ 'action.deploy': '部署',
+ 'action.save': '保存',
+ 'action.cancel': '取消',
+ 'action.confirm': '确认',
+
+ 'error.not_found': '未找到资源',
+ 'error.unauthorized': '未授权',
+ 'error.forbidden': '禁止访问',
+ 'error.rate_limited': '请求过多',
+ 'error.internal': '内部服务器错误',
+ 'error.container_not_found': '未找到容器',
+ 'error.service_not_found': '未找到服务',
+ 'error.invalid_input': '输入无效',
+ 'error.docker_unreachable': '无法连接 Docker 守护进程',
+ 'error.disk_full': '磁盘空间严重不足',
+ },
+
de: {
'dashboard.title': 'Dashboard',
'dashboard.services': 'Dienste',
diff --git a/dashcaddy-api/src/utilities/nesting-guard.js b/dashcaddy-api/src/utilities/nesting-guard.js
new file mode 100644
index 0000000..74d523c
--- /dev/null
+++ b/dashcaddy-api/src/utilities/nesting-guard.js
@@ -0,0 +1,41 @@
+/**
+ * Recursive data nesting guard.
+ *
+ * In past versions, a buggy update/restore path created data/data/data/...
+ * directories — each containing a full recursive copy of the parent.
+ * This module runs at startup, detects and removes nested duplicates.
+ *
+ * Add to app.js: require('./utilities/nesting-guard')();
+ */
+
+const fs = require('fs');
+const path = require('path');
+const log = require('./logging');
+
+module.exports = function nestingGuard() {
+ try {
+ const dataDir = require('../config/paths').dataDir;
+ const dataDataPath = path.join(dataDir, 'data');
+
+ // If data/data exists, it's a recursive duplicate — remove it
+ if (fs.existsSync(dataDataPath)) {
+ const stat = fs.statSync(dataDataPath);
+ if (stat.isDirectory()) {
+ // Verify it's truly a duplicate (contains config.json like the parent)
+ const markerFile = path.join(dataDataPath, 'config.json');
+ const parentMarker = path.join(dataDir, 'config.json');
+ if (fs.existsSync(markerFile) && fs.existsSync(parentMarker)) {
+ const size = require('child_process')
+ .execSync(`du -sh '${dataDataPath}' 2>/dev/null | cut -f1`)
+ .toString().trim();
+ log.warn('startup', `Removing recursive data nesting: ${dataDataPath} (${size})`);
+ fs.rmSync(dataDataPath, { recursive: true, force: true });
+ log.info('startup', 'Recursive nesting removed');
+ }
+ }
+ }
+ } catch (e) {
+ // Non-fatal — don't crash startup over cleanup
+ log.warn('startup', `Nesting guard skipped: ${e.message}`);
+ }
+};
diff --git a/dashcaddy-installer/package-lock.json b/dashcaddy-installer/package-lock.json
index 05985f1..fc79231 100644
--- a/dashcaddy-installer/package-lock.json
+++ b/dashcaddy-installer/package-lock.json
@@ -8,6 +8,9 @@
"name": "dashcaddy-installer",
"version": "1.0.0",
"license": "MIT",
+ "dependencies": {
+ "electron-updater": "^6.8.9"
+ },
"devDependencies": {
"electron": "^28.3.3",
"electron-builder": "^24.9.1",
@@ -1999,7 +2002,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "dev": true,
"license": "Python-2.0"
},
"node_modules/assert-plus": {
@@ -2895,7 +2897,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3422,6 +3423,82 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/electron-updater": {
+ "version": "6.8.9",
+ "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz",
+ "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==",
+ "license": "MIT",
+ "dependencies": {
+ "builder-util-runtime": "9.7.0",
+ "fs-extra": "^10.1.0",
+ "js-yaml": "^4.1.0",
+ "lazy-val": "^1.0.5",
+ "lodash.escaperegexp": "^4.1.2",
+ "lodash.isequal": "^4.5.0",
+ "semver": "~7.7.3",
+ "tiny-typed-emitter": "^2.1.0"
+ }
+ },
+ "node_modules/electron-updater/node_modules/builder-util-runtime": {
+ "version": "9.7.0",
+ "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
+ "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4",
+ "sax": "^1.2.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/electron-updater/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/electron-updater/node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/electron-updater/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/electron-updater/node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
"node_modules/emittery": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
@@ -4109,7 +4186,6 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/has-flag": {
@@ -5202,7 +5278,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -5300,7 +5375,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/lazystream": {
@@ -5406,6 +5480,12 @@
"license": "MIT",
"peer": true
},
+ "node_modules/lodash.escaperegexp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
+ "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
+ "license": "MIT"
+ },
"node_modules/lodash.flatten": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
@@ -5414,6 +5494,13 @@
"license": "MIT",
"peer": true
},
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+ "license": "MIT"
+ },
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -5670,7 +5757,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/natural-compare": {
@@ -6325,7 +6411,6 @@
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
- "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
@@ -6823,6 +6908,12 @@
"node": "*"
}
},
+ "node_modules/tiny-typed-emitter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
+ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
+ "license": "MIT"
+ },
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
diff --git a/dashcaddy-installer/package.json b/dashcaddy-installer/package.json
index 39591d8..3ee30f8 100644
--- a/dashcaddy-installer/package.json
+++ b/dashcaddy-installer/package.json
@@ -96,6 +96,13 @@
"installerIcon": "assets/icon.ico",
"uninstallerIcon": "assets/icon.ico",
"installerHeaderIcon": "assets/icon.ico"
+ },
+ "publish": {
+ "provider": "generic",
+ "url": "https://get.dashcaddy.net/release/"
}
+ },
+ "dependencies": {
+ "electron-updater": "^6.8.9"
}
-}
\ No newline at end of file
+}
diff --git a/dashcaddy-installer/package.json.bak b/dashcaddy-installer/package.json.bak
new file mode 100644
index 0000000..dcd9b71
--- /dev/null
+++ b/dashcaddy-installer/package.json.bak
@@ -0,0 +1,104 @@
+{
+ "name": "dashcaddy-installer",
+ "version": "1.0.0",
+ "description": "Cross-platform installer for DashCaddy platform",
+ "main": "src/main/index.js",
+ "scripts": {
+ "start": "electron .",
+ "dev": "electron . --dev",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "build": "electron-builder",
+ "build:win": "electron-builder --win",
+ "build:mac": "electron-builder --mac",
+ "build:linux": "electron-builder --linux"
+ },
+ "keywords": [
+ "dashcaddy",
+ "installer",
+ "docker",
+ "caddy"
+ ],
+ "author": {
+ "name": "DashCaddy Team",
+ "email": "dashcaddy@sami.cloud"
+ },
+ "homepage": "https://github.com/dashcaddy/dashcaddy",
+ "license": "MIT",
+ "devDependencies": {
+ "electron": "^28.3.3",
+ "electron-builder": "^24.9.1",
+ "fast-check": "^3.15.0",
+ "jest": "^29.7.0"
+ },
+ "build": {
+ "appId": "com.dashcaddy.installer",
+ "productName": "DashCaddy Installer",
+ "asar": true,
+ "directories": {
+ "output": "build-output"
+ },
+ "files": [
+ "src/**/*",
+ "assets/**/*",
+ "templates/**/*"
+ ],
+ "extraResources": [
+ {
+ "from": "../status",
+ "to": "status",
+ "filter": [
+ "**/*",
+ "!node_modules/**",
+ "!.git/**",
+ "!**/*.test.js",
+ "!**/*.spec.js"
+ ]
+ },
+ {
+ "from": "../dashcaddy-api",
+ "to": "dashcaddy-api",
+ "filter": [
+ "**/*",
+ "!node_modules/**",
+ "!.git/**",
+ "!**/*.test.js",
+ "!**/*.spec.js"
+ ]
+ }
+ ],
+ "icon": "assets/favicon.ico",
+ "win": {
+ "target": [
+ "nsis",
+ "portable"
+ ],
+ "icon": "assets/favicon.ico",
+ "signAndEditExecutable": false
+ },
+ "mac": {
+ "target": [
+ "zip"
+ ],
+ "icon": "assets/dashcaddy-logo.png"
+ },
+ "linux": {
+ "target": [
+ "AppImage",
+ "deb"
+ ],
+ "icon": "assets/dashcaddy-logo.png",
+ "category": "Utility"
+ },
+ "nsis": {
+ "oneClick": false,
+ "allowToChangeInstallationDirectory": true,
+ "installerIcon": "assets/icon.ico",
+ "uninstallerIcon": "assets/icon.ico",
+ "installerHeaderIcon": "assets/icon.ico"
+ }
+ },
+ "dependencies": {
+ "electron-updater": "^6.8.9"
+ }
+}
diff --git a/dashcaddy-installer/src/main/index.js b/dashcaddy-installer/src/main/index.js
index 079b31d..7e00f46 100644
--- a/dashcaddy-installer/src/main/index.js
+++ b/dashcaddy-installer/src/main/index.js
@@ -12,6 +12,105 @@ process.on('uncaughtException', (error) => {
let mainWindow;
const { registerVMHandlers } = require('./vm-ipc');
+// --- Auto-updater (electron-updater) ---
+// Checks get.dashcaddy.net for new installer versions. Failures are silent
+// so offline / air-gapped hosts are unaffected.
+const { autoUpdater, Notification } = require('electron-updater');
+const UPDATE_FEED_URL = 'https://get.dashcaddy.net/release/';
+
+function configureAutoUpdater() {
+ autoUpdater.autoDownload = true; // download silently in background
+ autoUpdater.autoInstallOnAppQuit = true; // install on next quit
+ autoUpdater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL });
+
+ // Graceful error handling — never crash on update failures
+ autoUpdater.on('error', (error) => {
+ console.error('[Updater] Error:', error == null ? 'unknown' : error.message || String(error));
+ });
+
+ autoUpdater.on('update-available', (info) => {
+ console.log('[Updater] Update available:', info && info.version);
+ try {
+ // Show a desktop notification if supported; renderer is notified via IPC too
+ if (Notification && Notification.isSupported()) {
+ new Notification({
+ title: 'A new version of DashCaddy is available',
+ body: `Version ${info && info.version ? info.version : 'new'} is downloading and will install when you quit.`,
+ silent: true
+ }).show();
+ }
+ } catch (e) {
+ // notifications may be unsupported (headless) — ignore
+ }
+ // Forward to the wizard so it can show an in-app banner
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('update-available', {
+ version: info && info.version ? info.version : null
+ });
+ }
+ });
+
+ autoUpdater.on('update-not-available', (info) => {
+ console.log('[Updater] Up to date.');
+ });
+
+ autoUpdater.on('download-progress', (progress) => {
+ // keep verbose; useful for debugging but not surfaced to UI unless desired
+ if (progress && progress.percent) {
+ console.log(`[Updater] Downloading update: ${Math.round(progress.percent)}%`);
+ }
+ });
+
+ autoUpdater.on('update-downloaded', (info) => {
+ console.log('[Updater] Update downloaded; will install on quit.', info && info.version);
+ try {
+ if (Notification && Notification.isSupported()) {
+ new Notification({
+ title: 'DashCaddy update ready',
+ body: 'It will be installed automatically when you quit the installer.',
+ silent: true
+ }).show();
+ }
+ } catch (e) {
+ // ignore
+ }
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('update-downloaded', {
+ version: info && info.version ? info.version : null
+ });
+ }
+ });
+
+ // Check for updates after a short delay so the wizard can boot first.
+ setTimeout(() => {
+ autoUpdater.checkForUpdates().catch((e) => {
+ // offline / network errors are expected — stay silent
+ console.error('[Updater] checkForUpdates failed (likely offline):', e == null ? 'unknown' : e.message || String(e));
+ });
+ }, 10000);
+}
+
+// IPC: renderer can manually trigger an update check
+ipcMain.handle('check-for-updates', async () => {
+ try {
+ const result = await autoUpdater.checkForUpdates();
+ return { success: true, updateInfo: result && result.updateInfo ? { version: result.updateInfo.version } : null };
+ } catch (e) {
+ return { success: false, error: e == null ? 'unknown' : e.message || String(e) };
+ }
+});
+
+// IPC: renderer can request to quit-and-install a downloaded update
+ipcMain.handle('quit-and-install', async () => {
+ try {
+ autoUpdater.quitAndInstall();
+ return { success: true };
+ } catch (e) {
+ return { success: false, error: e == null ? 'unknown' : e.message || String(e) };
+ }
+});
+
+
function createWindow() {
mainWindow = new BrowserWindow({
width: 900,
@@ -64,6 +163,9 @@ ipcMain.handle('get-disk-space', async (event, targetPath) => {
app.whenReady().then(() => {
createWindow();
+ // Start the auto-updater (10s delayed check, silent on failure)
+ configureAutoUpdater();
+
registerVMHandlers(mainWindow);
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
diff --git a/dashcaddy-installer/src/main/index.js.bak b/dashcaddy-installer/src/main/index.js.bak
new file mode 100644
index 0000000..079b31d
--- /dev/null
+++ b/dashcaddy-installer/src/main/index.js.bak
@@ -0,0 +1,1062 @@
+const { app, BrowserWindow, ipcMain, dialog } = require('electron');
+const path = require('path');
+
+// Disable GPU acceleration to prevent crashes
+app.disableHardwareAcceleration();
+
+// Add error handling
+process.on('uncaughtException', (error) => {
+ console.error('Uncaught Exception:', error);
+});
+
+let mainWindow;
+const { registerVMHandlers } = require('./vm-ipc');
+
+function createWindow() {
+ mainWindow = new BrowserWindow({
+ width: 900,
+ height: 700,
+ minWidth: 800,
+ minHeight: 600,
+ icon: path.join(__dirname, '../../assets/favicon.ico'),
+ webPreferences: {
+ preload: path.join(__dirname, '../preload/index.js'),
+ nodeIntegration: false,
+ contextIsolation: true,
+ sandbox: false
+ },
+ autoHideMenuBar: true,
+ resizable: true,
+ show: false // Don't show until ready
+ });
+
+ mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
+
+ // Show window when ready
+ mainWindow.once('ready-to-show', () => {
+ mainWindow.show();
+ });
+
+ // Open DevTools in development mode
+ if (process.argv.includes('--dev')) {
+ mainWindow.webContents.openDevTools();
+ }
+
+ mainWindow.on('closed', () => {
+ mainWindow = null;
+ });
+}
+
+// App lifecycle handlers
+// --- Disk space check (for VM disk budget step) ---
+ipcMain.handle('get-disk-space', async (event, targetPath) => {
+ try {
+ const stats = await require('fs').promises.statfs(targetPath || '/');
+ return {
+ free: stats.bavail * stats.bsize,
+ total: stats.blocks * stats.bsize,
+ };
+ } catch (e) {
+ return { free: 0, total: 0, error: e.message };
+ }
+});
+
+app.whenReady().then(() => {
+ createWindow();
+
+ registerVMHandlers(mainWindow);
+ app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) {
+ createWindow();
+ }
+ });
+});
+
+app.on('window-all-closed', () => {
+ if (process.platform !== 'darwin') {
+ app.quit();
+ }
+});
+
+// Import utilities
+const { DEFAULT_PORTS } = require('../shared/constants');
+const { getPlatformInfo } = require('../shared/platform-utils');
+const DependencyChecker = require('./dependency-checker');
+const ConfigManager = require('./config-manager');
+const FileDeployer = require('./file-deployer');
+const CaddyfileGenerator = require('./caddyfile-generator');
+const BrowserLauncher = require('./browser-launcher');
+const ServiceManager = require('./service-manager');
+
+// Create instances
+const dependencyChecker = new DependencyChecker();
+const configManager = new ConfigManager();
+const fileDeployer = new FileDeployer();
+const caddyfileGenerator = new CaddyfileGenerator();
+const browserLauncher = new BrowserLauncher();
+const serviceManager = new ServiceManager();
+
+// IPC handlers
+ipcMain.handle('check-platform', async () => {
+ try {
+ return {
+ success: true,
+ data: getPlatformInfo()
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+ipcMain.handle('check-dependencies', async (event, options) => {
+ try {
+ // Accept optional search paths for Caddy (e.g. user's configured caddy folder)
+ const additionalPaths = options?.caddySearchPaths || [];
+ const [docker, caddy, platform] = await Promise.all([
+ dependencyChecker.checkDocker(),
+ dependencyChecker.checkCaddy(additionalPaths),
+ dependencyChecker.checkPlatform()
+ ]);
+
+ return {
+ success: true,
+ data: {
+ docker,
+ caddy,
+ platform
+ }
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+ipcMain.handle('check-docker', async () => {
+ try {
+ const result = await dependencyChecker.checkDocker();
+ return {
+ success: true,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+ipcMain.handle('check-caddy', async (event, options) => {
+ try {
+ const additionalPaths = options?.caddySearchPaths || [];
+ const result = await dependencyChecker.checkCaddy(additionalPaths);
+ return {
+ success: true,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+ipcMain.handle('install-docker', async () => {
+ try {
+ const platform = getPlatformInfo().os;
+ const result = await dependencyChecker.installDocker(platform);
+ return {
+ success: result.success,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+ipcMain.handle('install-caddy', async (event, options) => {
+ try {
+ const platform = getPlatformInfo().os;
+ const targetPath = options?.targetPath || null;
+ const result = await dependencyChecker.installCaddy(platform, null, targetPath);
+ return {
+ success: result.success,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+// Configuration management handlers
+ipcMain.handle('validate-path', async (event, testPath) => {
+ try {
+ const result = await configManager.validatePath(testPath);
+ return {
+ success: result.valid,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+ipcMain.handle('save-config', async (event, config) => {
+ try {
+ const result = await configManager.saveConfig(config, config.installPath);
+ return {
+ success: result.success,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+ipcMain.handle('load-config', async (event, installPath) => {
+ try {
+ const result = await configManager.loadConfig(installPath);
+ return {
+ success: result.exists,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+// File deployment handlers
+ipcMain.handle('validate-sources', async () => {
+ try {
+ const result = await fileDeployer.validateSources();
+ return {
+ success: result.valid,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+ipcMain.handle('deploy-dashboard', async (event, installPath) => {
+ try {
+ const result = await fileDeployer.deployDashboard(installPath, (progress) => {
+ event.sender.send('deployment-progress', progress);
+ });
+ return {
+ success: result.success,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+ipcMain.handle('deploy-api', async (event, installPath) => {
+ try {
+ const result = await fileDeployer.deployAPI(installPath, (progress) => {
+ event.sender.send('deployment-progress', progress);
+ });
+ return {
+ success: result.success,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+ipcMain.handle('deploy-complete', async (event, installPath) => {
+ try {
+ const result = await fileDeployer.deployComplete(installPath, (progress) => {
+ event.sender.send('deployment-progress', progress);
+ });
+ return {
+ success: result.success,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+// Caddyfile generation handlers
+ipcMain.handle('create-caddyfile', async (event, installPath, options) => {
+ try {
+ const result = await caddyfileGenerator.createCaddyfileSetup(installPath, options);
+ return {
+ success: result.success,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+ipcMain.handle('create-docker-compose', async (event, installPath) => {
+ try {
+ const result = await caddyfileGenerator.createDockerCompose(installPath);
+ return {
+ success: result.success,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+// Browser launcher handlers
+ipcMain.handle('open-dashboard', async (event, port, hostname) => {
+ try {
+ const result = await browserLauncher.openDashboardWhenReady(port, hostname);
+ return {
+ success: result.success,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+// Folder selection handler
+ipcMain.handle('select-folder', async () => {
+ try {
+ const result = await dialog.showOpenDialog(mainWindow, {
+ properties: ['openDirectory', 'createDirectory'],
+ title: 'Select Installation Folder'
+ });
+
+ if (result.canceled) {
+ return { success: false, canceled: true };
+ }
+
+ return {
+ success: true,
+ path: result.filePaths[0]
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+// File selection handler (for logo, etc.)
+ipcMain.handle('select-file', async (event, options) => {
+ try {
+ const result = await dialog.showOpenDialog(mainWindow, {
+ properties: ['openFile'],
+ filters: options?.filters || [
+ { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'svg', 'ico'] }
+ ],
+ title: options?.title || 'Select File'
+ });
+
+ if (result.canceled) {
+ return { success: false, canceled: true };
+ }
+
+ return {
+ success: true,
+ path: result.filePaths[0]
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+// Installation orchestration handler (tier-aware)
+ipcMain.handle('run-installation', async (event, config) => {
+ try {
+ const tier = config.tier || 'basic';
+ const isDocker = tier !== 'basic'; // intermediate or advanced needs Docker
+
+ // Build steps dynamically based on tier
+ const steps = [
+ { name: 'Creating directories', weight: 5 },
+ { name: 'Creating seed files', weight: 3 }
+ ];
+
+ steps.push({ name: 'Deploying dashboard files', weight: 20 });
+
+ if (isDocker) {
+ steps.push({ name: 'Deploying API server', weight: 15 });
+ }
+
+ steps.push({ name: 'Generating Caddyfile', weight: 10 });
+
+ if (isDocker) {
+ steps.push({ name: 'Creating docker-compose.yml', weight: 8 });
+ }
+
+ steps.push({ name: 'Setting up branding', weight: 5 });
+
+ if (config.dns) {
+ steps.push({ name: 'Saving DNS credentials', weight: 5 });
+ }
+
+ steps.push({ name: 'Saving configuration', weight: 5 });
+ steps.push({ name: 'Starting services', weight: 10 });
+
+ let completedWeight = 0;
+ const totalWeight = steps.reduce((sum, s) => sum + s.weight, 0);
+
+ const sendProgress = (stepName, stepProgress = 100) => {
+ const overallProgress = Math.round((completedWeight / totalWeight) * 100);
+ event.sender.send('step-progress', {
+ task: stepName,
+ progress: overallProgress,
+ stepProgress
+ });
+ };
+
+ const completeStep = (stepName) => {
+ const step = steps.find(s => s.name === stepName);
+ if (step) completedWeight += step.weight;
+ event.sender.send('step-complete', { step: stepName });
+ };
+
+ // Step: Create directories
+ sendProgress('Creating directories', 0);
+ await configManager.createDirectories(config.installPath);
+ completeStep('Creating directories');
+
+ // Step: Restore preserved settings from a previous uninstall (if any)
+ try {
+ const restoreResult = await configManager.restoreUserSettings(config.installPath);
+ if (restoreResult.success && restoreResult.restored.length > 0) {
+ console.log('[Installer] Restored preserved settings:', restoreResult.restored);
+ event.sender.send('step-progress', {
+ task: `Restored ${restoreResult.restored.length} preserved file(s) from previous install`,
+ progress: 0,
+ stepProgress: 100
+ });
+ }
+ } catch (err) {
+ console.warn('Settings restore check:', err.message);
+ }
+
+ // Step: Create seed files (services.json, credentials.json, etc.)
+ sendProgress('Creating seed files', 0);
+ const seedResult = await configManager.seedFiles(config.installPath);
+ if (!seedResult.success) {
+ console.warn('Seed file creation warning:', seedResult.error);
+ }
+ completeStep('Creating seed files');
+
+ // Step: Deploy dashboard
+ sendProgress('Deploying dashboard files', 0);
+ await fileDeployer.deployDashboard(config.installPath, (progress) => {
+ sendProgress('Deploying dashboard files', progress.percent || 0);
+ });
+ completeStep('Deploying dashboard files');
+
+ // Step: Deploy API (intermediate/advanced only)
+ if (isDocker) {
+ sendProgress('Deploying API server', 0);
+ await fileDeployer.deployAPI(config.installPath, (progress) => {
+ sendProgress('Deploying API server', progress.percent || 0);
+ });
+ completeStep('Deploying API server');
+ }
+
+ // Step: Generate Caddyfile
+ sendProgress('Generating Caddyfile', 0);
+ const caddyfileOptions = {
+ port: config.dashboardPort || 8080,
+ apiPort: config.apiPort || 3001,
+ tier: tier,
+ domainMode: config.domainMode || 'local'
+ };
+ if (config.domainMode === 'public' && config.domain) {
+ caddyfileOptions.publicDomain = config.domain.publicDomain;
+ caddyfileOptions.email = config.domain.email;
+ } else if (config.domainMode === 'custom-tld' && config.domain) {
+ caddyfileOptions.tld = config.domain.tld;
+ caddyfileOptions.caName = config.domain.caName || 'DashCaddy Local CA';
+ }
+ await caddyfileGenerator.createCaddyfileSetup(config.installPath, caddyfileOptions);
+ completeStep('Generating Caddyfile');
+
+ // Step: Create docker-compose (intermediate/advanced only)
+ if (isDocker) {
+ sendProgress('Creating docker-compose.yml', 0);
+ await caddyfileGenerator.createDockerCompose(config.installPath, {
+ apiPort: config.apiPort || 3001,
+ domainMode: config.domainMode || 'local',
+ lanIP: config.lanIP || '',
+ tailscaleIP: config.tailscaleIP || ''
+ });
+ completeStep('Creating docker-compose.yml');
+ }
+
+ // Step: Setup branding
+ sendProgress('Setting up branding', 0);
+ if (config.branding) {
+ await configManager.saveBranding(config.branding, config.installPath);
+ }
+ completeStep('Setting up branding');
+
+ // Step: Save DNS credentials (if provided)
+ if (config.dns) {
+ sendProgress('Saving DNS credentials', 0);
+ await configManager.saveDNSCredentials(config.dns, config.installPath);
+ completeStep('Saving DNS credentials');
+ }
+
+ // Step: Save configuration
+ sendProgress('Saving configuration', 0);
+
+ // Derive dashboardHost and tld from domain mode
+ let dashboardHost, tld = null;
+ if (config.domainMode === 'public' && config.domain) {
+ dashboardHost = config.domain.publicDomain;
+ } else if (config.domainMode === 'custom-tld' && config.domain) {
+ tld = config.domain.tld;
+ dashboardHost = `dashcaddy${tld}`;
+ } else {
+ dashboardHost = `localhost:${config.dashboardPort || 8080}`;
+ }
+
+ await configManager.saveConfig({
+ setupComplete: true,
+ configurationType: config.domainMode === 'custom-tld' ? 'homelab' : (config.domainMode === 'public' ? 'public' : 'local'),
+ tier: tier,
+ domainMode: config.domainMode || 'local',
+ tld: tld,
+ dashboardHost: dashboardHost,
+ dns: config.dns ? {
+ provider: 'technitium',
+ ip: config.dns.server ? config.dns.server.replace(/^https?:\/\//, '').replace(/:\d+$/, '') : '',
+ port: '5380'
+ } : undefined,
+ installedAt: new Date().toISOString(),
+ version: '1.0.0'
+ }, config.installPath);
+ completeStep('Saving configuration');
+
+ // Step: Start services
+ sendProgress('Starting services', 0);
+ if (config.autoStart) {
+ const caddyfilePath = path.join(config.installPath, 'Caddyfile');
+ const caddyBinaryPath = config.caddyBinaryPath || null;
+
+ // Start Caddy
+ sendProgress('Starting Caddy...', 30);
+ const caddyResult = await serviceManager.startCaddy(caddyfilePath, caddyBinaryPath);
+ if (!caddyResult.success) {
+ console.warn('Caddy start warning:', caddyResult.error);
+ }
+
+ // Start Docker Compose only for intermediate/advanced tiers
+ if (isDocker) {
+ sendProgress('Starting Docker containers...', 60);
+ const dockerResult = await serviceManager.startDockerCompose(config.installPath);
+ if (!dockerResult.success) {
+ console.warn('Docker Compose start warning:', dockerResult.error);
+ }
+ }
+ }
+ completeStep('Starting services');
+
+ // Installation complete - determine dashboard URL
+ let dashboardUrl;
+ if (config.domainMode === 'public' && config.domain) {
+ dashboardUrl = `https://${config.domain.publicDomain}`;
+ } else if (config.domainMode === 'custom-tld' && config.domain) {
+ dashboardUrl = `https://dashcaddy${config.domain.tld}`;
+ } else {
+ dashboardUrl = `http://localhost:${config.dashboardPort || 8080}`;
+ }
+
+ // Quick health check (non-blocking, just informational)
+ let healthResults = null;
+ if (config.autoStart) {
+ sendProgress('Verifying services...', 95);
+ // Wait a moment for services to start up
+ await new Promise(r => setTimeout(r, 3000));
+ try {
+ const http = require('http');
+ const caddyOk = await new Promise((resolve) => {
+ const req = http.get(`http://localhost:${DEFAULT_PORTS.CADDY_ADMIN}/config/`, { timeout: 5000 }, (res) => {
+ resolve(res.statusCode === 200);
+ });
+ req.on('error', () => resolve(false));
+ req.on('timeout', () => { req.destroy(); resolve(false); });
+ });
+ healthResults = { caddy: caddyOk };
+ } catch {
+ healthResults = { caddy: false };
+ }
+ }
+
+ sendProgress('Installation complete', 100);
+ event.sender.send('installation-complete', {
+ success: true,
+ installPath: config.installPath,
+ dashboardUrl,
+ health: healthResults
+ });
+
+ return {
+ success: true,
+ data: {
+ installPath: config.installPath,
+ dashboardUrl
+ }
+ };
+ } catch (error) {
+ event.sender.send('step-error', { error: error.message });
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+// DNS connection test handler
+ipcMain.handle('test-dns-connection', async (event, credentials) => {
+ try {
+ // Test connectivity to the DNS server
+ if (!credentials?.server) {
+ return {
+ success: false,
+ error: 'No server URL provided'
+ };
+ }
+
+ const http = require('http');
+ const https = require('https');
+ const { URL } = require('url');
+
+ const url = new URL(credentials.server);
+ const protocol = url.protocol === 'https:' ? https : http;
+
+ const connected = await new Promise((resolve) => {
+ const req = protocol.get(url.href, { timeout: 10000 }, (res) => {
+ resolve(true);
+ });
+ req.on('error', () => resolve(false));
+ req.on('timeout', () => {
+ req.destroy();
+ resolve(false);
+ });
+ });
+
+ return {
+ success: true,
+ data: {
+ connected,
+ message: connected ? 'Successfully connected to DNS server' : 'Could not reach DNS server'
+ }
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+// Network detection handler - detects LAN and Tailscale IPs
+ipcMain.handle('detect-network', async () => {
+ try {
+ const os = require('os');
+ const interfaces = os.networkInterfaces();
+ let lanIP = '';
+ let tailscaleIP = '';
+
+ for (const [name, addrs] of Object.entries(interfaces)) {
+ for (const addr of addrs) {
+ if (addr.family !== 'IPv4' || addr.internal) continue;
+
+ // Tailscale interfaces: typically 100.x.x.x range
+ if (addr.address.startsWith('100.') && (name.toLowerCase().includes('tailscale') || name.toLowerCase().includes('utun'))) {
+ tailscaleIP = addr.address;
+ }
+ // LAN: common private ranges (not 100.x Tailscale, not 172.17+ Docker)
+ else if (
+ addr.address.startsWith('192.168.') ||
+ addr.address.startsWith('10.') ||
+ (addr.address.startsWith('172.') && !addr.address.startsWith('172.17.'))
+ ) {
+ if (!lanIP) lanIP = addr.address;
+ }
+ }
+ }
+
+ // Fallback: try to find Tailscale IP from 100.x range even without name match
+ if (!tailscaleIP) {
+ for (const addrs of Object.values(interfaces)) {
+ for (const addr of addrs) {
+ if (addr.family === 'IPv4' && addr.address.startsWith('100.') && !addr.internal) {
+ tailscaleIP = addr.address;
+ break;
+ }
+ }
+ if (tailscaleIP) break;
+ }
+ }
+
+ return {
+ success: true,
+ data: { lanIP, tailscaleIP }
+ };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+});
+
+// Health check handler - verify services are running after install
+ipcMain.handle('health-check', async (event, config) => {
+ try {
+ const http = require('http');
+ const results = {};
+
+ // Check Caddy admin API
+ results.caddy = await new Promise((resolve) => {
+ const req = http.get(`http://localhost:${DEFAULT_PORTS.CADDY_ADMIN}/config/`, { timeout: 5000 }, (res) => {
+ resolve({ running: res.statusCode === 200, statusCode: res.statusCode });
+ });
+ req.on('error', () => resolve({ running: false }));
+ req.on('timeout', () => { req.destroy(); resolve({ running: false }); });
+ });
+
+ // Check API server (only for non-basic tiers)
+ const apiPort = config?.apiPort || DEFAULT_PORTS.API;
+ results.api = await new Promise((resolve) => {
+ const req = http.get(`http://localhost:${apiPort}/api/health`, { timeout: 5000 }, (res) => {
+ resolve({ running: res.statusCode === 200, statusCode: res.statusCode });
+ });
+ req.on('error', () => resolve({ running: false }));
+ req.on('timeout', () => { req.destroy(); resolve({ running: false }); });
+ });
+
+ // Check dashboard is reachable
+ let dashboardPort = config?.dashboardPort || 8080;
+ let dashboardProto = 'http';
+ if (config?.domainMode === 'custom-tld' || config?.domainMode === 'public') {
+ dashboardPort = 443;
+ dashboardProto = 'https';
+ }
+
+ results.dashboard = await new Promise((resolve) => {
+ const mod = dashboardProto === 'https' ? require('https') : http;
+ const opts = { timeout: 5000 };
+ if (dashboardProto === 'https') opts.rejectUnauthorized = false;
+ const req = mod.get(`${dashboardProto}://localhost:${dashboardPort}/`, opts, (res) => {
+ resolve({ running: res.statusCode >= 200 && res.statusCode < 400, statusCode: res.statusCode });
+ });
+ req.on('error', () => resolve({ running: false }));
+ req.on('timeout', () => { req.destroy(); resolve({ running: false }); });
+ });
+
+ return { success: true, data: results };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+});
+
+// DNS credential handlers
+ipcMain.handle('save-dns-credentials', async (event, credentials, installPath) => {
+ try {
+ const result = await configManager.saveDNSCredentials(credentials, installPath);
+ return {
+ success: result.success,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+// Branding handler
+ipcMain.handle('save-branding', async (event, branding, installPath) => {
+ try {
+ const result = await configManager.saveBranding(branding, installPath);
+ return {
+ success: result.success,
+ data: result
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error.message
+ };
+ }
+});
+
+// Service management handlers
+ipcMain.handle('start-services', async (event, config) => {
+ try {
+ const result = await serviceManager.startAll(config.installPath, {
+ caddyfilePath: config.caddyfilePath,
+ caddyBinaryPath: config.caddyBinaryPath
+ });
+ return { success: result.success, data: result };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+});
+
+ipcMain.handle('stop-services', async (event, config) => {
+ try {
+ const result = await serviceManager.stopAll(config.installPath);
+ return { success: result.success, data: result };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+});
+
+ipcMain.handle('check-service-status', async (event, config) => {
+ try {
+ const [caddyStatus, dockerStatus] = await Promise.all([
+ serviceManager.checkCaddyStatus(),
+ serviceManager.checkDockerComposeStatus(config.installPath)
+ ]);
+ return {
+ success: true,
+ data: { caddy: caddyStatus, docker: dockerStatus }
+ };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+});
+
+// Detect existing installation
+ipcMain.handle('detect-installation', async () => {
+ try {
+ const { DEFAULT_PATHS } = require('../shared/constants');
+ const platformInfo = getPlatformInfo();
+ const defaultPath = DEFAULT_PATHS[platformInfo.platform] || DEFAULT_PATHS.win32;
+
+ // Check default path first
+ const exists = await configManager.installationExists(defaultPath);
+ if (exists) {
+ const configResult = await configManager.loadConfig(defaultPath);
+ return {
+ success: true,
+ data: { found: true, installPath: defaultPath, config: configResult.config }
+ };
+ }
+
+ // Check common alternative paths
+ const alternatives = ['C:\\DashCaddy', 'C:\\caddy', '/opt/dashcaddy', '/Applications/DashCaddy'];
+ for (const altPath of alternatives) {
+ if (altPath === defaultPath) continue;
+ const altExists = await configManager.installationExists(altPath);
+ if (altExists) {
+ const configResult = await configManager.loadConfig(altPath);
+ return {
+ success: true,
+ data: { found: true, installPath: altPath, config: configResult.config }
+ };
+ }
+ }
+
+ return { success: true, data: { found: false } };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+});
+
+// Check for preserved settings from previous uninstall
+ipcMain.handle('check-preserved-settings', async (event, installPath) => {
+ try {
+ const settingsDir = path.join(installPath, '.dashcaddy-settings');
+ try {
+ await require('fs').promises.access(settingsDir);
+ const files = await require('fs').promises.readdir(settingsDir);
+ return { success: true, data: { found: true, files, settingsPath: settingsDir } };
+ } catch {
+ return { success: true, data: { found: false } };
+ }
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+});
+
+// Restore preserved settings during reinstall
+ipcMain.handle('restore-preserved-settings', async (event, installPath) => {
+ try {
+ const result = await configManager.restoreUserSettings(installPath);
+ return { success: result.success, data: result };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+});
+
+// Uninstall orchestration handler with progress tracking
+ipcMain.handle('run-uninstallation', async (event, options) => {
+ try {
+ const { installPath, preserveSettings, preserveCA } = options;
+
+ // Verify installation exists
+ const exists = await configManager.installationExists(installPath);
+ if (!exists) {
+ return { success: false, error: 'No DashCaddy installation found at this path' };
+ }
+
+ // Load config to understand what was installed
+ const configResult = await configManager.loadConfig(installPath);
+ const config = configResult.config || {};
+ const isDocker = config.tier && config.tier !== 'basic';
+
+ // Build uninstall steps
+ const uninstallSteps = [];
+ uninstallSteps.push({ name: 'Stopping Caddy', weight: 10 });
+ if (isDocker) {
+ uninstallSteps.push({ name: 'Stopping Docker containers', weight: 15 });
+ uninstallSteps.push({ name: 'Removing Docker containers', weight: 10 });
+ }
+ if (preserveSettings || preserveCA) {
+ uninstallSteps.push({ name: 'Backing up preserved data', weight: 10 });
+ }
+ uninstallSteps.push({ name: 'Removing installation files', weight: 40 });
+
+ let completedWeight = 0;
+ const totalWeight = uninstallSteps.reduce((sum, s) => sum + s.weight, 0);
+
+ const sendProgress = (stepName, stepProgress = 0) => {
+ const overallProgress = Math.round((completedWeight / totalWeight) * 100);
+ event.sender.send('uninstall-progress', {
+ task: stepName,
+ progress: overallProgress,
+ stepProgress
+ });
+ };
+
+ const completeStep = (stepName) => {
+ const step = uninstallSteps.find(s => s.name === stepName);
+ if (step) completedWeight += step.weight;
+ event.sender.send('uninstall-step-complete', { step: stepName });
+ };
+
+ // Step: Stop Caddy
+ sendProgress('Stopping Caddy');
+ try {
+ await serviceManager.stopCaddy();
+ } catch (err) {
+ console.warn('Caddy stop warning:', err.message);
+ }
+ completeStep('Stopping Caddy');
+
+ // Step: Stop & remove Docker containers (if applicable)
+ if (isDocker) {
+ sendProgress('Stopping Docker containers');
+ try {
+ await serviceManager.stopDockerCompose(installPath);
+ } catch (err) {
+ console.warn('Docker stop warning:', err.message);
+ }
+ completeStep('Stopping Docker containers');
+
+ sendProgress('Removing Docker containers');
+ try {
+ const execPromise = require('util').promisify(require('child_process').exec);
+ const composePath = path.join(installPath, 'sites', 'dashcaddy-api', 'docker-compose.yml');
+ await execPromise(`docker compose -f "${composePath}" down --rmi local --volumes`, {
+ cwd: path.dirname(composePath),
+ timeout: 120000
+ });
+ } catch (err) {
+ console.warn('Docker cleanup warning:', err.message);
+ }
+ completeStep('Removing Docker containers');
+ }
+
+ // Step: Back up preserved data
+ if (preserveSettings || preserveCA) {
+ sendProgress('Backing up preserved data');
+ }
+
+ // Step: Remove files (config-manager handles backup internally)
+ sendProgress('Removing installation files');
+ const removeResult = await configManager.removeInstallation(installPath, {
+ preserveSettings,
+ preserveCA
+ });
+
+ if (!removeResult.success) {
+ event.sender.send('uninstall-error', { error: removeResult.error || removeResult.message });
+ return { success: false, error: removeResult.message };
+ }
+
+ if (preserveSettings || preserveCA) {
+ completeStep('Backing up preserved data');
+ }
+ completeStep('Removing installation files');
+
+ sendProgress('Uninstall complete', 100);
+ event.sender.send('uninstall-complete', {
+ success: true,
+ preservedSettings: preserveSettings,
+ preservedCA: preserveCA,
+ settingsPath: removeResult.settingsPath || null
+ });
+
+ return {
+ success: true,
+ data: {
+ preservedSettings: preserveSettings,
+ preservedCA: preserveCA,
+ settingsPath: removeResult.settingsPath || null
+ }
+ };
+ } catch (error) {
+ event.sender.send('uninstall-error', { error: error.message });
+ return { success: false, error: error.message };
+ }
+});
+
+module.exports = { mainWindow };
diff --git a/dashcaddy-installer/src/preload/index.js b/dashcaddy-installer/src/preload/index.js
index e0365d8..8c873b7 100644
--- a/dashcaddy-installer/src/preload/index.js
+++ b/dashcaddy-installer/src/preload/index.js
@@ -113,6 +113,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('vm:error', (event, data) => callback(data));
},
+ // --- Auto-updater ---
+ checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
+ quitAndInstall: () => ipcRenderer.invoke('quit-and-install'),
+ onUpdateAvailable: (callback) => {
+ ipcRenderer.on('update-available', (event, data) => callback(data));
+ },
+ onUpdateDownloaded: (callback) => {
+ ipcRenderer.on('update-downloaded', (event, data) => callback(data));
+ },
+
// Remove listeners
removeListener: (channel) => {
ipcRenderer.removeAllListeners(channel);
diff --git a/dashcaddy-installer/src/renderer/wizard.js b/dashcaddy-installer/src/renderer/wizard.js
index 13ae664..99e3f58 100644
--- a/dashcaddy-installer/src/renderer/wizard.js
+++ b/dashcaddy-installer/src/renderer/wizard.js
@@ -56,6 +56,12 @@ const state = {
tailscaleIP: '',
detected: false
},
+ // Auto-updater state
+ update: {
+ available: false,
+ downloaded: false,
+ version: null
+ },
installation: {
status: 'pending', // pending, running, complete, error
progress: 0,
@@ -229,6 +235,20 @@ function setupEventListeners() {
state.installation.error = data.error || 'VM provisioning failed';
render();
});
+
+ // Auto-updater listeners
+ window.electronAPI.onUpdateAvailable((data) => {
+ state.update.available = true;
+ state.update.downloaded = false;
+ state.update.version = (data && data.version) ? data.version : null;
+ render();
+ });
+
+ window.electronAPI.onUpdateDownloaded((data) => {
+ state.update.downloaded = true;
+ state.update.version = (data && data.version) ? data.version : state.update.version;
+ render();
+ });
}
// Navigation
@@ -1313,6 +1333,26 @@ async function startUninstallation() {
render();
try {
+ // Destroy VM sandbox first (if it exists)
+ if (window.electronAPI.vmDestroy && state.uninstall.config?.vmInfo) {
+ state.uninstall.currentTask = 'Destroying virtual disk sandbox...';
+ render();
+ try {
+ const vmResult = await window.electronAPI.vmDestroy({
+ installPath: state.uninstall.installPath,
+ vmInfo: state.uninstall.config.vmInfo,
+ exportDataPath: state.uninstall.preserveSettings ? null : null
+ });
+ if (vmResult.success) {
+ state.uninstall.completedTasks.push({ step: 'VM sandbox removed', detail: vmResult.message || 'Virtual disk deleted' });
+ render();
+ }
+ } catch (vmErr) {
+ console.warn('VM destroy failed (non-fatal):', vmErr.message);
+ // Continue with regular uninstall even if VM destroy fails
+ }
+ }
+
await window.electronAPI.runUninstallation({
installPath: state.uninstall.installPath,
preserveSettings: state.uninstall.preserveSettings,
diff --git a/status/js/language-selector.js b/status/js/language-selector.js
new file mode 100644
index 0000000..23d70d8
--- /dev/null
+++ b/status/js/language-selector.js
@@ -0,0 +1,285 @@
+/**
+ * DC-077: i18n Language Selector
+ *
+ * Compact dropdown in the navbar (next to the theme toggle) that lets users switch
+ * the dashboard language between en / es / zh / ar / de.
+ *
+ * - Shows current language with flag emoji
+ * - Persists selection to localStorage('dashcaddy-language')
+ * - Sends selection to backend via POST /api/v1/config with { language: 'xx' }
+ * - Reloads the page on change so the new language takes effect
+ *
+ * Depends on: secureFetch() / postJSON() from globals.js (bundled in /dist/core.js).
+ */
+(function () {
+ 'use strict';
+
+ const STORAGE_KEY = 'dashcaddy-language';
+ const CONFIG_ENDPOINT = '/api/v1/config';
+
+ const LANGUAGES = [
+ { code: 'en', flag: '🇬🇧', label: 'English' },
+ { code: 'es', flag: '🇪🇸', label: 'Español' },
+ { code: 'zh', flag: '🇨🇳', label: '中文' },
+ { code: 'ar', flag: '🇸🇦', label: 'العربية' },
+ { code: 'de', flag: '🇩🇪', label: 'Deutsch' },
+ ];
+
+ const SUPPORTED = LANGUAGES.map(l => l.code);
+
+ function getCurrentLanguage() {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored && SUPPORTED.includes(stored)) return stored;
+ return 'en';
+ }
+
+ function langMeta(code) {
+ return LANGUAGES.find(l => l.code === code) || LANGUAGES[0];
+ }
+
+ // ===== Inject styles once =====
+ function injectStyles() {
+ if (document.getElementById('dc-lang-selector-styles')) return;
+ const style = document.createElement('style');
+ style.id = 'dc-lang-selector-styles';
+ style.textContent = `
+ .dc-lang-wrap {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ }
+ .dc-lang-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 14px;
+ border-radius: 6px;
+ font-size: 0.82rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+ background: var(--card-base);
+ color: var(--accent);
+ border: 1px solid var(--border);
+ font-family: inherit;
+ }
+ .dc-lang-btn:hover {
+ background: color-mix(in srgb, var(--accent) 22%, transparent);
+ }
+ .dc-lang-btn .dc-lang-flag {
+ font-size: 1rem;
+ line-height: 1;
+ }
+ .dc-lang-btn .dc-lang-caret {
+ font-size: 0.6rem;
+ opacity: 0.7;
+ margin-left: 2px;
+ }
+ .dc-lang-menu {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ min-width: 160px;
+ background: var(--card-base, #1e1e2e);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+ padding: 4px;
+ z-index: 10000;
+ display: none;
+ }
+ .dc-lang-menu.open {
+ display: block;
+ }
+ .dc-lang-option {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.85rem;
+ color: var(--fg);
+ transition: background 0.15s ease;
+ white-space: nowrap;
+ }
+ .dc-lang-option:hover {
+ background: color-mix(in srgb, var(--accent) 15%, transparent);
+ }
+ .dc-lang-option.active {
+ background: color-mix(in srgb, var(--accent) 25%, transparent);
+ color: var(--accent);
+ font-weight: 600;
+ }
+ .dc-lang-option .dc-lang-flag {
+ font-size: 1.1rem;
+ line-height: 1;
+ }
+ .dc-lang-option .dc-lang-check {
+ margin-left: auto;
+ opacity: 0;
+ font-size: 0.8rem;
+ }
+ .dc-lang-option.active .dc-lang-check {
+ opacity: 1;
+ }
+ .dc-lang-label-sm {
+ background: none !important;
+ border: none !important;
+ color: var(--muted);
+ font-size: 0.7rem;
+ cursor: pointer;
+ padding: 2px 6px;
+ font-family: inherit;
+ opacity: 0.8;
+ text-align: center;
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ function buildMenu(current) {
+ const menu = document.createElement('div');
+ menu.className = 'dc-lang-menu';
+ menu.setAttribute('role', 'menu');
+
+ for (const lang of LANGUAGES) {
+ const opt = document.createElement('div');
+ opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : '');
+ opt.setAttribute('role', 'menuitemradio');
+ opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false');
+ opt.dataset.lang = lang.code;
+ opt.innerHTML =
+ '' + lang.flag + '' +
+ '' + lang.label + '' +
+ '✓';
+ menu.appendChild(opt);
+ }
+ return menu;
+ }
+
+ async function selectLanguage(code) {
+ if (!SUPPORTED.includes(code) || code === getCurrentLanguage()) return;
+ // Persist locally immediately for instant reload
+ localStorage.setItem(STORAGE_KEY, code);
+
+ // Best-effort backend sync — don't block the reload on failure
+ try {
+ if (typeof postJSON === 'function') {
+ await postJSON(CONFIG_ENDPOINT, { language: code });
+ } else if (typeof secureFetch === 'function') {
+ await secureFetch(CONFIG_ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ language: code }),
+ });
+ }
+ } catch (err) {
+ // Non-fatal: localStorage already holds the preference
+ console.warn('[LanguageSelector] backend sync failed:', err);
+ }
+
+ // Reload so the new language takes effect across the dashboard
+ window.location.reload();
+ }
+
+ function init() {
+ injectStyles();
+
+ const current = getCurrentLanguage();
+ const meta = langMeta(current);
+
+ const wrap = document.createElement('div');
+ wrap.className = 'dc-lang-wrap';
+
+ const btn = document.createElement('button');
+ btn.className = 'dc-lang-btn';
+ btn.type = 'button';
+ btn.setAttribute('aria-label', 'Select language');
+ btn.setAttribute('aria-haspopup', 'true');
+ btn.setAttribute('aria-expanded', 'false');
+ btn.title = 'Switch language';
+ btn.innerHTML =
+ '' + meta.flag + '' +
+ '' + current.toUpperCase() + '' +
+ '▼';
+
+ const menu = buildMenu(current);
+
+ // Small label beneath, matching the "Customize Theme" link style
+ const label = document.createElement('span');
+ label.className = 'dc-lang-label-sm';
+ label.textContent = 'Language';
+
+ wrap.appendChild(btn);
+ wrap.appendChild(label);
+ wrap.appendChild(menu);
+
+ // Toggle menu open/closed
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const isOpen = menu.classList.toggle('open');
+ btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
+ });
+
+ // Option clicks
+ menu.addEventListener('click', (e) => {
+ const opt = e.target.closest('.dc-lang-option');
+ if (!opt) return;
+ const code = opt.dataset.lang;
+ menu.classList.remove('open');
+ btn.setAttribute('aria-expanded', 'false');
+ selectLanguage(code);
+ });
+
+ // Close when clicking outside
+ document.addEventListener('click', (e) => {
+ if (!wrap.contains(e.target)) {
+ menu.classList.remove('open');
+ btn.setAttribute('aria-expanded', 'false');
+ }
+ });
+
+ // Close on Escape
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ menu.classList.remove('open');
+ btn.setAttribute('aria-expanded', 'false');
+ }
+ });
+
+ return wrap;
+ }
+
+ // Expose for programmatic use / testing
+ window.DCLanguageSelector = {
+ getCurrentLanguage,
+ selectLanguage,
+ LANGUAGES,
+ STORAGE_KEY,
+ };
+
+ // Auto-mount into the navbar next to the theme toggle.
+ // The dashboard loads core.js (globals) before this deferred script, but the
+ // navbar container is always present in the initial HTML.
+ function mount() {
+ const group = document.querySelector('.theme-toggle-group');
+ if (group && group.parentNode) {
+ // Insert immediately after the theme-toggle-group so it sits beside it
+ group.parentNode.insertBefore(init(), group.nextSibling);
+ return true;
+ }
+ return false;
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', mount);
+ } else {
+ mount();
+ }
+
+ console.log('[LanguageSelector] Module loaded — current language:', getCurrentLanguage());
+})();