visionA/local-tool/frontend/src/hooks/use-shutdown-watcher.ts
jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。

程式碼變動
  - M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
    Makefile vendor / installer / bootstrap / CI workflow,-555 行)
  - M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
    VISIONA_MOCK 環境變數,-528 行)
  - M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
    LGPL binary,macOS 自 build minimal decoder-only 進 git
    (vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
  - M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
    preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
    notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
  - M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
    stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
  - M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
    state 視覺、log panel、startup progress panel、Stage 6 manual CTA
    pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
  - M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
  - M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
    wsEverConnected 容錯 + Page Visibility)
  - M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
    ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
  - MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
    (/ws/system endpoint + notifyShutdownImminent helper)
  - M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)

品質
  - ~105+ 新 unit test + race detector (-count=2) 全綠
  - 10 個 milestone 全部通過 Reviewer 審查
  - 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
    收錄在 .autoflow/

交付前待處理(M8-10)
  - 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
  - 三平台 end-to-end build 驗證

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

359 lines
13 KiB
TypeScript
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.

'use client';
import { useEffect } from 'react';
import { useSystemStore, FAILURE_THRESHOLD, type OfflineReason } from '@/stores/system-store';
import { getApiBaseUrl, getWsBaseUrl, getRelayToken } from '@/lib/constants';
import { getRelayHeaders, ensureRelayToken } from '@/lib/api';
/**
* use-shutdown-watcher
*
* 對應 TDD v2 §2.6M8-7+ §2.6.2aMinor 4 WebSocket 廣播)+ §12M8-9 Boot-ID reload
*
* 偵測機制(三個獨立管道,任一觸發即標記 forcedOffline
* 1. WebSocket `server:shutdown-imminent` 廣播 → 立即標記,依 reason 決定後續行為
* 2. WebSocket onclose非 clean close→ 立即標記為 healthcheck-failed
* 3. 被動 polling fallbackhealth check 連續 2 次失敗 → 標記為 healthcheck-failed
*
* 模式:
* - normal10 s pollingserver 正常時運轉
* - active retry3 s pollingoverlay 顯示後自動重試直到 server 回來
*
* Page Visibility背景 tab 暫停 polling回前景立即 probe 一次。
*
* 容錯server 端 `/ws/system` endpoint 可能尚未實作M8-4b 才會做),
* 因此 WebSocket 連線失敗時不應觸發 forcedOffline僅靠 polling fallback。
*
* Boot-ID reloadM8-9
* - 每次 /system/boot-id 成功回來後,將 response.bootId 丟給 store.checkAndUpdateBootId
* • first → 首次載入,記下 bootIdmarkOnline
* • match → bootId 一致markOnline
* • mismatch → bootId 變了,代表 server 已重啟 → force `window.location.reload()`
* - reload 前用 sessionStorage 記下「已針對此 bootId 觸發過 reload」flag避免
* 異常情況(例如每次 poll bootId 都不同的 bug造成 reload loop。
* 正常情況下 reload 後 store reset bootId=null會走 first 路徑,不會誤觸發。
*/
/** sessionStorage key記錄上次針對哪個 bootId 觸發過 reload防 reload loop。 */
const RELOAD_LOOP_GUARD_KEY = 'visiona-reload-loop-guard';
const POLL_INTERVAL_NORMAL_MS = 10_000;
const POLL_INTERVAL_ACTIVE_RETRY_MS = 3_000;
const FETCH_TIMEOUT_MS = 3_000;
const WS_RECONNECT_DELAY_MS = 5_000;
const RESTART_DEFER_MS = 10_000;
interface BootIdResponse {
success: boolean;
data?: {
bootId: string;
startedAt: number;
};
}
interface ShutdownImminentMessage {
type: 'server:shutdown-imminent';
reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit';
ts?: number;
}
/** 把 server WebSocket reason 對應到本地 store 的 OfflineReason。 */
function mapReason(serverReason: ShutdownImminentMessage['reason']): OfflineReason {
switch (serverReason) {
case 'quit':
case 'app-closing':
return 'quit';
case 'manual-stop':
return 'quit';
case 'restart':
return 'restart';
default:
return 'unknown';
}
}
/**
* 處理 boot-id 比對 + force reloadM8-9
*
* 回傳值:
* - `true` 代表呼叫端應繼續後續流程first / match
* - `false` 代表已觸發或即將觸發reload呼叫端不需要再做任何事
*
* reload loop guard用 sessionStorage 記下「上次針對哪個 bootId 觸發過 reload」。
* - 正常情況reload 後 store.bootId=null → 走 first → 不會再 reload。
* - 異常情況(例如 server 每次回不同 bootIdsessionStorage 裡還是相同值
* → 跳過 reload 只 markOnline避免 reload loop 癱瘓使用者介面。
*/
function handleBootIdCheck(newBootId: string): boolean {
const store = useSystemStore.getState();
const result = store.checkAndUpdateBootId(newBootId);
if (result === 'first' || result === 'match') {
return true;
}
// mismatchserver 重啟force reload。
if (typeof window === 'undefined') {
// SSR / 測試環境保險:不做 reload只標示不再繼續處理。
return false;
}
try {
const lastGuard = window.sessionStorage.getItem(RELOAD_LOOP_GUARD_KEY);
if (lastGuard === newBootId) {
// 同一個 bootId 已經觸發過 reload避免 loop改為接受這個 bootId + markOnline。
console.warn(
'[boot-id] skip reload: already reloaded for this bootId this session',
newBootId,
);
store.setBootId(newBootId);
store.markOnline();
return false;
}
window.sessionStorage.setItem(RELOAD_LOOP_GUARD_KEY, newBootId);
} catch {
// sessionStorage 可能在隱私模式等情境下失敗;失敗時仍然執行 reload。
}
console.info('[boot-id] mismatch detected, force reloading tab', {
oldBootId: store.bootId,
newBootId,
});
window.location.reload();
return false;
}
/** 打一次 boot-id成功 → 比對 bootId可能觸發 reload+ markOnline失敗 → recordFailure。 */
async function pollOnce(): Promise<void> {
const store = useSystemStore.getState();
try {
await ensureRelayToken();
const res = await fetch(`${getApiBaseUrl()}/system/boot-id`, {
cache: 'no-store',
headers: getRelayHeaders(),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
store.recordFailure();
return;
}
const json = (await res.json()) as BootIdResponse;
if (!json.success || !json.data?.bootId) {
store.recordFailure();
return;
}
// 成功:先比對 bootIdM8-9若 mismatch → reload直接結束
// first / match → markOnline。
const shouldContinue = handleBootIdCheck(json.data.bootId);
if (!shouldContinue) return;
store.markOnline();
} catch {
store.recordFailure();
}
}
export function useShutdownWatcher(): void {
useEffect(() => {
if (typeof window === 'undefined') return;
let intervalHandle: ReturnType<typeof setInterval> | null = null;
let currentMode: 'normal' | 'active-retry' = 'normal';
let ws: WebSocket | null = null;
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let wsEverConnected = false; // 只有曾經連上才把 onclose 視為「server 斷了」
let cancelled = false;
let restartDeferTimer: ReturnType<typeof setTimeout> | null = null;
const intervalFor = (mode: 'normal' | 'active-retry') =>
mode === 'active-retry' ? POLL_INTERVAL_ACTIVE_RETRY_MS : POLL_INTERVAL_NORMAL_MS;
const startPolling = (mode: 'normal' | 'active-retry') => {
if (intervalHandle !== null && currentMode === mode) return;
if (intervalHandle !== null) clearInterval(intervalHandle);
currentMode = mode;
intervalHandle = setInterval(() => {
void pollOnce();
}, intervalFor(mode));
};
const stopPolling = () => {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
};
// 訂閱 store當 forcedOffline / consecutiveFailures 改變時,自動切 polling 模式
const unsubscribe = useSystemStore.subscribe((state) => {
const shouldActiveRetry =
state.forcedOffline || state.consecutiveFailures >= FAILURE_THRESHOLD;
const targetMode: 'normal' | 'active-retry' = shouldActiveRetry ? 'active-retry' : 'normal';
// 連續失敗達到門檻但尚未 forcedOffline → 由 hook 補上 forceOffline
if (
!state.forcedOffline &&
state.consecutiveFailures >= FAILURE_THRESHOLD &&
state.serverOnline !== false
) {
useSystemStore.getState().forceOffline('healthcheck-failed');
}
if (intervalHandle !== null && targetMode !== currentMode) {
startPolling(targetMode);
}
});
// ─── WebSocket 訂閱 ───────────────────────────────────────────
// server 端 `/ws/system` 可能尚未實作M8-4b此處需容錯
// - 連不上 → onerror / onclosewsEverConnected=false→ 不觸發 forcedOffline
// - 連上後再斷 → 視為 server 真的掛了 → 觸發 forcedOffline
const connectWs = () => {
if (cancelled) return;
try {
const token = getRelayToken();
let wsUrl = `${getWsBaseUrl()}/ws/system`;
if (token) {
wsUrl += `?token=${encodeURIComponent(token)}`;
}
ws = new WebSocket(wsUrl);
} catch {
scheduleWsReconnect();
return;
}
ws.onopen = () => {
wsEverConnected = true;
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data)) as Partial<ShutdownImminentMessage>;
if (msg?.type !== 'server:shutdown-imminent') return;
const serverReason = (msg as ShutdownImminentMessage).reason;
const reason = mapReason(serverReason);
if (reason === 'restart') {
// restart延遲 10 s 才顯示 overlay期間若 polling 成功bootId 變)→ M8-9 reload
if (restartDeferTimer !== null) clearTimeout(restartDeferTimer);
restartDeferTimer = setTimeout(() => {
const s = useSystemStore.getState();
if (!s.serverOnline || s.consecutiveFailures >= FAILURE_THRESHOLD) {
s.forceOffline('restart');
}
}, RESTART_DEFER_MS);
return;
}
// quit / unknown立即顯示
useSystemStore.getState().forceOffline(reason);
} catch {
// 非 JSON / 格式錯誤 → 忽略
}
};
ws.onclose = (ev) => {
const wasEver = wsEverConnected;
ws = null;
// 只有「曾經連上後又斷」才視為 server 真的掛了;
// 若從未連上server 端尚未實作 /ws/system不觸發 forcedOffline。
if (wasEver && !ev.wasClean) {
// 立即觸發一次 polling 確認,若失敗才會被 polling 機制處理
void pollOnce();
}
scheduleWsReconnect();
};
ws.onerror = () => {
// 忽略 — onclose 會接續處理
};
};
const scheduleWsReconnect = () => {
if (cancelled || wsReconnectTimer !== null) return;
wsReconnectTimer = setTimeout(() => {
wsReconnectTimer = null;
if (!cancelled && document.visibilityState === 'visible') {
connectWs();
}
}, WS_RECONNECT_DELAY_MS);
};
// ─── Page Visibility ───────────────────────────────────────────
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void pollOnce();
const s = useSystemStore.getState();
const mode: 'normal' | 'active-retry' =
s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD ? 'active-retry' : 'normal';
startPolling(mode);
if (ws === null && wsReconnectTimer === null) {
connectWs();
}
} else {
stopPolling();
}
};
// ─── 初始啟動 ───────────────────────────────────────────────
void pollOnce();
if (document.visibilityState === 'visible') {
startPolling('normal');
connectWs();
}
document.addEventListener('visibilitychange', onVisibilityChange);
// ─── 清理 ───────────────────────────────────────────────
return () => {
cancelled = true;
document.removeEventListener('visibilitychange', onVisibilityChange);
unsubscribe();
stopPolling();
if (wsReconnectTimer !== null) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
}
if (restartDeferTimer !== null) {
clearTimeout(restartDeferTimer);
restartDeferTimer = null;
}
if (ws) {
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
ws.close();
ws = null;
}
};
}, []);
}
/** 匯出供 overlay 元件呼叫,手動 retry 按鈕用。 */
export async function retryServerHealth(): Promise<boolean> {
const store = useSystemStore.getState();
try {
await ensureRelayToken();
const res = await fetch(`${getApiBaseUrl()}/system/boot-id`, {
cache: 'no-store',
headers: getRelayHeaders(),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
store.recordFailure();
return false;
}
const json = (await res.json()) as BootIdResponse;
if (!json.success || !json.data?.bootId) {
store.recordFailure();
return false;
}
// M8-9手動 retry 也要比對 bootIdmismatch → reload。
const shouldContinue = handleBootIdCheck(json.data.bootId);
if (!shouldContinue) {
// 已觸發 reload或在 SSR/guard 情境下被 skip回傳 true 讓 overlay 視為成功。
return true;
}
store.markOnline();
return true;
} catch {
store.recordFailure();
return false;
}
}