From 8ac1937784338beb8f1c76c6ba6cd6dd7e12bbea Mon Sep 17 00:00:00 2001 From: Krystie Date: Thu, 13 Aug 2026 02:49:25 -0700 Subject: [PATCH] fix: recursive data nesting guard + VM destroy in uninstall wizard - Cleaned 242MB of recursive data/data/data/ nesting - Added nesting-guard.js: auto-detects and removes recursive duplicates at startup - Wired VM sandbox cleanup into uninstall wizard (calls vmDestroy before regular uninstall) - Container stats, health data, and VM disk all cleaned on uninstall --- dashcaddy-api/src/app.js | 1 + dashcaddy-api/src/utilities/i18n.js | 38 + dashcaddy-api/src/utilities/nesting-guard.js | 41 + dashcaddy-installer/package-lock.json | 105 +- dashcaddy-installer/package.json | 9 +- dashcaddy-installer/package.json.bak | 104 ++ dashcaddy-installer/src/main/index.js | 102 ++ dashcaddy-installer/src/main/index.js.bak | 1062 ++++++++++++++++++ dashcaddy-installer/src/preload/index.js | 10 + dashcaddy-installer/src/renderer/wizard.js | 40 + status/js/language-selector.js | 285 +++++ 11 files changed, 1789 insertions(+), 8 deletions(-) create mode 100644 dashcaddy-api/src/utilities/nesting-guard.js create mode 100644 dashcaddy-installer/package.json.bak create mode 100644 dashcaddy-installer/src/main/index.js.bak create mode 100644 status/js/language-selector.js 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()); +})();