依 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>
28 KiB
v2/web-ui-offline-overlay.md — Web UI Server Offline Overlay
所屬:TDD v2 §2.6 版本:v2.1(2026-04-14 吸收 Minor 4 WebSocket shutdown-imminent 廣播) 決策依據:R5-2(關閉 Wails 視窗 = server 停,瀏覽器顯示 offline overlay)、三方共識 #14(boot-id + retry)、PM Minor 4(WebSocket 廣播即時觸發 overlay,消除 race condition) 對應 milestone:M8-7 相關文件:
v2/server-lifecycle.md§8.3(Wails 端 WebSocket 廣播時機)、§9(server 端 boot-id API)
1. 目的
當瀏覽器 tab 偵測到 server 連不上(Wails app 被關了、使用者按 Stop、server crash)時,顯示一個全域的覆蓋層告訴使用者「Server 已離線」,並提供重試機制。同時實作 boot-id polling 讓 server 重啟後瀏覽器自動 reload。
2. UI 設計
┌─────────────────────────────────────────────────────┐
│ │
│ ⚠️ │
│ │
│ Server 已離線 │
│ │
│ visionA Local 的本機伺服器沒有回應。 │
│ 可能原因: │
│ • 你關閉了 visionA Local 控制台 │
│ • Server 被手動停止 │
│ • Server 發生錯誤 │
│ │
│ 請重新開啟 visionA Local 應用程式後按「重試」。 │
│ │
│ [重試] [關閉這個頁面] │
│ │
└─────────────────────────────────────────────────────┘
- 全螢幕半透明 backdrop(
rgba(0,0,0,0.65)) - 中央卡片(
max-w-md) - 中英文隨現有 i18n locale
- 不擋 devtools、不擋 keyboard(使用者可用 devtools 檢查 network 看是不是真連不上)
3. 偵測機制
3.1 偵測參數定版(v2.1 Architect 二次審閱補齊)
Polling 間隔與失敗門檻(定版):
| 參數 | 值 | 說明 |
|---|---|---|
| Health check 間隔(正常模式) | 10 秒 | server 正常時的 boot-id polling 間隔 |
| Health check 間隔(active retry 模式) | 3 秒 | overlay 顯示後自動重試的 polling 間隔 |
| 連續失敗門檻 | 2 次(約 20 秒) | 避免偶發網路抖動誤觸發 |
| fetch 單次 timeout | 3 秒 | AbortSignal.timeout(3000) |
Active retry 模式定義:使用者第一次看到 overlay 後(或主動點 Retry 按鈕),前端自動每 3 秒重打一次 /api/system/boot-id,不需要使用者持續按按鈕。只要任一次成功 → 立即 resetFailures() → overlay 消失(若 bootId 變了則 window.location.reload())。
觸發管道:Overlay 顯示由三個獨立管道觸發,任一觸發即顯示(優先序由高到低):
- WebSocket close event(v2.1 新增):瀏覽器端 WebSocket 收到
onclose→ 立即顯示 overlay(不等 polling);理由:close 事件代表 server 端已斷,polling 也一定會失敗,不需要等 20 秒 - WebSocket
server:shutdown-imminent事件(Minor 4):收到廣播訊息 → 立即顯示 overlay,並依reason欄位決定文案:reason="app-closing"/reason="manual-stop"/reason="quit"→ 立即顯示reason="restart"→ 延遲 10 秒顯示(給 boot-id 變化 →window.location.reload()的時間)
- 被動偵測(polling fallback):連續 2 次 boot-id polling 失敗 → 顯示 overlay(當 WebSocket 已斷線或從未建立時生效)
WebSocket 管道的優勢:
- 零延遲:Wails 開始 SIGTERM 的瞬間就發廣播(在實際 shutdown 完成前),瀏覽器 tab 幾乎同時看到 overlay
- 消除 race condition:原 v2.0 設計下,使用者關 Wails 視窗到瀏覽器 tab 看到 overlay 有 0.5-20 s 的 window(取決於 polling 時機),這段期間使用者可能對著一個「已失效但看起來正常」的頁面操作
- 回退:若 WebSocket 已斷線或從未建立(極罕見,例如 Next.js 載入失敗),polling fallback 仍有效
3.2 Polling 策略(v2.1 定版)
瀏覽器 tab 載入
↓
initial fetch /api/system/boot-id
↓
成功 → 記錄 initialBootId + startedAt,進 normal polling(10 s interval)
↓
下一次 poll:
├─ 成功 → 比對 bootId
│ ├─ 相同 → 無事
│ └─ 不同 → window.location.reload()(server 重啟,強制 reload)
└─ 失敗 → consecutiveFailures++
├─ < 2 → 不顯示 overlay(避免偶發網路 glitch)
└─ ≥ 2(連續 ~20 s)→ 顯示 <ServerOfflineOverlay>
+ 切 polling interval 為 3 s(active retry 模式)
↓
Active retry 模式下:
每 3 s 自動重打 /api/system/boot-id
├─ 成功(bootId 相同)→ resetFailures() + 切回 10 s interval → overlay 消失
└─ 成功(bootId 不同)→ window.location.reload()
3.2a WebSocket server:shutdown-imminent 偵測
瀏覽器 tab 已連上 WebSocket(例如 /ws/server-logs 或新增的 /ws/system)
↓
server 收到 Wails 的 ctrl.Stop() 呼叫
↓
server 對 WebSocket hub 廣播:
{
"type": "server:shutdown-imminent",
"reason": "app-closing" | "manual-stop" | "restart",
"ts": 1744656180123
}
↓
瀏覽器收到 message → store.forceShowOverlay()
↓
<ServerOfflineOverlay> 立即顯示(不等 polling 失敗)
↓
後續 SIGTERM、server 真的停止、polling 失敗 → 疊加在 overlay 上不重複觸發
reason 欄位用途(v2.1 定版):
app-closing:Wails OnShutdown 觸發,應用即將退出 → 立即顯示 overlay,文案「visionA Local 已關閉」manual-stop:使用者按控制台的 Stop → 立即顯示 overlay,文案「Server 已停止,請在控制台按 Start」quit:使用者從 Wails 選單選 Quit(macOS Cmd+Q 等)→ 立即顯示 overlay,文案同app-closingrestart:使用者按控制台的 Restart → 延遲 10 秒再顯示 overlay(給 boot-id 變化 →window.location.reload()的時間);若 10 s 內 server 回來 + boot-id 變 → reload → overlay 不會真的顯示
3.3 失敗計數策略(polling 管道,v2.1 定版)
- Normal 模式:10 s poll interval,連續 2 次失敗(= 覆蓋 ~20 s 容忍期)才觸發 overlay
- Active retry 模式:overlay 顯示後切 3 s poll interval,自動重試至 server 回來
- Wails 關閉視窗的
StopServer過程約 0.5-1 s,瀏覽器 polling 通常靠 WebSocket close 事件或server:shutdown-imminent廣播立即顯示 overlay,而非等 polling 失敗 - 使用者正常 Restart(透過控制台)約 2-3 s,boot-id 會變但也不會連續 2 次失敗 → 下一次成功 poll 偵測 boot-id 變化 → force reload
- 網路偶發抖動(< 10 s 單次 glitch)→ 最多 1 次 failure,下一次 poll 成功即清零,不觸發 overlay
3.3 Page Visibility API(R-v2-6)
瀏覽器背景 tab 的 setInterval 會被降頻到 1 次/分鐘,會讓「server 重啟後使用者切回 tab」體驗不好。
解決:
const BOOT_ID_POLL_MS = 5000;
let intervalHandle: number | null = null;
function startPolling() {
if (intervalHandle !== null) return;
intervalHandle = window.setInterval(pollOnce, BOOT_ID_POLL_MS);
}
function stopPolling() {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
pollOnce(); // 立刻 probe 一次
startPolling();
} else {
stopPolling(); // 背景 tab 不 poll,回來再說
}
});
// 初始:tab 可見時才啟動
if (document.visibilityState === 'visible') {
startPolling();
}
4. 實作
4.1 檔案清單
| 檔案 | 狀態 | 說明 |
|---|---|---|
frontend/src/stores/system-store.ts |
新增 | Zustand store:serverOnline / bootId / forcedOffline / lastError |
frontend/src/hooks/use-boot-id-watcher.ts |
新增 | React hook:掛 useEffect 啟動 polling + visibility handling |
frontend/src/hooks/use-shutdown-watcher.ts |
新增(v2.1) | React hook:訂閱 WebSocket server:shutdown-imminent 事件(Minor 4) |
frontend/src/components/server-offline-overlay.tsx |
新增 | 全域覆蓋層 UI(shadcn Dialog / Card) |
frontend/src/components/boot-id-watcher-mount.tsx |
新增 | Client wrapper 掛 useBootIdWatcher() + useShutdownWatcher() |
frontend/src/app/layout.tsx |
修改 | 在 root layout 掛 <ServerOfflineOverlay /> 和 <BootIdWatcherMount /> |
frontend/src/lib/i18n/types.ts |
修改 | 新增 serverOffline 區塊的 i18n key 定義(含 3 種 reason 文案) |
frontend/src/lib/i18n/zh-TW.ts |
修改 | 新增繁體中文字串 |
frontend/src/lib/i18n/en.ts |
修改 | 新增英文字串 |
4.2 frontend/src/stores/system-store.ts
import { create } from 'zustand';
interface SystemState {
serverOnline: boolean;
bootId: string | null;
consecutiveFailures: number;
lastProbeAt: number | null;
// Minor 4:由 WebSocket shutdown-imminent 事件強制設定,讓 overlay 立即顯示
forcedOffline: boolean;
forcedOfflineReason: 'app-closing' | 'manual-stop' | 'restart' | 'quit' | null;
setOnline: (bootId: string) => void;
recordFailure: () => void;
shouldShowOverlay: () => boolean;
resetFailures: () => void;
forceOffline: (reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit') => void;
}
const FAILURE_THRESHOLD = 2; // v2.1 定版:2 次失敗(約 20 s)即觸發
export const useSystemStore = create<SystemState>((set, get) => ({
serverOnline: true,
bootId: null,
consecutiveFailures: 0,
lastProbeAt: null,
forcedOffline: false,
forcedOfflineReason: null,
setOnline: (bootId) => set({
serverOnline: true,
bootId,
consecutiveFailures: 0,
lastProbeAt: Date.now(),
// 注意:recover 時不自動清 forcedOffline,要走 resetFailures(使用者點「重試」)
}),
recordFailure: () => set((s) => ({
consecutiveFailures: s.consecutiveFailures + 1,
serverOnline: s.consecutiveFailures + 1 < FAILURE_THRESHOLD,
lastProbeAt: Date.now(),
})),
shouldShowOverlay: () => {
const s = get();
return s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD;
},
// Active retry 模式:overlay 顯示中時,前端自動切 3 s interval 重試
isActiveRetryMode: () => {
const s = get();
return s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD;
},
resetFailures: () => set({
consecutiveFailures: 0,
serverOnline: true,
forcedOffline: false,
forcedOfflineReason: null,
}),
forceOffline: (reason) => {
// Minor 4:restart 情境不立即顯示 overlay,給 10 s 等 boot-id 變化 reload
if (reason === 'restart') {
setTimeout(() => {
const s = get();
if (s.consecutiveFailures < FAILURE_THRESHOLD && s.bootId !== null) {
// 10 s 內 server 沒回來才強制顯示
set({ forcedOffline: true, forcedOfflineReason: 'restart' });
}
}, 10_000);
return;
}
set({ forcedOffline: true, forcedOfflineReason: reason });
},
}));
4.3 frontend/src/hooks/use-boot-id-watcher.ts
'use client';
import { useEffect } from 'react';
import { useSystemStore } from '@/stores/system-store';
import { getBackendUrl } from '@/lib/api';
const POLL_INTERVAL_NORMAL_MS = 10_000; // v2.1 定版:正常模式 10 s
const POLL_INTERVAL_ACTIVE_RETRY_MS = 3_000; // v2.1 定版:overlay 顯示後自動每 3 s 重試
interface BootIdResponse {
success: boolean;
data: {
bootId: string;
startedAt: number;
};
}
async function pollOnce(): Promise<void> {
const store = useSystemStore.getState();
try {
const res = await fetch(`${getBackendUrl()}/api/system/boot-id`, {
cache: 'no-store',
// 短 timeout:若 server 假死要快速失敗
signal: AbortSignal.timeout(3000),
});
if (!res.ok) {
store.recordFailure();
return;
}
const json = (await res.json()) as BootIdResponse;
if (!json.success || !json.data?.bootId) {
store.recordFailure();
return;
}
const newBootId = json.data.bootId;
if (store.bootId !== null && store.bootId !== newBootId) {
// Server 重啟:force reload
window.location.reload();
return;
}
store.setOnline(newBootId);
} catch {
store.recordFailure();
}
}
export function useBootIdWatcher(): void {
useEffect(() => {
let intervalHandle: number | null = null;
let currentMode: 'normal' | 'active-retry' = 'normal';
const intervalFor = (mode: 'normal' | 'active-retry') =>
mode === 'active-retry' ? POLL_INTERVAL_ACTIVE_RETRY_MS : POLL_INTERVAL_NORMAL_MS;
const start = (mode: 'normal' | 'active-retry') => {
if (intervalHandle !== null && currentMode === mode) return;
if (intervalHandle !== null) clearInterval(intervalHandle);
currentMode = mode;
intervalHandle = window.setInterval(pollOnce, intervalFor(mode));
};
const stop = () => {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
};
// 監聽 store 變化,overlay 顯示 ↔ 隱藏時切換 polling 模式
const unsubscribe = useSystemStore.subscribe((state) => {
const shouldActiveRetry = state.forcedOffline || state.consecutiveFailures >= 2;
const targetMode = shouldActiveRetry ? 'active-retry' : 'normal';
if (intervalHandle !== null && targetMode !== currentMode) {
start(targetMode); // 自動切換 interval
}
});
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void pollOnce(); // 立即 probe
const s = useSystemStore.getState();
const mode = s.forcedOffline || s.consecutiveFailures >= 2 ? 'active-retry' : 'normal';
start(mode);
} else {
stop();
}
};
// 初次載入時執行一次 probe + 啟動 normal 輪詢
void pollOnce();
if (document.visibilityState === 'visible') {
start('normal');
}
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
unsubscribe();
stop();
};
}, []);
}
4.3a frontend/src/hooks/use-shutdown-watcher.ts(Minor 4 新增)
訂閱 server 的 WebSocket 廣播,接收 server:shutdown-imminent 事件後立即設 forcedOffline。
'use client';
import { useEffect } from 'react';
import { useSystemStore } from '@/stores/system-store';
import { getBackendUrl } from '@/lib/api';
interface ShutdownImminentMessage {
type: 'server:shutdown-imminent';
reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit';
ts: number;
}
export function useShutdownWatcher(): void {
useEffect(() => {
const wsUrl = getBackendUrl().replace(/^http/, 'ws') + '/ws/system';
let ws: WebSocket | null = null;
let reconnectTimer: number | null = null;
const connect = () => {
try {
ws = new WebSocket(wsUrl);
} catch {
scheduleReconnect();
return;
}
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data));
if (msg?.type === 'server:shutdown-imminent') {
const reason = (msg as ShutdownImminentMessage).reason;
useSystemStore.getState().forceOffline(reason);
}
} catch {
// 非 JSON 或格式錯誤,忽略
}
};
ws.onclose = (ev) => {
ws = null;
// v2.1:WebSocket close 事件也視為 server 已斷,立即顯示 overlay
// (比等 polling 失敗 20 s 更即時;若 server 正常重啟,成功 poll 後會自動清 overlay)
// 只在「曾經連上」的前提下才觸發,避免初次連線就失敗導致誤觸發
if (ev && ev.wasClean === false) {
const reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit' = 'manual-stop';
useSystemStore.getState().forceOffline(reason);
}
scheduleReconnect();
};
ws.onerror = () => {
// 忽略 — 後續 onclose 會處理
};
};
const scheduleReconnect = () => {
if (reconnectTimer !== null) return;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
if (document.visibilityState === 'visible') {
connect();
}
}, 3000);
};
connect();
return () => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null;
ws.onerror = null;
ws.close();
ws = null;
}
};
}, []);
}
server 端配合(server-lifecycle.md §8):ctrl.Stop() 開始時透過 server 的 WebSocket hub(建議新增 /ws/system endpoint 或沿用現有 /ws/server-logs)廣播該事件。
注意:此 hook 的 WebSocket 是從瀏覽器連到 server,與 Wails IPC 無關。Wails 端只需要在 ctrl.Stop() 時透過 HTTP / gRPC / 直接從 server 內呼叫 hub.Broadcast(...) 即可(具體做法視 server 內部 API 而定)。
4.4 frontend/src/components/server-offline-overlay.tsx
'use client';
import { useSystemStore } from '@/stores/system-store';
import { useTranslation } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react';
async function retryOnce() {
// 重試 = 立即打一次 boot-id,若成功會自動清 failures
try {
const res = await fetch('/api/system/boot-id', { cache: 'no-store' });
if (res.ok) {
const json = await res.json();
if (json.success && json.data?.bootId) {
useSystemStore.getState().setOnline(json.data.bootId);
return;
}
}
useSystemStore.getState().recordFailure();
} catch {
useSystemStore.getState().recordFailure();
}
}
export function ServerOfflineOverlay() {
const show = useSystemStore((s) => s.forcedOffline || s.consecutiveFailures >= 2);
const reason = useSystemStore((s) => s.forcedOfflineReason);
const { t } = useTranslation();
if (!show) return null;
return (
<div
role="alertdialog"
aria-labelledby="server-offline-title"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/65 backdrop-blur-sm"
>
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-2xl">
<div className="flex items-start gap-4">
<AlertTriangle className="h-8 w-8 shrink-0 text-amber-500" />
<div className="flex-1">
<h2 id="server-offline-title" className="mb-2 text-xl font-semibold">
{t('serverOffline.title')}
</h2>
<p className="mb-4 text-sm text-muted-foreground">
{t('serverOffline.body')}
</p>
<ul className="mb-4 list-disc pl-5 text-sm text-muted-foreground">
<li>{t('serverOffline.reasons.appClosed')}</li>
<li>{t('serverOffline.reasons.manuallyStopped')}</li>
<li>{t('serverOffline.reasons.crashed')}</li>
</ul>
<p className="mb-4 text-sm text-muted-foreground">
{t('serverOffline.instruction')}
</p>
<div className="flex gap-2">
<Button onClick={retryOnce}>{t('serverOffline.retry')}</Button>
<Button
variant="outline"
onClick={() => window.close()}
>
{t('serverOffline.closeTab')}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
4.5 frontend/src/app/layout.tsx 修改
+import { ServerOfflineOverlay } from '@/components/server-offline-overlay';
+import { BootIdWatcherMount } from '@/components/boot-id-watcher-mount';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html ...>
<body ...>
{/* 既有的 providers / sidebar / theme 等 */}
{children}
+ <BootIdWatcherMount />
+ <ServerOfflineOverlay />
</body>
</html>
);
}
BootIdWatcherMount 是一個空的 'use client' 元件,作用只是掛 useBootIdWatcher()(hook 不能直接在 layout 的 server component 呼叫):
// frontend/src/components/boot-id-watcher-mount.tsx
'use client';
import { useBootIdWatcher } from '@/hooks/use-boot-id-watcher';
import { useShutdownWatcher } from '@/hooks/use-shutdown-watcher';
export function BootIdWatcherMount() {
useBootIdWatcher();
useShutdownWatcher(); // Minor 4:WebSocket shutdown-imminent 訂閱
return null;
}
4.6 i18n 字串
frontend/src/lib/i18n/types.ts 新增:
serverOffline: {
title: string;
body: string;
reasons: {
appClosed: string;
manuallyStopped: string;
crashed: string;
};
instruction: string;
retry: string;
closeTab: string;
};
frontend/src/lib/i18n/zh-TW.ts:
serverOffline: {
title: 'Server 已離線',
body: 'visionA Local 的本機伺服器沒有回應。可能的原因:',
reasons: {
appClosed: '你關閉了 visionA Local 應用程式',
manuallyStopped: 'Server 被手動停止',
crashed: 'Server 發生錯誤',
},
instruction: '請重新開啟 visionA Local 應用程式後按「重試」。',
retry: '重試',
closeTab: '關閉這個頁面',
},
frontend/src/lib/i18n/en.ts:
serverOffline: {
title: 'Server Offline',
body: 'The visionA Local server is not responding. Possible reasons:',
reasons: {
appClosed: 'You closed the visionA Local app',
manuallyStopped: 'Server was manually stopped',
crashed: 'Server encountered an error',
},
instruction: 'Please relaunch visionA Local and click "Retry".',
retry: 'Retry',
closeTab: 'Close this tab',
},
5. SSR 相容性
Next.js 的 static export 會在 build 階段把頁面 render 成 HTML。以下程式碼都必須處理 SSR(typeof window === 'undefined' 情境):
use-boot-id-watcher.ts— 標'use client',只在瀏覽器 run,OKserver-offline-overlay.tsx— 標'use client',OKsystem-store.ts— Zustand 不需'use client',但 store 只被 client component 使用,OKboot-id-watcher-mount.tsx—'use client',OK
驗收:pnpm build 在 frontend/ 目錄下要成功,沒有 ReferenceError: window is not defined 或其他 SSR 錯誤。
6. 與 ServerController 事件的關係(v2.1 更新)
Wails 控制台的 log panel 會收到 server:state-change 事件並更新狀態卡片;瀏覽器 tab 無法收到 Wails event(不同 context)。瀏覽器的真相來源有三個,優先序由高到低:
- WebSocket
server:shutdown-imminent(最即時,v2.1 Minor 4 新增):Wails 的ctrl.Stop()在開始 SIGTERM 前透過 server 的 WebSocket hub 廣播,瀏覽器 tab 幾乎同時收到 → 立即顯示 overlay GET /api/system/boot-idpolling:- 成功 + bootId 變 →
window.location.reload()(server 重啟後同 port) - 連續 2 次失敗 → 顯示 overlay(fallback,當 WebSocket 已斷或未建立)
- 成功 + bootId 變 →
- 業務 API 呼叫的 network error:次要,不當作 overlay 觸發器(避免單一 API 問題誤觸發)
Wails → server → WebSocket → 瀏覽器 的路徑:Wails 只與 server 進程通訊(透過 stdin/stdout 或 HTTP),server 再轉發到 WebSocket hub 廣播給所有連線 tab。這是單向 push,比 polling 更即時。
7. 驗收條件
| 檢查 | 操作 | 預期 |
|---|---|---|
| 正常啟動沒 overlay | 開 app → 點 Open in Browser → tab 載入 | overlay 不顯示 |
| 關 Wails → 立即顯示 overlay(WebSocket 管道) | 開 app → Open in Browser → 關 Wails 視窗 | < 1 s 內 tab 顯示 overlay(WebSocket shutdown-imminent 或 onclose 觸發) |
| 關 Wails → polling fallback | 同上但 WebSocket 未建立(mock) | 20 s 內 tab 顯示 overlay(連續 2 次 10 s poll 失敗) |
| 關 Wails → 瀏覽器 tab 顯示正確訊息 | 同上 | 看到 title「Server 已離線」 |
| Active retry 模式自動重試 | Overlay 顯示後 | 前端自動每 3 s 打 boot-id(非使用者手動按) |
| Overlay 重試按鈕 | server 關著時點「重試」 | failures +1 或維持(視結果),overlay 仍在 |
| 重試 → 成功 | 關 app → 等 overlay → 重開 app | Active retry 3 s 內自動 dismiss overlay;若 bootId 變則 reload |
| 控制台按 Restart → 自動 reload | server running → 在 Wails 按 Restart → 3 s 後 server 回來 | tab 自動 window.location.reload(),載入後正常;期間不顯示 overlay(restart reason 延遲 10 s) |
| 控制台按 Stop → 立即 overlay | Stop | WebSocket 廣播 manual-stop → 立即顯示 overlay |
| 背景 tab 不浪費資源 | 切到別的 tab → chrome://performance-metrics |
tab 不在時 setInterval 停(visibility hidden) |
| 切回背景 tab → 立即 probe | 切回 tab | 立即打 boot-id,不等 10 s interval |
| 網路 glitch < 10 s 不顯示 | 人工用 DevTools Network → Offline 8 s → Online | overlay 不顯示(最多 1 次失敗,未達 2 次門檻) |
8. 待確認
window.close()在非window.open()開的 tab 不能用 — 使用者從 Wails Open in Browser 進來的 tab 確實是 browser 自己開的,不是window.open,Chrome 會拒絕window.close()。備案:把「關閉這個頁面」按鈕改為顯示「請手動關閉此分頁」提示。M8-7 執行者實測後決定 UI 文案。- boot-id cache header — 目前用
cache: 'no-store',但某些 Chrome 版本會忽略。若發現 polling 拿到快取值(bootId 不變),改加?_=Date.now()query 防快取。 - Overlay z-index 衝突 — 9999 高於 shadcn Dialog 的 50,應夠。若未來有其他「更上層」元件要放,確認 z-index layering。