jim800121chen 35db6c8167 fix(local-tool): Windows popup 卡死 safety net + appLog 覆蓋啟動流程
使用者回報 c649a81 之後仍看到「正在停止伺服器…」popup 一打開就卡住。
無法在不看 log 的情況下推斷根因,先加三層 safety net 確保 popup 不是
blocker,並把關鍵啟動訊息寫到 wails.log 供 Windows 除錯。

Safety net 三層:

1. 前端 watchdog:shutdown-modal 最多顯示 15 秒,超時自動 hide 並 toast
   提示使用者 server 可能還沒停掉。
2. 前端 escape hatch:點 backdrop 空白處 / 按 Esc 可手動關閉 popup。
3. Go 端 hardBailout timer:stopGraceful 最多跑 shutdownGraceV2 + 2 秒
   (目前 = 9 秒),到上限直接 return leak process,避免 Process.Wait
   永遠阻塞(Windows 偶有情境)。graceTimer 分支的 `<-done` 也改成
   非阻塞 `select-with-1s-timeout`。

Windows 除錯 log 強化:

4. startup 頭加版本識別標記到 wails.log:
     ==================================================
     visionA-local startup build=dev buildTime=unknown
     platform=windows arch=amd64 dataDir=...
     fix marker: c649a81+ (Stage3 waitHealthy pause / shutdown modal safety net)
     ==================================================
   使用者拉新版後啟動可從此確認 build 是否是最新版。
5. app.go 把 startup 路徑上的 fmt.Fprintln(os.Stderr, ...) 改 appLog:
   IPC server start / seed failure / Stage 1 complete / ctrl.Start 結果。
   Windows 上 stderr 是 null device,appLog 會同時寫檔到 wails.log。
6. server_control.go stopGraceful 加 appLog 記錄 entry / modal-show /
   grace timer / hard bailout / return,整條 Stop 路徑完全透明。
7. driver auto-install failed 訊息也改 appLog。

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK

需要使用者協助:拉新版後在 Windows 乾淨環境試,啟動後貼以下三個檔案
內容給我:
  %APPDATA%\visiona-local\logs\wails.log        — appLog 記錄整個啟動流程
  %APPDATA%\visiona-local\logs\server.stdout.log — server subprocess stdout
  %APPDATA%\visiona-local\logs\server.stderr.log — server subprocess stderr

log 裡有「fix marker: c649a81+」即為本 commit 或更新;若沒有 marker
或 marker 指向別的 commit 則代表 build 不是最新版。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:31:15 +08:00

387 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// visionA-local 控制台 main entry
// - 負責 bindings / events 註冊、初始化 UI
// - M8-5 Wails 控制台 UI對齊 Design Spec v2.1
import {
StartServer,
StopServer,
RestartServer,
ForceKillServer,
GetServerStatusV2,
GetRecentLogs,
ClearLogs,
GetSystemInfo,
OpenInBrowser,
RevealLogsFolder,
ExportLog,
GetPreferences,
SetPreferences,
RestartStartupSequence,
} from './wailsjs/go/main/App.js';
import { EventsOn } from './wailsjs/runtime/runtime.js';
import { t, applyI18n, setLocale, getLocale, detectLocale, LOCALES } from './i18n.js';
import {
setServerState,
updateServerMeta,
updatePrimaryControls,
showErrorBanner,
hideErrorBanner,
showToast,
initHeaderClock,
setPrimaryCTAPulse,
STATE_ERROR,
} from './control-panel.js';
import {
showStartupPanel,
hideStartupPanel,
updateStage,
markStageTimeout,
showStartupError,
renderStages,
onManualModeChange,
} from './startup-panel.js';
import {
initLogPanel,
appendLogs,
clearLog,
flashLastError,
applyLogFilter,
} from './log-panel.js';
import { initSettingsPanel, openSettings } from './settings-panel.js';
// ---------- 全域狀態 ----------
const state = {
server: null, // ServerStatusV2
prefs: null, // Preferences
sysInfo: null, // SystemInfo
starting: false, // 啟動進度面板是否顯示
};
// ---------- 初始化 ----------
async function init() {
// 1. 讀 preferences → 決定 locale
try {
state.prefs = await GetPreferences();
} catch (e) {
console.warn('GetPreferences failed:', e);
state.prefs = { autoOpenBrowser: true, locale: '' };
}
const locale = detectLocale(state.prefs && state.prefs.locale);
setLocale(locale);
// 2. 讀系統資訊
try {
state.sysInfo = await GetSystemInfo();
} catch (e) {
console.warn('GetSystemInfo failed:', e);
}
// 3. 套 i18n 到 DOM
applyI18n();
document.title = t('control.title');
if (state.sysInfo && state.sysInfo.appVersion) {
document.getElementById('app-version').textContent = state.sysInfo.appVersion;
}
// 4. render 6 階段 UI skeleton
renderStages();
// 5. 初始化 log panel拉既有 buffer
try {
const existingLogs = await GetRecentLogs(2000);
initLogPanel(existingLogs || []);
} catch (e) {
console.warn('GetRecentLogs failed:', e);
initLogPanel([]);
}
// 6. 綁按鈕 handlers
bindHandlers();
// 7. 訂閱 Wails events
subscribeEvents();
// 8. 初始 state query
try {
const status = await GetServerStatusV2();
handleServerStatus(status);
} catch (e) {
console.warn('GetServerStatusV2 failed:', e);
}
// 9. 初始化 settings panel
initSettingsPanel({
prefs: state.prefs,
sysInfo: state.sysInfo,
getPreferences: GetPreferences,
setPreferences: SetPreferences,
onLocaleChange: (newLoc) => {
setLocale(newLoc || detectLocale(''));
applyI18n();
document.title = t('control.title');
renderStages();
// re-render current state text
if (state.server) setServerState(state.server);
},
});
// 10. header uptime 時鐘
initHeaderClock(() => state.server);
// 11. Manual mode 監聽stage 5 skipped 時引導使用者點 Open in Browser
// 對齊 Design Spec §4.1
onManualModeChange((enabled) => {
setPrimaryCTAPulse(enabled);
});
}
// ---------- 處理 server status ----------
function handleServerStatus(status) {
if (!status) return;
state.server = status;
setServerState(status);
updateServerMeta(status);
updatePrimaryControls(status);
// Error state runtime banner非 startup error
if (status.state === STATE_ERROR && status.lastError && !state.starting) {
showErrorBanner(status.lastError);
} else if (status.state !== STATE_ERROR) {
hideErrorBanner();
}
}
// ---------- 按鈕 handlers ----------
function bindHandlers() {
const $ = (id) => document.getElementById(id);
// Open in Browser
$('btn-open-browser').addEventListener('click', async () => {
try {
await OpenInBrowser('');
} catch (e) {
showToast('Failed to open browser: ' + e);
}
});
// Start
$('btn-start').addEventListener('click', async () => {
try {
await StartServer();
} catch (e) {
showToast('Start failed: ' + e);
}
});
// Manage menu
const manageBtn = $('btn-manage');
const manageMenu = $('manage-menu');
manageBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
const hidden = manageMenu.hasAttribute('hidden');
if (hidden) {
manageMenu.removeAttribute('hidden');
manageBtn.setAttribute('aria-expanded', 'true');
} else {
manageMenu.setAttribute('hidden', '');
manageBtn.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('click', () => {
if (!manageMenu.hasAttribute('hidden')) {
manageMenu.setAttribute('hidden', '');
manageBtn.setAttribute('aria-expanded', 'false');
}
});
$('mi-stop').addEventListener('click', async () => {
try {
await StopServer();
} catch (e) {
showToast('Stop failed: ' + e);
}
});
$('mi-restart').addEventListener('click', async () => {
try {
await RestartServer();
} catch (e) {
showToast('Restart failed: ' + e);
}
});
$('mi-open-folder').addEventListener('click', async () => {
try {
await RevealLogsFolder();
} catch (e) {
showToast('Open folder failed: ' + e);
}
});
// Log actions
$('btn-clear-log').addEventListener('click', async () => {
try {
await ClearLogs();
clearLog();
showToast(t('control.log.clearToast'));
} catch (e) {
showToast('Clear failed: ' + e);
}
});
$('btn-copy-log').addEventListener('click', async () => {
const output = document.getElementById('log-output');
try {
await navigator.clipboard.writeText(output.innerText || '');
showToast(t('control.log.copied'));
} catch (e) {
showToast('Copy failed: ' + e);
}
});
$('btn-export-log').addEventListener('click', async () => {
try {
const path = await ExportLog();
showToast(t('control.log.exported', { path }));
} catch (e) {
showToast('Export failed: ' + e);
}
});
$('btn-open-folder').addEventListener('click', async () => {
try {
await RevealLogsFolder();
} catch (e) {
showToast('Open folder failed: ' + e);
}
});
// Filter
$('filter-input').addEventListener('input', (e) => applyLogFilter({ text: e.target.value }));
$('level-filter').addEventListener('change', (e) => applyLogFilter({ level: e.target.value }));
// Settings
$('btn-settings').addEventListener('click', openSettings);
// Startup error actions
$('btn-retry').addEventListener('click', async () => {
try {
await RestartStartupSequence();
} catch (e) {
showToast('Retry failed: ' + e);
}
});
$('btn-view-log').addEventListener('click', () => {
hideStartupPanel();
flashLastError();
});
// Report 按鈕 disabledcoming soon
// Error banner
$('banner-restart').addEventListener('click', async () => {
try {
await RestartServer();
} catch (e) {
showToast('Restart failed: ' + e);
}
});
$('banner-view').addEventListener('click', () => {
flashLastError();
});
// 鍵盤快捷鍵⌘F / Ctrl+F focus filter
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault();
$('filter-input').focus();
}
});
}
// ---------- 訂閱 Wails events ----------
function subscribeEvents() {
// server state / error / recovered
EventsOn('server:state-change', (payload) => handleServerStatus(payload));
EventsOn('server:error', (payload) => {
// Go 端 server_control.go L184/L361 emit payload key 為 "reason"(不是 "error"
if (payload && payload.reason) showErrorBanner(payload.reason);
});
EventsOn('server:recovered', () => {
hideErrorBanner();
showToast('Server recovered');
});
// log stream
EventsOn('log:append', (entries) => {
if (Array.isArray(entries)) appendLogs(entries);
else if (entries) appendLogs([entries]);
});
EventsOn('log:clear', () => clearLog());
// startup pipeline
EventsOn('startup:progress', (ev) => {
if (!state.starting) {
state.starting = true;
showStartupPanel();
}
updateStage(ev);
});
EventsOn('startup:stage-timeout', (ev) => {
markStageTimeout(ev);
});
EventsOn('startup:error', (ev) => {
showStartupError(ev);
});
EventsOn('startup:ready', () => {
state.starting = false;
hideStartupPanel();
});
// shutdown modalM8-4 1 秒後顯示)
// 用 watchdog 做 safety netpopup show 後最多顯示 15 秒,即使 Go 端
// 沒 emit hidestopGraceful 卡死、Process.Wait 阻塞等邊界情境)前端也
// 會自己 hide避免使用者卡在無法關閉的 popup。
let shutdownModalWatchdog = null;
const hideShutdownModal = () => {
const m = document.getElementById('shutdown-modal');
if (m) m.setAttribute('hidden', '');
if (shutdownModalWatchdog) {
clearTimeout(shutdownModalWatchdog);
shutdownModalWatchdog = null;
}
};
EventsOn('shutdown:modal-show', () => {
const m = document.getElementById('shutdown-modal');
if (m) m.removeAttribute('hidden');
if (shutdownModalWatchdog) clearTimeout(shutdownModalWatchdog);
shutdownModalWatchdog = setTimeout(() => {
hideShutdownModal();
showToast('伺服器停止耗時過久popup 已自動關閉。請用工作管理員確認 server 是否已結束。');
}, 15000);
});
// stopGraceful 結束時對稱 hide。
EventsOn('shutdown:modal-hide', hideShutdownModal);
// 點 backdrop 空白處可手動關閉 popupEscape hatch
const shutdownBackdrop = document.getElementById('shutdown-modal');
if (shutdownBackdrop) {
shutdownBackdrop.addEventListener('click', (e) => {
if (e.target === shutdownBackdrop) hideShutdownModal();
});
}
// Esc 鍵也可以關
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const m = document.getElementById('shutdown-modal');
if (m && !m.hasAttribute('hidden')) hideShutdownModal();
}
});
// app level fatal保留相容
EventsOn('app:error', (msg) => {
showErrorBanner(typeof msg === 'string' ? msg : JSON.stringify(msg));
});
}
// ---------- 啟動 ----------
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}