visionA/local-tool/.autoflow/04-architecture/v2/web-ui-offline-overlay.md
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

28 KiB
Raw Blame History

v2/web-ui-offline-overlay.md — Web UI Server Offline Overlay

所屬TDD v2 §2.6 版本v2.12026-04-14 吸收 Minor 4 WebSocket shutdown-imminent 廣播) 決策依據R5-2關閉 Wails 視窗 = server 停,瀏覽器顯示 offline overlay、三方共識 #14boot-id + retry、PM Minor 4WebSocket 廣播即時觸發 overlay消除 race condition 對應 milestoneM8-7 相關文件:v2/server-lifecycle.md §8.3Wails 端 WebSocket 廣播時機、§9server 端 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 應用程式後按「重試」。        │
│                                                     │
│         [重試]        [關閉這個頁面]                   │
│                                                     │
└─────────────────────────────────────────────────────┘
  • 全螢幕半透明 backdroprgba(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 顯示由三個獨立管道觸發,任一觸發即顯示(優先序由高到低):

  1. WebSocket close eventv2.1 新增):瀏覽器端 WebSocket 收到 onclose立即顯示 overlay不等 polling理由close 事件代表 server 端已斷polling 也一定會失敗,不需要等 20 秒
  2. WebSocket server:shutdown-imminent 事件Minor 4:收到廣播訊息 → 立即顯示 overlay並依 reason 欄位決定文案:
    • reason="app-closing" / reason="manual-stop" / reason="quit" → 立即顯示
    • reason="restart" → 延遲 10 秒顯示(給 boot-id 變化 → window.location.reload() 的時間)
  3. 被動偵測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 polling10 s interval
  ↓
下一次 poll
  ├─ 成功 → 比對 bootId
  │    ├─ 相同 → 無事
  │    └─ 不同 → window.location.reload()server 重啟,強制 reload
  └─ 失敗 → consecutiveFailures++
       ├─ < 2 → 不顯示 overlay避免偶發網路 glitch
       └─ ≥ 2連續 ~20 s→ 顯示 <ServerOfflineOverlay>
                             + 切 polling interval 為 3 sactive 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-closingWails OnShutdown 觸發,應用即將退出 → 立即顯示 overlay文案「visionA Local 已關閉」
  • manual-stop:使用者按控制台的 Stop → 立即顯示 overlay文案「Server 已停止,請在控制台按 Start」
  • quit:使用者從 Wails 選單選 QuitmacOS Cmd+Q 等)→ 立即顯示 overlay文案同 app-closing
  • restart:使用者按控制台的 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 sboot-id 會變但也不會連續 2 次失敗 → 下一次成功 poll 偵測 boot-id 變化 → force reload
  • 網路偶發抖動(< 10 s 單次 glitch→ 最多 1 次 failure下一次 poll 成功即清零,不觸發 overlay

3.3 Page Visibility APIR-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 storeserverOnline / 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 新增 全域覆蓋層 UIshadcn 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 4restart 情境不立即顯示 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.tsMinor 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.1WebSocket 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 §8ctrl.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 4WebSocket 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。以下程式碼都必須處理 SSRtypeof window === 'undefined' 情境):

  • use-boot-id-watcher.ts — 標 'use client',只在瀏覽器 runOK
  • server-offline-overlay.tsx — 標 'use client'OK
  • system-store.ts — Zustand 不需 'use client',但 store 只被 client component 使用OK
  • boot-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。瀏覽器的真相來源有三個優先序由高到低

  1. WebSocket server:shutdown-imminent最即時v2.1 Minor 4 新增)Wails 的 ctrl.Stop() 在開始 SIGTERM 前透過 server 的 WebSocket hub 廣播,瀏覽器 tab 幾乎同時收到 → 立即顯示 overlay
  2. GET /api/system/boot-id polling
    • 成功 + bootId 變 → window.location.reload()server 重啟後同 port
    • 連續 2 次失敗 → 顯示 overlayfallback當 WebSocket 已斷或未建立)
  3. 業務 API 呼叫的 network error:次要,不當作 overlay 觸發器(避免單一 API 問題誤觸發)

Wails → server → WebSocket → 瀏覽器 的路徑Wails 只與 server 進程通訊(透過 stdin/stdout 或 HTTPserver 再轉發到 WebSocket hub 廣播給所有連線 tab。這是單向 push比 polling 更即時。


7. 驗收條件

檢查 操作 預期
正常啟動沒 overlay 開 app → 點 Open in Browser → tab 載入 overlay 不顯示
關 Wails → 立即顯示 overlayWebSocket 管道) 開 app → Open in Browser → 關 Wails 視窗 < 1 s 內 tab 顯示 overlayWebSocket shutdown-imminentonclose 觸發)
關 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(),載入後正常;期間不顯示 overlayrestart 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. 待確認

  1. window.close() 在非 window.open() 開的 tab 不能用 — 使用者從 Wails Open in Browser 進來的 tab 確實是 browser 自己開的,不是 window.openChrome 會拒絕 window.close()。備案把「關閉這個頁面」按鈕改為顯示「請手動關閉此分頁」提示。M8-7 執行者實測後決定 UI 文案。
  2. boot-id cache header — 目前用 cache: 'no-store',但某些 Chrome 版本會忽略。若發現 polling 拿到快取值bootId 不變),改加 ?_=Date.now() query 防快取。
  3. Overlay z-index 衝突 — 9999 高於 shadcn Dialog 的 50應夠。若未來有其他「更上層」元件要放確認 z-index layering。