diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json
index 2357cda6..3851fb1a 100644
--- a/web-nodejs/lang/en.json
+++ b/web-nodejs/lang/en.json
@@ -2887,6 +2887,40 @@
"server_downloaded": "Server binary downloaded",
"server_download_failed": "Server binary download failed",
"server_download_note": "A pre-built binary will be downloaded from GitHub Releases. This usually takes less than a minute.",
- "install_go_hint": "Install Go from https://go.dev/dl/ to enable compilation, or publish a release with pre-built binaries"
+ "install_go_hint": "Install Go from https://go.dev/dl/ to enable compilation, or publish a release with pre-built binaries",
+ "strategy_install_go": "Auto-install Go and compile",
+ "strategy_install_go_hint": "Downloads the official Go toolchain (~120 MB) into the console data directory, then compiles the server. Use this when Go is not installed on the host.",
+ "auto_strategy": "Auto",
+ "auto_strategy_hint": "The console will pick the best available method (download → compile → auto-install Go).",
+ "auto_available": "Auto-rebuild ready",
+ "toolchain_ready": "Toolchain installed",
+ "toolchain_will_install": "Go toolchain will be installed automatically",
+ "toolchain_downloading": "Downloading Go toolchain…",
+ "toolchain_failed": "Go toolchain install failed",
+ "modal_title": "Installing update",
+ "modal_done_title": "Update complete",
+ "modal_close": "Close",
+ "modal_reload_now": "Reload console",
+ "phase_confirm": "Confirmation",
+ "phase_backup": "Backup",
+ "phase_console": "Console files",
+ "phase_server": "Go server",
+ "phase_restart": "Restart",
+ "phase_done": "Done",
+ "preparing": "Preparing…",
+ "backup_skipped": "Backup skipped (per user choice)",
+ "server_skipped": "Go server skipped",
+ "server_not_selected": "Go server not selected",
+ "server_processing": "Processing Go server update…",
+ "no_restart_needed": "No restart required",
+ "complete": "Update applied",
+ "complete_summary": "The update has been applied successfully.",
+ "console_will_restart": "Console process will restart now…",
+ "restart_complete_msg": "The console finished restarting. Click reload to load the new version. The page will reload automatically in 8 seconds.",
+ "refresh_recommended": "Static assets changed — reload the page to see the new UI.",
+ "confirm_title": "Apply update",
+ "confirm_with_backup": "A pre-update backup will be created automatically.",
+ "confirm_no_backup": "No backup will be created (proceed at your own risk).",
+ "install": "Install"
}
}
diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json
index 9aee9aee..254274c6 100644
--- a/web-nodejs/lang/pl.json
+++ b/web-nodejs/lang/pl.json
@@ -2887,6 +2887,40 @@
"server_downloaded": "Binarka serwera pobrana",
"server_download_failed": "Pobieranie binarki serwera nie powiodło się",
"server_download_note": "Gotowa binarka zostanie pobrana z GitHub Releases. Zwykle trwa to mniej niż minutę.",
- "install_go_hint": "Zainstaluj Go z https://go.dev/dl/ aby włączyć kompilację lub opublikuj wydanie z gotowymi binarkami"
+ "install_go_hint": "Zainstaluj Go z https://go.dev/dl/ aby włączyć kompilację lub opublikuj wydanie z gotowymi binarkami",
+ "strategy_install_go": "Pobierz Go i skompiluj automatycznie",
+ "strategy_install_go_hint": "Pobiera oficjalny zestaw Go (~120 MB) do katalogu danych konsoli i kompiluje serwer. Wybierz tę opcję, jeśli Go nie jest zainstalowane na hoście.",
+ "auto_strategy": "Auto",
+ "auto_strategy_hint": "Konsola wybierze najlepszą dostępną metodę (pobranie → kompilacja → automatyczna instalacja Go).",
+ "auto_available": "Automatyczna przebudowa gotowa",
+ "toolchain_ready": "Zestaw zainstalowany",
+ "toolchain_will_install": "Zestaw Go zostanie zainstalowany automatycznie",
+ "toolchain_downloading": "Pobieranie zestawu Go…",
+ "toolchain_failed": "Instalacja zestawu Go nie powiodła się",
+ "modal_title": "Instalowanie aktualizacji",
+ "modal_done_title": "Aktualizacja zakończona",
+ "modal_close": "Zamknij",
+ "modal_reload_now": "Odśwież konsolę",
+ "phase_confirm": "Potwierdzenie",
+ "phase_backup": "Kopia zapasowa",
+ "phase_console": "Pliki konsoli",
+ "phase_server": "Serwer Go",
+ "phase_restart": "Restart",
+ "phase_done": "Gotowe",
+ "preparing": "Przygotowywanie…",
+ "backup_skipped": "Kopia zapasowa pominięta (wybór operatora)",
+ "server_skipped": "Serwer Go pominięty",
+ "server_not_selected": "Serwer Go nie wybrany",
+ "server_processing": "Przetwarzanie aktualizacji serwera Go…",
+ "no_restart_needed": "Restart nie jest wymagany",
+ "complete": "Aktualizacja zastosowana",
+ "complete_summary": "Aktualizacja została zastosowana pomyślnie.",
+ "console_will_restart": "Proces konsoli zostanie teraz zrestartowany…",
+ "restart_complete_msg": "Konsola zakończyła restart. Kliknij Odśwież, aby załadować nową wersję. Strona zostanie odświeżona automatycznie za 8 sekund.",
+ "refresh_recommended": "Zmieniły się statyczne zasoby — odśwież stronę, aby zobaczyć nowy interfejs.",
+ "confirm_title": "Zastosuj aktualizację",
+ "confirm_with_backup": "Kopia zapasowa zostanie utworzona automatycznie.",
+ "confirm_no_backup": "Kopia zapasowa nie zostanie utworzona (na własne ryzyko).",
+ "install": "Zainstaluj"
}
}
diff --git a/web-nodejs/public/css/pages.css b/web-nodejs/public/css/pages.css
index 8d82f8b0..6aebdd09 100644
--- a/web-nodejs/public/css/pages.css
+++ b/web-nodejs/public/css/pages.css
@@ -1497,6 +1497,94 @@
margin-top: var(--space-xs);
}
+/* Modal-based update progress */
+.update-progress-modal {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+ min-width: 480px;
+}
+.update-progress-modal .update-progress-bar {
+ height: 8px;
+ background: var(--bg-tertiary, rgba(255,255,255,0.06));
+ border-radius: 4px;
+ overflow: hidden;
+}
+.update-progress-modal .update-progress-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--primary), var(--accent, var(--primary)));
+ border-radius: 4px;
+ transition: width 0.4s ease;
+ width: 0%;
+}
+.update-phases {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.update-phase {
+ display: grid;
+ grid-template-columns: 28px 1fr 28px;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: var(--bg-tertiary, rgba(255,255,255,0.03));
+ border: 1px solid var(--border, rgba(255,255,255,0.06));
+ font-size: var(--font-size-sm);
+}
+.update-phase-icon {
+ font-size: 20px;
+ color: var(--text-secondary);
+}
+.update-phase-label {
+ color: var(--text-primary);
+}
+.update-phase-state .material-icons {
+ font-size: 20px;
+ color: var(--text-tertiary, var(--text-secondary));
+}
+.update-phase-state[data-state="active"] .material-icons {
+ color: var(--primary);
+}
+.update-phase-state[data-state="done"] .material-icons {
+ color: var(--success, #22c55e);
+}
+.update-phase-state[data-state="error"] .material-icons {
+ color: var(--danger, #ef4444);
+}
+.update-phase-state[data-state="skipped"] .material-icons {
+ color: var(--text-tertiary, rgba(255,255,255,0.35));
+ opacity: 0.7;
+}
+.update-phase-state .spinning {
+ animation: update-spin 1.2s linear infinite;
+}
+@keyframes update-spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+.update-progress-detail {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ min-height: 1.4em;
+}
+.update-progress-log {
+ background: var(--bg-secondary, rgba(0,0,0,0.25));
+ color: var(--text-secondary);
+ font-family: var(--font-mono, ui-monospace, monospace);
+ font-size: 11px;
+ line-height: 1.45;
+ padding: 8px 10px;
+ border-radius: 6px;
+ border: 1px solid var(--border, rgba(255,255,255,0.06));
+ max-height: 160px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0;
+}
+
.update-backups {
display: flex;
flex-direction: column;
diff --git a/web-nodejs/public/js/settings.js b/web-nodejs/public/js/settings.js
index 906e5556..b2b0271f 100644
--- a/web-nodejs/public/js/settings.js
+++ b/web-nodejs/public/js/settings.js
@@ -1386,6 +1386,13 @@
${_('updates.strategy_compile')}
+
+
${_('updates.strategy_install_go_hint')}
`;
@@ -1407,26 +1414,28 @@
const strategySection = document.getElementById('update-server-strategy');
const downloadRadio = document.getElementById('strategy-download');
const compileRadio = document.getElementById('strategy-compile');
+ const installGoRadio = document.getElementById('strategy-install-go');
const downloadBadge = document.getElementById('strategy-download-badge');
const compileBadge = document.getElementById('strategy-compile-badge');
+ const installGoBadge = document.getElementById('strategy-install-go-badge');
if (!statusEl || !toggleEl) return;
-
+
try {
const info = await Utils.api('/api/settings/updates/server-info');
- const hasGo = info.goAvailable;
+ const hasGo = !!info.goAvailable;
const prebuilt = info.prebuilt || {};
- const hasDownload = prebuilt.available;
- const canUpdate = hasGo || hasDownload;
-
- // Always enable the checkbox if at least one strategy is available
+ const hasDownload = !!prebuilt.available;
+ const canInstallGo = !!info.canInstallGo;
+ const vendoredReady = !!info.vendoredGoInstalled;
+
+ // Server rebuild is always offered: if no Go and no release, the
+ // "auto-install Go" path will take over.
+ const canUpdate = hasGo || hasDownload || canInstallGo;
toggleEl.disabled = !canUpdate;
- if (!canUpdate) toggleEl.checked = false;
-
- // Show strategy section when checkbox is enabled
+
if (strategySection && canUpdate) {
strategySection.style.display = '';
-
- // Show/enable strategy options based on availability
+
if (downloadRadio) {
downloadRadio.disabled = !hasDownload;
if (downloadBadge) {
@@ -1452,36 +1461,46 @@
}
}
}
-
+ if (installGoRadio && installGoBadge) {
+ installGoRadio.disabled = false;
+ if (vendoredReady) {
+ installGoBadge.className = 'badge badge-success badge-sm';
+ installGoBadge.textContent = _('updates.toolchain_ready');
+ } else {
+ installGoBadge.className = 'badge badge-info badge-sm';
+ installGoBadge.textContent = _('updates.auto_strategy');
+ }
+ }
+
// Auto-select the best available strategy
if (hasDownload && downloadRadio) downloadRadio.checked = true;
else if (hasGo && compileRadio) compileRadio.checked = true;
+ else if (installGoRadio) installGoRadio.checked = true;
}
-
+
// Status badge
- if (canUpdate) {
- if (hasGo && hasDownload) {
- statusEl.className = 'badge badge-success badge-sm';
- statusEl.textContent = _('updates.both_available');
- } else if (hasDownload) {
- statusEl.className = 'badge badge-info badge-sm';
- statusEl.textContent = _('updates.download_available');
- } else {
- statusEl.className = 'badge badge-success badge-sm';
- statusEl.textContent = info.goVersion ? info.goVersion.replace('go version ', '') : 'Go';
- }
+ if (hasGo && hasDownload) {
+ statusEl.className = 'badge badge-success badge-sm';
+ statusEl.textContent = _('updates.both_available');
+ } else if (hasDownload) {
+ statusEl.className = 'badge badge-info badge-sm';
+ statusEl.textContent = _('updates.download_available');
+ } else if (hasGo) {
+ statusEl.className = 'badge badge-success badge-sm';
+ statusEl.textContent = info.goVersion ? info.goVersion.replace('go version ', '') : 'Go';
} else {
- statusEl.className = 'badge badge-danger badge-sm';
- statusEl.textContent = _('updates.no_method');
+ statusEl.className = 'badge badge-info badge-sm';
+ statusEl.textContent = _('updates.auto_available');
}
-
+
// Info line
if (infoEl) {
const parts = [];
if (info.binaryPath) parts.push(`Binary: ${info.binaryPath}`);
if (info.sourcePresent) parts.push('Source: present');
if (hasDownload && prebuilt.releaseName) parts.push(`Release: ${prebuilt.releaseName}`);
- if (!hasGo && !hasDownload) parts.push(_('updates.install_go_hint'));
+ if (info.goSource && info.goSource !== 'path') parts.push(`Go: ${info.goSource}`);
+ if (!hasGo && !hasDownload) parts.push(_('updates.toolchain_will_install'));
infoEl.textContent = parts.join(' · ');
}
} catch (_e) {
@@ -1491,124 +1510,314 @@
}
}
+ // ---------- Update progress modal ----------
+
+ const UPDATE_PHASES = [
+ { id: 'confirm', icon: 'task_alt', key: 'updates.phase_confirm' },
+ { id: 'backup', icon: 'inventory', key: 'updates.phase_backup' },
+ { id: 'console', icon: 'cloud_download', key: 'updates.phase_console' },
+ { id: 'server', icon: 'memory', key: 'updates.phase_server' },
+ { id: 'restart', icon: 'restart_alt', key: 'updates.phase_restart' },
+ { id: 'done', icon: 'check_circle', key: 'updates.phase_done' }
+ ];
+
+ function buildUpdateModalContent() {
+ const items = UPDATE_PHASES.map(p => `
+
+ ${p.icon}
+ ${_(p.key)}
+
+ radio_button_unchecked
+
+
+ `).join('');
+ return `
+
+
+
${items}
+
${_('updates.preparing')}
+
+
+ `;
+ }
+
+ function setUpdatePhase(phaseId, state, detail) {
+ // state: 'pending' | 'active' | 'done' | 'error' | 'skipped'
+ const stateEl = document.querySelector(`[data-phase-state="${phaseId}"]`);
+ if (stateEl) {
+ const icons = {
+ pending: 'radio_button_unchecked',
+ active: 'sync',
+ done: 'check_circle',
+ error: 'error',
+ skipped: 'remove_circle_outline'
+ };
+ const cls = {
+ pending: '',
+ active: 'spinning',
+ done: '',
+ error: '',
+ skipped: ''
+ };
+ stateEl.innerHTML = `${icons[state] || 'help'}`;
+ stateEl.dataset.state = state;
+ }
+ if (detail) {
+ const det = document.getElementById('update-modal-detail');
+ if (det) det.textContent = detail;
+ }
+ // Advance progress bar based on phase index
+ const idx = UPDATE_PHASES.findIndex(p => p.id === phaseId);
+ if (idx >= 0) {
+ const pct = state === 'done' ? Math.round(((idx + 1) / UPDATE_PHASES.length) * 100)
+ : state === 'active' ? Math.round((idx / UPDATE_PHASES.length) * 100)
+ : null;
+ const bar = document.getElementById('update-modal-bar');
+ if (bar && pct !== null) bar.style.width = pct + '%';
+ }
+ }
+
+ function logUpdate(line) {
+ const log = document.getElementById('update-modal-log');
+ if (!log) return;
+ const ts = new Date().toLocaleTimeString();
+ log.textContent += `[${ts}] ${line}\n`;
+ log.scrollTop = log.scrollHeight;
+ }
+
async function installUpdate() {
const installBtn = document.getElementById('update-install-btn');
- const progressEl = document.getElementById('update-progress');
- const progressFill = document.getElementById('update-progress-fill');
- const progressText = document.getElementById('update-progress-text');
-
+
if (!_updateState.remoteSHA) {
Notifications.error(_('updates.no_version'));
return;
}
-
+
const includeServer = document.getElementById('update-include-server')?.checked || false;
const serverStrategy = includeServer
? (document.querySelector('input[name="server-strategy"]:checked')?.value || 'auto')
: null;
- const confirmMsg = includeServer
- ? _('updates.install_confirm') + '\n\n' + (serverStrategy === 'compile' ? _('updates.server_build_note') : _('updates.server_download_note'))
- : _('updates.install_confirm');
-
- if (!confirm(confirmMsg)) return;
-
+ const createBackup = document.getElementById('update-backup-toggle')?.checked ?? true;
+
+ // Pre-flight confirmation modal
+ let strategyNote = '';
+ if (includeServer) {
+ if (serverStrategy === 'compile') strategyNote = _('updates.server_build_note');
+ else if (serverStrategy === 'install-go') strategyNote = _('updates.strategy_install_go_hint');
+ else if (serverStrategy === 'download') strategyNote = _('updates.server_download_note');
+ else strategyNote = _('updates.auto_strategy_hint');
+ }
+ const confirmHtml = `
+ ${Utils.escapeHtml(_('updates.install_confirm'))}
+
+ - ${Utils.escapeHtml(createBackup ? _('updates.confirm_with_backup') : _('updates.confirm_no_backup'))}
+ ${includeServer ? `- ${Utils.escapeHtml(strategyNote || '')}
` : ''}
+
+ `;
+ const proceed = await new Promise((resolve) => {
+ window.Modal.show({
+ title: _('updates.confirm_title'),
+ content: confirmHtml,
+ buttons: [
+ { label: _('actions.cancel'), class: 'btn-secondary', onClick: () => { window.Modal.close(); resolve(false); } },
+ { label: _('updates.install'), class: 'btn-primary', icon: 'system_update', onClick: () => { window.Modal.close(); resolve(true); } }
+ ],
+ closable: true,
+ onClose: () => resolve(false)
+ });
+ });
+ if (!proceed) return;
+
if (installBtn) installBtn.disabled = true;
- if (progressEl) progressEl.style.display = '';
- if (progressFill) progressFill.style.width = '10%';
- if (progressText) progressText.textContent = _('updates.installing');
-
+
+ // Open progress modal (non-closable while running)
+ window.Modal.show({
+ title: _('updates.modal_title'),
+ content: buildUpdateModalContent(),
+ buttons: [],
+ closable: false,
+ size: 'large'
+ });
+
+ UPDATE_PHASES.forEach(p => setUpdatePhase(p.id, 'pending'));
+ setUpdatePhase('confirm', 'done');
+ if (createBackup) setUpdatePhase('backup', 'active', _('updates.creating_backup'));
+ else setUpdatePhase('backup', 'skipped', _('updates.backup_skipped'));
+ logUpdate(`Starting update to ${_updateState.remoteSHA.slice(0, 7)}…`);
+
try {
- const createBackup = document.getElementById('update-backup-toggle')?.checked ?? true;
const components = ['console', 'scripts'];
if (includeServer) components.push('server');
-
- if (progressFill) progressFill.style.width = '20%';
- if (progressText) progressText.textContent = createBackup ? _('updates.creating_backup') : _('updates.downloading');
-
+
+ // Console download phase indicator (we cannot stream backend
+ // progress today, so we just mark it active until response arrives)
+ setTimeout(() => {
+ setUpdatePhase('backup', 'done');
+ setUpdatePhase('console', 'active', _('updates.downloading'));
+ }, 800);
+
if (includeServer) {
- // Server build/download can take time — show an informative message
+ const serverDetailKey =
+ serverStrategy === 'install-go' ? 'updates.toolchain_downloading' :
+ serverStrategy === 'compile' ? 'updates.server_building' :
+ serverStrategy === 'download' ? 'updates.server_downloading' :
+ 'updates.server_processing';
setTimeout(() => {
- if (progressFill && progressFill.style.width !== '100%') {
- progressFill.style.width = '50%';
- if (progressText) progressText.textContent = serverStrategy === 'compile' ? _('updates.server_building') : _('updates.server_downloading');
- }
- }, 5000);
+ setUpdatePhase('console', 'done');
+ setUpdatePhase('server', 'active', _(serverDetailKey));
+ }, 4000);
}
-
+
const result = await Utils.api('/api/settings/updates/install', {
method: 'POST',
body: { remoteSHA: _updateState.remoteSHA, createBackup, components, serverStrategy: serverStrategy || 'auto' }
});
-
- if (progressFill) progressFill.style.width = '100%';
-
- let msg = `${_('updates.applied')}: ${result.applied?.length || 0}`;
- if (result.failed?.length) msg += `, ${_('updates.failed')}: ${result.failed.length}`;
- if (result.removed?.length) msg += `, ${_('updates.removed')}: ${result.removed.length}`;
-
- // Server build/deploy results
- if (result.serverBuild) {
- if (result.serverBuild.success) {
- if (result.serverBuild.method === 'download') {
- const sizeMB = result.serverBuild.size ? ` (${(result.serverBuild.size / (1024 * 1024)).toFixed(1)} MB)` : '';
- msg += `. ${_('updates.server_downloaded')}${sizeMB}`;
+
+ // Mark earlier phases done if not already
+ ['backup', 'console'].forEach(p => {
+ const st = document.querySelector(`[data-phase-state="${p}"]`)?.dataset?.state;
+ if (st !== 'done' && st !== 'skipped') setUpdatePhase(p, 'done');
+ });
+
+ // Server result
+ if (includeServer) {
+ if (result.toolchainInstall) {
+ if (result.toolchainInstall.success) {
+ logUpdate(`Go toolchain ready: ${result.toolchainInstall.version || ''}`.trim());
} else {
- const secs = Math.round((result.serverBuild.duration || 0) / 1000);
- msg += `. ${_('updates.server_built')}` + (secs ? ` (${secs}s)` : '');
+ logUpdate(`Go toolchain install failed: ${result.toolchainInstall.error || 'unknown'}`);
+ }
+ }
+ if (result.serverBuild) {
+ if (result.serverBuild.success) {
+ const ms = result.serverBuild.duration || 0;
+ const secs = ms ? Math.round(ms / 1000) : 0;
+ const sizeMB = result.serverBuild.size ? ` (${(result.serverBuild.size / (1024 * 1024)).toFixed(1)} MB)` : '';
+ const detail = result.serverBuild.method === 'download'
+ ? `${_('updates.server_downloaded')}${sizeMB}`
+ : `${_('updates.server_built')}${secs ? ` · ${secs}s` : ''}`;
+ setUpdatePhase('server', 'done', detail);
+ logUpdate(detail);
+ } else {
+ const detail = result.serverBuild.method === 'download'
+ ? _('updates.server_download_failed')
+ : _('updates.server_build_failed');
+ setUpdatePhase('server', 'error', detail);
+ logUpdate(`${detail}: ${result.serverBuild.error || ''}`);
}
} else {
- msg += `. ${result.serverBuild.method === 'download' ? _('updates.server_download_failed') : _('updates.server_build_failed')}`;
- if (result.serverBuild.error) Notifications.error(result.serverBuild.error);
+ setUpdatePhase('server', 'skipped', _('updates.server_skipped'));
}
- }
- if (result.serverDeploy) {
- if (result.serverDeploy.success) {
- msg += `. ${_('updates.server_deployed')}`;
- } else {
- msg += `. ${_('updates.server_deploy_failed')}`;
- if (result.serverDeploy.error) Notifications.error(result.serverDeploy.error);
+ if (result.serverDeploy && !result.serverDeploy.success) {
+ logUpdate(`Deploy failed: ${result.serverDeploy.error || ''}`);
}
- }
-
- if (result.needsConsoleRestart) {
- if (progressText) progressText.textContent = _('updates.restarting');
- Notifications.success(msg);
- setTimeout(() => pollServerRestart(), 3000);
} else {
- Notifications.success(msg);
- if (progressEl) setTimeout(() => { progressEl.style.display = 'none'; }, 5000);
+ setUpdatePhase('server', 'skipped', _('updates.server_not_selected'));
+ }
+
+ const applied = result.applied?.length || 0;
+ const failed = result.failed?.length || 0;
+ const removed = result.removed?.length || 0;
+ logUpdate(`${_('updates.applied')}: ${applied} · ${_('updates.failed')}: ${failed} · ${_('updates.removed')}: ${removed}`);
+
+ if (result.needsConsoleRestart) {
+ setUpdatePhase('restart', 'active', _('updates.restarting'));
+ logUpdate(_('updates.console_will_restart'));
+ setTimeout(() => pollConsoleRestart(), 2500);
+ } else {
+ setUpdatePhase('restart', 'skipped', _('updates.no_restart_needed'));
+ setUpdatePhase('done', 'done', _('updates.complete'));
+ showUpdateCompletionModal(result);
if (installBtn) installBtn.disabled = false;
}
} catch (error) {
+ const activePhase = UPDATE_PHASES.find(p => {
+ const st = document.querySelector(`[data-phase-state="${p.id}"]`)?.dataset?.state;
+ return st === 'active';
+ });
+ if (activePhase) setUpdatePhase(activePhase.id, 'error', error.message || _('updates.install_failed'));
+ logUpdate(`ERROR: ${error.message || error}`);
+
+ // Replace empty footer with a Close button so the user can dismiss
+ window.Modal.close();
+ await window.Modal.alert({
+ title: _('updates.install_failed'),
+ message: error.message || _('errors.server_error')
+ });
Notifications.error(error.message || _('updates.install_failed'));
- if (progressEl) progressEl.style.display = 'none';
if (installBtn) installBtn.disabled = false;
}
}
-
- function pollServerRestart() {
- const progressText = document.getElementById('update-progress-text');
+
+ function showUpdateCompletionModal(result) {
+ const lines = [];
+ lines.push(`${Utils.escapeHtml(_('updates.complete_summary'))}
`);
+ const stats = [
+ { label: _('updates.applied'), value: result.applied?.length || 0 },
+ { label: _('updates.failed'), value: result.failed?.length || 0 },
+ { label: _('updates.removed'), value: result.removed?.length || 0 }
+ ];
+ lines.push(`${stats.map(s => `- ${Utils.escapeHtml(s.label)}: ${s.value}
`).join('')}
`);
+ if (result.serverBuild?.success) {
+ const note = result.serverBuild.method === 'download' ? _('updates.server_downloaded') : _('updates.server_built');
+ lines.push(`${Utils.escapeHtml(note)}
`);
+ }
+ // Some updates require manual reload (e.g., static asset changes)
+ const needsReload = (result.applied || []).some(p => /\.(js|css|html|ejs)$/i.test(p));
+ if (needsReload) {
+ lines.push(`${Utils.escapeHtml(_('updates.refresh_recommended'))}
`);
+ }
+
+ window.Modal.close();
+ window.Modal.show({
+ title: _('updates.modal_done_title'),
+ content: lines.join(''),
+ buttons: [
+ { label: _('updates.modal_close'), class: 'btn-secondary', onClick: () => { window.Modal.close(); } },
+ { label: _('updates.modal_reload_now'), class: 'btn-primary', icon: 'refresh', onClick: () => { window.location.reload(); } }
+ ],
+ closable: true
+ });
+ }
+
+ function pollConsoleRestart() {
let attempts = 0;
const maxAttempts = 30;
-
const interval = setInterval(async () => {
attempts++;
- if (progressText) progressText.textContent = `${_('updates.restarting')} (${attempts}/${maxAttempts})`;
-
+ setUpdatePhase('restart', 'active', `${_('updates.restarting')} (${attempts}/${maxAttempts})`);
try {
- const resp = await fetch('/api/settings/info', { credentials: 'same-origin' });
+ const resp = await fetch('/api/settings/info?_=' + Date.now(), { credentials: 'same-origin' });
if (resp.ok) {
clearInterval(interval);
- Notifications.success(_('updates.restart_complete'));
- setTimeout(() => window.location.reload(), 1000);
+ setUpdatePhase('restart', 'done', _('updates.restart_complete'));
+ setUpdatePhase('done', 'done', _('updates.complete'));
+ logUpdate(_('updates.restart_complete'));
+
+ // Final modal: tell operator to refresh
+ window.Modal.close();
+ window.Modal.show({
+ title: _('updates.modal_done_title'),
+ content: `${Utils.escapeHtml(_('updates.restart_complete_msg'))}
`,
+ buttons: [
+ { label: _('updates.modal_close'), class: 'btn-secondary', onClick: () => { window.Modal.close(); } },
+ { label: _('updates.modal_reload_now'), class: 'btn-primary', icon: 'refresh', onClick: () => { window.location.reload(); } }
+ ],
+ closable: true
+ });
+
+ // Auto-reload after a short grace period so operators do
+ // not have to click — gives them time to read the modal.
+ setTimeout(() => { window.location.reload(); }, 8000);
+ return;
}
- } catch {
+ } catch (_e) {
// Server still down, keep polling
}
-
if (attempts >= maxAttempts) {
clearInterval(interval);
- if (progressText) progressText.textContent = _('updates.restart_timeout');
+ setUpdatePhase('restart', 'error', _('updates.restart_timeout'));
+ logUpdate(_('updates.restart_timeout'));
}
}, 2000);
}
diff --git a/web-nodejs/routes/settings.routes.js b/web-nodejs/routes/settings.routes.js
index be092141..25a6835e 100644
--- a/web-nodejs/routes/settings.routes.js
+++ b/web-nodejs/routes/settings.routes.js
@@ -544,6 +544,32 @@ router.get('/api/settings/updates/server-info', requireAuth, requirePermission('
}
});
+/**
+ * POST /api/settings/updates/install-go - Download & install the Go toolchain
+ * into data/go-toolchain/ so the console can compile the Go server even when
+ * Go is not present on the host. Idempotent.
+ */
+router.post('/api/settings/updates/install-go', requireAuth, requirePermission('server.config'), async (req, res) => {
+ req.setTimeout(600000);
+ res.setTimeout(600000);
+ try {
+ const result = await updateService.installGoToolchain();
+ await db.logAction(
+ req.session?.userId,
+ 'system_install_go',
+ result.success ? `Installed Go toolchain (${result.version})` : `Go toolchain install failed: ${result.error}`,
+ req.ip
+ );
+ if (!result.success) {
+ return res.status(500).json({ success: false, error: result.error || 'Toolchain install failed' });
+ }
+ res.json({ success: true, data: { version: result.version, binPath: result.binPath } });
+ } catch (err) {
+ console.error('Go toolchain install error:', err);
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
/**
* GET /api/settings/updates/check - Check for available updates
*/
diff --git a/web-nodejs/services/updateService.js b/web-nodejs/services/updateService.js
index 3226922d..e33676d6 100644
--- a/web-nodejs/services/updateService.js
+++ b/web-nodejs/services/updateService.js
@@ -246,15 +246,76 @@ function copyDirRecursive(src, dest) {
/**
* Check if Go toolchain is available.
- * @returns {{ available: boolean, version: string|null }}
+ * Searches PATH first, then well-known install locations (snap, tarball,
+ * Homebrew, vendored toolchain). Returns the absolute path so callers can
+ * `exec` it even when the console process started without a complete PATH.
+ *
+ * @returns {{ available: boolean, version: string|null, binPath: string|null, source: string|null }}
*/
function checkGoAvailable() {
+ // 1. Try the regular PATH lookup first
try {
const version = execSync('go version', { timeout: 10000, stdio: 'pipe' }).toString().trim();
- return { available: true, version };
- } catch (_e) {
- return { available: false, version: null };
+ let binPath = null;
+ try {
+ binPath = execSync(IS_WINDOWS ? 'where go' : 'command -v go', {
+ timeout: 5000, stdio: 'pipe'
+ }).toString().split(/\r?\n/)[0].trim() || null;
+ } catch (_e) { /* ok */ }
+ return { available: true, version, binPath: binPath || 'go', source: 'path' };
+ } catch (_e) { /* fall through */ }
+
+ // 2. Scan well-known install locations
+ const home = process.env.HOME || process.env.USERPROFILE || '';
+ const localGoBin = path.join(config.dataDir, 'go-toolchain', 'go', 'bin', IS_WINDOWS ? 'go.exe' : 'go');
+ const candidates = IS_WINDOWS
+ ? [
+ localGoBin,
+ 'C:\\Go\\bin\\go.exe',
+ 'C:\\Program Files\\Go\\bin\\go.exe',
+ path.join(home, 'go', 'bin', 'go.exe'),
+ path.join(home, '.local', 'go', 'bin', 'go.exe')
+ ]
+ : [
+ localGoBin,
+ '/usr/local/go/bin/go',
+ '/snap/bin/go',
+ '/opt/go/bin/go',
+ '/usr/lib/go/bin/go',
+ '/usr/lib/go-1.22/bin/go',
+ '/usr/lib/go-1.23/bin/go',
+ '/usr/lib/go-1.24/bin/go',
+ path.join(home, 'go', 'bin', 'go'),
+ path.join(home, '.local', 'go', 'bin', 'go'),
+ '/opt/homebrew/bin/go',
+ '/usr/local/bin/go'
+ ];
+
+ for (const candidate of candidates) {
+ if (!candidate || !fs.existsSync(candidate)) continue;
+ try {
+ const version = execSync(`"${candidate}" version`, {
+ timeout: 10000, stdio: 'pipe'
+ }).toString().trim();
+ const source = candidate === localGoBin ? 'vendored' : 'system';
+ return { available: true, version, binPath: candidate, source };
+ } catch (_e) { /* candidate broken, try next */ }
}
+
+ return { available: false, version: null, binPath: null, source: null };
+}
+
+/**
+ * Wrap an exec environment so a manually located `go` binary is on PATH.
+ */
+function buildEnvWithGo(goBinPath) {
+ const env = { ...process.env, CGO_ENABLED: '0' };
+ if (goBinPath && goBinPath !== 'go') {
+ const goBinDir = path.dirname(goBinPath);
+ const sep = IS_WINDOWS ? ';' : ':';
+ env.PATH = goBinDir + sep + (env.PATH || '');
+ }
+ return env;
}
/**
@@ -392,31 +453,200 @@ async function buildGoServer() {
const binaryName = IS_WINDOWS ? 'betterdesk-server.exe' : 'betterdesk-server';
const outputPath = path.join(serverDir, binaryName);
const start = Date.now();
- const buildEnv = { ...process.env, CGO_ENABLED: '0' };
+ const goBin = goCheck.binPath || 'go';
+ const buildEnv = buildEnvWithGo(goBin);
+ // Quote when path contains spaces (Windows "Program Files")
+ const goCmd = /\s/.test(goBin) ? `"${goBin}"` : goBin;
try {
- await execPromise('go mod download', {
+ await execPromise(`${goCmd} mod download`, {
cwd: serverDir,
timeout: 120000,
env: buildEnv
});
await execPromise(
- `go build -trimpath -ldflags="-s -w" -o "${binaryName}" .`,
- { cwd: serverDir, timeout: 300000, env: buildEnv }
+ `${goCmd} build -trimpath -ldflags="-s -w" -o "${binaryName}" .`,
+ { cwd: serverDir, timeout: 600000, env: buildEnv }
);
if (!fs.existsSync(outputPath)) {
return { success: false, binaryPath: null, error: 'Build completed but binary not found' };
}
- return { success: true, binaryPath: outputPath, duration: Date.now() - start };
+ return { success: true, binaryPath: outputPath, duration: Date.now() - start, goVersion: goCheck.version, goSource: goCheck.source };
} catch (err) {
const stderr = (err.stderr || '').toString().slice(0, 500);
return { success: false, binaryPath: null, error: `Build failed: ${stderr || err.message}`.trim() };
}
}
+// ---------- Vendored Go toolchain bootstrap ----------
+
+const GO_TOOLCHAIN_DIR = path.join(config.dataDir, 'go-toolchain');
+// Minimum Go version required to build the server (must match
+// betterdesk-server/go.mod). The actual point release is selected at
+// install time from the live go.dev manifest.
+const GO_MIN_VERSION = '1.23.0';
+
+function getToolchainKey() {
+ const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
+ if (IS_WINDOWS) return { os: 'windows', arch, kind: 'archive' };
+ if (process.platform === 'darwin') return { os: 'darwin', arch, kind: 'archive' };
+ return { os: 'linux', arch, kind: 'archive' };
+}
+
+/**
+ * Compare semantic-ish Go versions ("go1.23.4" or "1.23.4").
+ * Returns >0 if a > b, 0 if equal, <0 if a < b.
+ */
+function compareGoVersion(a, b) {
+ const norm = (v) => String(v || '').replace(/^go/, '').split(/[^\d]+/).map(Number).filter(Number.isFinite);
+ const aa = norm(a), bb = norm(b);
+ const len = Math.max(aa.length, bb.length);
+ for (let i = 0; i < len; i++) {
+ const x = aa[i] || 0, y = bb[i] || 0;
+ if (x !== y) return x - y;
+ }
+ return 0;
+}
+
+/**
+ * Download a binary file via HTTPS (with up to 5 redirects).
+ * @returns {Promise}
+ */
+function httpsDownload(url, redirects = 5) {
+ return new Promise((resolve, reject) => {
+ if (redirects < 0) return reject(new Error('Too many redirects'));
+ const req = https.get(url, { headers: { 'User-Agent': USER_AGENT } }, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ res.resume();
+ return resolve(httpsDownload(res.headers.location, redirects - 1));
+ }
+ if (res.statusCode !== 200) {
+ res.resume();
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
+ }
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => resolve(Buffer.concat(chunks)));
+ res.on('error', reject);
+ });
+ req.on('error', reject);
+ req.setTimeout(600000, () => req.destroy(new Error('Download timeout')));
+ });
+}
+
+/**
+ * Resolve the latest stable Go release for this OS/arch by querying
+ * https://go.dev/dl/?mode=json. Returns the asset metadata including
+ * the canonical SHA-256 sum so the download can be verified securely.
+ */
+async function resolveGoRelease() {
+ const key = getToolchainKey();
+ const data = await httpsDownload('https://go.dev/dl/?mode=json');
+ const releases = JSON.parse(data.toString('utf8'));
+ if (!Array.isArray(releases) || !releases.length) {
+ throw new Error('go.dev manifest empty');
+ }
+ // Pick the highest stable release that meets GO_MIN_VERSION.
+ const stable = releases
+ .filter(r => r.stable && compareGoVersion(r.version, GO_MIN_VERSION) >= 0)
+ .sort((a, b) => compareGoVersion(b.version, a.version));
+ const target = stable[0] || releases[0];
+ const ext = key.os === 'windows' ? 'zip' : 'tar.gz';
+ const file = (target.files || []).find(f =>
+ f.os === key.os && f.arch === key.arch && f.kind === 'archive' && f.filename && f.filename.endsWith(ext)
+ );
+ if (!file || !file.sha256 || !file.filename) {
+ throw new Error(`No Go ${target.version} archive for ${key.os}/${key.arch}`);
+ }
+ return {
+ version: target.version,
+ filename: file.filename,
+ sha256: file.sha256,
+ size: file.size || 0,
+ url: `https://go.dev/dl/${file.filename}`
+ };
+}
+
+/**
+ * Install (or refresh) the Go toolchain into data/go-toolchain/.
+ *
+ * @param {(phase: string, detail?: string) => void} [onProgress]
+ * @returns {Promise<{ success: boolean, binPath: string|null, version: string|null, error?: string }>}
+ */
+async function installGoToolchain(onProgress) {
+ const log = (phase, detail) => { try { (onProgress || (() => {}))(phase, detail); } catch (_e) { /* ignore */ } };
+ const goRoot = path.join(GO_TOOLCHAIN_DIR, 'go');
+ const goBin = path.join(goRoot, 'bin', IS_WINDOWS ? 'go.exe' : 'go');
+
+ // Reuse existing install if it still works
+ if (fs.existsSync(goBin)) {
+ try {
+ const v = execSync(`"${goBin}" version`, { timeout: 5000, stdio: 'pipe' }).toString().trim();
+ log('ready', v);
+ return { success: true, binPath: goBin, version: v };
+ } catch (_e) { /* fall through and reinstall */ }
+ }
+
+ fs.mkdirSync(GO_TOOLCHAIN_DIR, { recursive: true });
+
+ let release;
+ try {
+ log('resolving', 'go.dev/dl');
+ release = await resolveGoRelease();
+ } catch (err) {
+ return { success: false, binPath: null, version: null, error: `Cannot resolve Go release: ${err.message}` };
+ }
+
+ const archivePath = path.join(GO_TOOLCHAIN_DIR, release.filename);
+ try {
+ log('downloading', `${release.version} (${Math.round((release.size || 0) / 1048576)} MB)`);
+ const buf = await httpsDownload(release.url);
+
+ const crypto = require('crypto');
+ const sha = crypto.createHash('sha256').update(buf).digest('hex');
+ if (sha.toLowerCase() !== release.sha256.toLowerCase()) {
+ return {
+ success: false, binPath: null, version: null,
+ error: `Go toolchain checksum mismatch (expected ${release.sha256.slice(0, 12)}…, got ${sha.slice(0, 12)}…)`
+ };
+ }
+ fs.writeFileSync(archivePath, buf);
+ log('extracting', release.filename);
+
+ if (fs.existsSync(goRoot)) {
+ try { fs.rmSync(goRoot, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
+ }
+
+ if (IS_WINDOWS) {
+ await execPromise(
+ `powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${GO_TOOLCHAIN_DIR.replace(/'/g, "''")}'"`,
+ { timeout: 300000 }
+ );
+ } else {
+ await execPromise(`tar -xzf "${archivePath}" -C "${GO_TOOLCHAIN_DIR}"`, { timeout: 300000 });
+ }
+
+ try { fs.unlinkSync(archivePath); } catch (_e) { /* ignore */ }
+
+ if (!fs.existsSync(goBin)) {
+ return { success: false, binPath: null, version: null, error: 'Extraction succeeded but go binary not found' };
+ }
+ if (!IS_WINDOWS) {
+ try { fs.chmodSync(goBin, 0o755); } catch (_e) { /* ignore */ }
+ }
+
+ const v = execSync(`"${goBin}" version`, { timeout: 5000, stdio: 'pipe' }).toString().trim();
+ log('ready', v);
+ return { success: true, binPath: goBin, version: v };
+ } catch (err) {
+ try { if (fs.existsSync(archivePath)) fs.unlinkSync(archivePath); } catch (_e) { /* ignore */ }
+ return { success: false, binPath: null, version: null, error: err.message || String(err) };
+ }
+}
+
/**
* Deploy the compiled binary to the service installation path.
* Creates a timestamped backup of the existing binary first.
@@ -579,10 +809,16 @@ function getServerUpdateInfo() {
const goInfo = checkGoAvailable();
const binaryPath = detectServerBinaryPath();
const sourcePresent = fs.existsSync(path.join(COMPONENTS.server.localRoot || '', 'go.mod'));
+ const vendoredGoPath = path.join(GO_TOOLCHAIN_DIR, 'go', 'bin', IS_WINDOWS ? 'go.exe' : 'go');
+ const vendoredGoInstalled = fs.existsSync(vendoredGoPath);
return {
goAvailable: goInfo.available,
goVersion: goInfo.version,
+ goPath: goInfo.binPath,
+ goSource: goInfo.source, // 'path' | 'system' | 'vendored' | null
+ vendoredGoInstalled,
+ canInstallGo: true, // toolchain bootstrap is always available
binaryPath,
sourcePresent,
canAutoUpdate: goInfo.available,
@@ -889,12 +1125,55 @@ async function applyUpdate(remoteSHA, changedData, opts = {}) {
// ---- Server source files + compile/download + deploy ----
if (changedData.grouped.server?.length && selectedComponents.includes('server')) {
- const strategy = opts.serverStrategy || 'auto'; // 'auto', 'compile', 'download'
- const goAvailable = checkGoAvailable().available;
+ const strategy = opts.serverStrategy || 'auto'; // 'auto', 'compile', 'download', 'install-go'
+ let goAvailable = checkGoAvailable().available;
let serverBinaryPath = null;
let buildUsed = null;
- if (strategy === 'compile' || (strategy === 'auto' && goAvailable)) {
+ // ---- Strategy: download Go toolchain on demand ----
+ // Triggered explicitly ('install-go') or by auto-fallback (no Go + no
+ // matching pre-built binary).
+ let toolchainInstalled = false;
+ if (strategy === 'install-go' && !goAvailable) {
+ const tc = await installGoToolchain();
+ results.toolchainInstall = {
+ success: tc.success,
+ version: tc.version || null,
+ error: tc.error || null,
+ binPath: tc.binPath || null
+ };
+ if (tc.success) {
+ toolchainInstalled = true;
+ goAvailable = true;
+ }
+ } else if (strategy === 'auto' && !goAvailable) {
+ // Auto-fallback: only attempt toolchain install if no pre-built
+ // release is reachable. This keeps the default path light.
+ try {
+ const prebuilt = await checkPrebuiltAvailable();
+ if (!prebuilt.available || !prebuilt.downloadUrl) {
+ const tc = await installGoToolchain();
+ results.toolchainInstall = {
+ success: tc.success,
+ version: tc.version || null,
+ error: tc.error || null,
+ binPath: tc.binPath || null,
+ autoTriggered: true
+ };
+ if (tc.success) {
+ toolchainInstalled = true;
+ goAvailable = true;
+ }
+ }
+ } catch (err) {
+ console.error('[UPDATE] auto-fallback toolchain install failed:', err.message);
+ }
+ }
+
+ const wantsCompile = strategy === 'compile' || strategy === 'install-go' || toolchainInstalled
+ || (strategy === 'auto' && goAvailable);
+
+ if (wantsCompile && goAvailable) {
// ---- Strategy: Compile from source ----
// 1. Ensure full source is present (downloads if missing)
try {
@@ -1209,5 +1488,7 @@ module.exports = {
saveLocalSHA,
getServerUpdateInfo,
getPrebuiltInfo,
+ installGoToolchain,
+ checkGoAvailable,
COMPONENTS
};