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:
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
Generated
+98
-7
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
})();
|
||||
Reference in New Issue
Block a user