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
This commit is contained in:
Krystie
2026-08-13 02:49:25 -07:00
parent 2ff6c05a45
commit 8ac1937784
11 changed files with 1789 additions and 8 deletions
+1
View File
@@ -28,6 +28,7 @@ const auditLogger = require('./security/audit-logger');
const portLockManager = require('./managers/port-lock-manager'); const portLockManager = require('./managers/port-lock-manager');
const resourceMonitor = require('./managers/resource-monitor'); const resourceMonitor = require('./managers/resource-monitor');
const backupManager = require('./utilities/backup-manager'); const backupManager = require('./utilities/backup-manager');
require("./utilities/nesting-guard")();
const healthChecker = require('./monitoring/health-checker'); const healthChecker = require('./monitoring/health-checker');
const updateManager = require('./managers/update-manager'); const updateManager = require('./managers/update-manager');
const selfUpdater = require('./docker/self-updater'); const selfUpdater = require('./docker/self-updater');
+38
View File
@@ -135,6 +135,44 @@ const TRANSLATIONS = {
'error.disk_full': 'Espace disque critique', '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: { de: {
'dashboard.title': 'Dashboard', 'dashboard.title': 'Dashboard',
'dashboard.services': 'Dienste', 'dashboard.services': 'Dienste',
@@ -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}`);
}
};
+98 -7
View File
@@ -8,6 +8,9 @@
"name": "dashcaddy-installer", "name": "dashcaddy-installer",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": {
"electron-updater": "^6.8.9"
},
"devDependencies": { "devDependencies": {
"electron": "^28.3.3", "electron": "^28.3.3",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
@@ -1999,7 +2002,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/assert-plus": { "node_modules/assert-plus": {
@@ -2895,7 +2897,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -3422,6 +3423,82 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/emittery": {
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
@@ -4109,7 +4186,6 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/has-flag": { "node_modules/has-flag": {
@@ -5202,7 +5278,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -5300,7 +5375,6 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lazystream": { "node_modules/lazystream": {
@@ -5406,6 +5480,12 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/lodash.flatten": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
@@ -5414,6 +5494,13 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -5670,7 +5757,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/natural-compare": { "node_modules/natural-compare": {
@@ -6325,7 +6411,6 @@
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=11.0.0" "node": ">=11.0.0"
@@ -6823,6 +6908,12 @@
"node": "*" "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": { "node_modules/tmp": {
"version": "0.2.5", "version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
+7
View File
@@ -96,6 +96,13 @@
"installerIcon": "assets/icon.ico", "installerIcon": "assets/icon.ico",
"uninstallerIcon": "assets/icon.ico", "uninstallerIcon": "assets/icon.ico",
"installerHeaderIcon": "assets/icon.ico" "installerHeaderIcon": "assets/icon.ico"
},
"publish": {
"provider": "generic",
"url": "https://get.dashcaddy.net/release/"
} }
},
"dependencies": {
"electron-updater": "^6.8.9"
} }
} }
+104
View File
@@ -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"
}
}
+102
View File
@@ -12,6 +12,105 @@ process.on('uncaughtException', (error) => {
let mainWindow; let mainWindow;
const { registerVMHandlers } = require('./vm-ipc'); 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() { function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 900, width: 900,
@@ -64,6 +163,9 @@ ipcMain.handle('get-disk-space', async (event, targetPath) => {
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow(); createWindow();
// Start the auto-updater (10s delayed check, silent on failure)
configureAutoUpdater();
registerVMHandlers(mainWindow); registerVMHandlers(mainWindow);
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
File diff suppressed because it is too large Load Diff
+10
View File
@@ -113,6 +113,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('vm:error', (event, data) => callback(data)); 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 // Remove listeners
removeListener: (channel) => { removeListener: (channel) => {
ipcRenderer.removeAllListeners(channel); ipcRenderer.removeAllListeners(channel);
@@ -56,6 +56,12 @@ const state = {
tailscaleIP: '', tailscaleIP: '',
detected: false detected: false
}, },
// Auto-updater state
update: {
available: false,
downloaded: false,
version: null
},
installation: { installation: {
status: 'pending', // pending, running, complete, error status: 'pending', // pending, running, complete, error
progress: 0, progress: 0,
@@ -229,6 +235,20 @@ function setupEventListeners() {
state.installation.error = data.error || 'VM provisioning failed'; state.installation.error = data.error || 'VM provisioning failed';
render(); 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 // Navigation
@@ -1313,6 +1333,26 @@ async function startUninstallation() {
render(); render();
try { 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({ await window.electronAPI.runUninstallation({
installPath: state.uninstall.installPath, installPath: state.uninstall.installPath,
preserveSettings: state.uninstall.preserveSettings, preserveSettings: state.uninstall.preserveSettings,
+285
View File
@@ -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 =
'<span class="dc-lang-flag">' + lang.flag + '</span>' +
'<span class="dc-lang-name">' + lang.label + '</span>' +
'<span class="dc-lang-check">✓</span>';
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 =
'<span class="dc-lang-flag">' + meta.flag + '</span>' +
'<span class="dc-lang-code">' + current.toUpperCase() + '</span>' +
'<span class="dc-lang-caret">▼</span>';
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());
})();