依 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>
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
'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.6(M8-7)+ §2.6.2a(Minor 4 WebSocket 廣播)+ §12(M8-9 Boot-ID reload)。
|
||
*
|
||
* 偵測機制(三個獨立管道,任一觸發即標記 forcedOffline):
|
||
* 1. WebSocket `server:shutdown-imminent` 廣播 → 立即標記,依 reason 決定後續行為
|
||
* 2. WebSocket onclose(非 clean close)→ 立即標記為 healthcheck-failed
|
||
* 3. 被動 polling fallback:health check 連續 2 次失敗 → 標記為 healthcheck-failed
|
||
*
|
||
* 模式:
|
||
* - normal:10 s polling,server 正常時運轉
|
||
* - active retry:3 s polling,overlay 顯示後自動重試直到 server 回來
|
||
*
|
||
* Page Visibility:背景 tab 暫停 polling,回前景立即 probe 一次。
|
||
*
|
||
* 容錯:server 端 `/ws/system` endpoint 可能尚未實作(M8-4b 才會做),
|
||
* 因此 WebSocket 連線失敗時不應觸發 forcedOffline,僅靠 polling fallback。
|
||
*
|
||
* Boot-ID reload(M8-9):
|
||
* - 每次 /system/boot-id 成功回來後,將 response.bootId 丟給 store.checkAndUpdateBootId:
|
||
* • first → 首次載入,記下 bootId,markOnline
|
||
* • 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 reload(M8-9)。
|
||
*
|
||
* 回傳值:
|
||
* - `true` 代表呼叫端應繼續後續流程(first / match)
|
||
* - `false` 代表已觸發(或即將觸發)reload,呼叫端不需要再做任何事
|
||
*
|
||
* reload loop guard:用 sessionStorage 記下「上次針對哪個 bootId 觸發過 reload」。
|
||
* - 正常情況:reload 後 store.bootId=null → 走 first → 不會再 reload。
|
||
* - 異常情況(例如 server 每次回不同 bootId):sessionStorage 裡還是相同值
|
||
* → 跳過 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;
|
||
}
|
||
|
||
// mismatch:server 重啟,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;
|
||
}
|
||
// 成功:先比對 bootId(M8-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 / onclose(wsEverConnected=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 也要比對 bootId,mismatch → 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;
|
||
}
|
||
}
|