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

155 lines
12 KiB
Markdown
Raw Permalink 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.

# Reviewer 審查 M8-9 Boot-ID + tab 重連2026-04-15
## 摘要
- 結論:**✅ 通過**,無 Critical / Major 問題。
- 是否阻擋 M8-10**不阻擋**,可直接進入 M8-10。
- 問題統計Critical 0 / Major 0 / Minor 2 / Suggestion 1
- 親跑:`tsc` PASS、`test` 19/19 PASS、`lint`(改動檔零 error`build` 12 頁 PASS。
---
## A. `checkAndUpdateBootId` action
比對 TDD §9.3 規格(`system-store.ts:92-103`
| 行為 | 實作 | 狀態 |
|------|------|------|
| `bootId === null` → 記錄 + 回 `'first'` | L94-97 `set({ bootId: newBootId })` → return `'first'` | ✅ |
| `bootId === newBootId` → 回 `'match'`,不改 store | L98-100 純 return | ✅ |
| `bootId !== newBootId` → 回 `'mismatch'`**刻意不改 store** | L101-102 註解明確說明用意reload 後新 tab 重走 first | ✅ |
| TypeScript `BootIdCheckResult` union 定義 | L18-26 `'first' \| 'match' \| 'mismatch'` + JSDoc 完整 | ✅ |
語意正確,註解與 TDD「mismatch 時不更新 store」的理由一致。`get()` 而非閉包 snapshot避免競態。
## B. `handleBootIdCheck` helper
`use-shutdown-watcher.ts:87-123`
- **封裝完整**:呼叫 store action、處理 loop guard、觸發 reload 全部集中於此 helper。
- **DRY**`pollOnce`L146`retryServerHealth`L347都走同一 helper沒有重複邏輯。
- **Response shape 正確**`BootIdResponse` typeL46-52完整對應 TDD §9.2 `{ success, data: { bootId, startedAt } }`。L140 / L342 對 `json.success``json.data?.bootId` 雙重驗證,若缺欄位改走 `recordFailure`,不會把 undefined 丟進比對路徑。
- **回傳語意**`true` = 繼續後續流程first / match`false` = 已觸發 reload或 SSR / guard skip呼叫端不再 `markOnline`。L90-92 的 early return 避免對「即將 reload」的 tab 做多餘的 markOnline。
## C. Reload loop guard
`use-shutdown-watcher.ts:87-123`
| 檢查項 | 實作 | 狀態 |
|--------|------|------|
| key = `visiona-reload-loop-guard` | L38 `const RELOAD_LOOP_GUARD_KEY` | ✅ |
| 正常情境reload → store reset → 走 first 路徑不誤觸發 | L89 `checkAndUpdateBootId` 先於 guard 比對first 直接 return trueguard 不會被讀到 | ✅ |
| 異常情境server 每次回不同 bootIdguard 命中同一 id → skip reload + `setBootId + markOnline` | L102-111 | ✅ |
| sessionStorage 失敗(隱私模式)→ 仍 reload | L100-115 整段包 `try { ... } catch {}`catch 為空但之後 fall through 到 L117-121 `window.location.reload()` | ✅ |
| guard key 寫入時機:**reload 之前** | L112 `setItem` → L121 `reload()`,順序正確 | ✅ |
| `typeof window === 'undefined'` → SSR 保險直接 return false | L95-98 | ✅ |
**Minor 1**catch 空 blockL113-115沒有任何 log若 sessionStorage 存取失敗(即便在隱私視窗)開發者將看不到訊號。建議加 `console.warn('[boot-id] sessionStorage unavailable, reload guard disabled')`,只是提醒用,不阻擋 reload。不影響行為不阻擋合併。
## D. 和 M8-7 的 integration
- `pollOnce` (L126-152):保留原流程,新增路徑僅是在 `res.ok + json.success + data.bootId` 全部成立後插入 `handleBootIdCheck`;失敗分支完全沿用 M8-7 的 `recordFailure`polling → overlay 行為不變。
- WebSocket `server:shutdown-imminent`L225-249、restart 10 s deferL232-242、visibility change probeL279-292、cleanupL303-323全部不動。
- Active retry 3 s / normal 10 s 切換邏輯L186-201不動boot-id 比對只發生在「成功取得 response」那條路徑不影響 retry 節奏。
- `retryServerHealth`L328-358M8-7 既有函式)被 patch 成走 `handleBootIdCheck`,對 overlay 的「手動 retry 按鈕」呼叫者透明。
既有測試M8-7、model-store、lib/api全數通過 → 零回歸。
## E. Test 品質
### Store tests`system-store.test.ts`)— 4 個
1. `first` call 情境 → 驗 return + `bootId` 被設L24-28
2. 相同 bootId → 驗 `'match'` + store 不動L30-35
3. 不同 bootId → 驗 `'mismatch'` + store 保留舊值L37-43
4. Reset 後再呼叫 → 重新 `'first'`,模擬 reload 後情境L45-52
TDD §12 要求的三路徑 + 邊界全數覆蓋。beforeEachL14-22確實 reset 所有欄位,隔離性 OK。
### Hook tests`use-shutdown-watcher.test.ts`)— 5 個
1. 首次 fetch → setBootId + markOnline無 reloadL65-74
2. 相同 id fetch → markOnline無 reloadL76-85
3. 異 id fetch → `window.location.reload` 被呼叫 + guard 寫入 + store 不更新L87-99
4. Loop guard 命中 → 不 reload + store 對齊新 id + `markOnline`L101-113
5. Fetch 失敗 → `recordFailure` + 無 reloadL115-124
`mockFetchBootId` helper 乾淨,`vi.stubGlobal('fetch', ...)` 正確。`Object.defineProperty(window, 'location', ...)` 是 jsdom 下替換 `location.reload` 的標準做法jsdom 的 `window.location` 不可直接賦值)。`afterEach` L61-63 `vi.restoreAllMocks()` 搭配 beforeEach 清除 `sessionStorage` + 重置 store測試間無耦合。
**Suggestion**SSR 情境(`typeof window === 'undefined'`)走 false pathL95-98目前 hook test 不覆蓋。因為 jsdom 下必定有 window要另寫 node env test。優先級低M8-9 可接受不補。
**Minor 2**task brief 提到「sessionStorage 失敗私密視窗catch 後仍 reload」的邊界 case但 hook test 5 個中沒有直接模擬 `sessionStorage.setItem` throw 的情境。實作正確L100-115 catch fall through 到 reload但無 regression guard。建議補一個 test`sessionStorage.setItem``vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error(); })` → 驗 `reloadSpy` 仍被呼叫。非阻擋性,可列入 M8-10 之後的測試補強清單。
## F. Build / Test / Lint 結果
| 指令 | 結果 |
|------|------|
| `pnpm tsc --noEmit` | ✅ 零錯誤(無任何輸出) |
| `pnpm test` | ✅ 4 test files / 19 tests 全綠(含本次新增 4+5 |
| `pnpm lint`(全 repo | ⚠️ 16 問題全部為 **pre-existing**`use-server-health.ts``use-websocket.ts` 等檔),**M8-9 改動的 4 個檔零 error / 零 warning** |
| `pnpm build` | ✅ Compiled successfully 5.5s / 12 頁 SSG 全數 prerender 完成 |
既有 repo lint 問題不在本次審查範疇,不阻擋 M8-9。
## G. SSR 相容
- `'use client'` directive 在 L1 正確標記。
- `window``sessionStorage``window.location.reload` 全部在 `useEffect`L155`handleBootIdCheck` 函式內使用,且 L95-98 有 `typeof window === 'undefined'` guard雙重保險因為 hook 只會在 client 執行但 `retryServerHealth` 可能被 server component 誤呼)。
- `retryServerHealth` 走同一 `handleBootIdCheck`SSR guard 也保護它。
- Next.js `pnpm build` 的 SSG prerender 12 頁全部成功,證實 build 階段不會觸發 `window is not defined`
## H. 完整 flow 驗證(讀 code 推論)
### 場景 1Restart Server
1. Wails `notifyShutdownImminent(reason=restart)` → WS broadcast。
2. Browser tab 收到(`use-shutdown-watcher.ts:225-249`)→ `restartDeferTimer` 啟動 10 s deferL234-241**不立刻** `forceOffline`
3. 每 10 snormal/ 3 sactive-retrypoll `/api/system/boot-id`。Restart 期間 server 約 3 s 起步fetch 連續 `ECONNREFUSED``recordFailure` 累計 → consecutiveFailures ≥ 2 → subscribe callbackL186-201自動切 active-retry。
4. Server re-spawn → 新 boot-id。
5. 下一次 `pollOnce``res.ok + json.success + data.bootId``handleBootIdCheck`store 裡舊 bootId ≠ 新 bootId → mismatch → guard 寫入 → `window.location.reload()`
6. 新 tab 載入 → Zustand store reset `bootId = null` → 首次 poll → `'first'``setBootId` + `markOnline` → 正常運作。
路徑正確 ✅
### 場景 2Quit
1. Wails `notifyShutdownImminent(reason=quit)` → WS broadcast。
2. Browser tab 收到 → `mapReason('quit') → 'quit'` → L245 `forceOffline('quit')` 立即執行(**沒有 10 s defer**defer 只在 `reason === 'restart'` 分支L232
3. Polling 持續失敗server 整組關掉)→ overlay 顯示「請重開 app」。
4. 每次 poll fail → `recordFailure`**不進入** `handleBootIdCheck`(整個 L146 block 要 `res.ok + success + data.bootId` 才會進)。
5. Server 沒回來,`reloadSpy` 永不會被呼叫。
路徑正確,不會誤觸發 reload ✅
### 場景 3網路 glitch
1. 單次 health check fail → `recordFailure`consecutiveFailures = 1尚未 ≥ 2 門檻 → **不** forceOffline、**不** 切模式。
2. 第二次失敗 → consecutiveFailures = 2 → subscribe callback 補上 `forceOffline('healthcheck-failed')`L191-197→ 切 active retry。
3. Active retry 3 s 後 → server 還在 → 拿到**同**一 boot-id → `handleBootIdCheck``'match'`(因為 store.bootId 沒被任何人改)→ L147 的 `if (!shouldContinue)` false → 往下到 L148 `markOnline()`(會同時 reset consecutiveFailures=0 + forcedOffline=false + offlineReason=null→ subscribe callback 偵測到 shouldActiveRetry=false下一次 polling tick 自動切回 normal 10 s。
4. `reloadSpy` 不會被呼叫。
路徑正確,不會誤 reload ✅
**額外確認**Restart 路徑下,如果 boot-id 變化先於 restartDeferTimer 10 s 到期 → `handleBootIdCheck` 會直接 reloadrestartDeferTimer 還在 running。`useEffect` cleanupL312-315會 clear timer但 reload 觸發後頁面立刻卸載timer 會被 jsdom / 瀏覽器自然清掉,無副作用。若 reload 被 SSR / guard skip極端情境restartDeferTimer 仍會 10 s 後執行,此時 `forceOffline('restart')` 的 guard 檢查 `!s.serverOnline || consecutiveFailures >= FAILURE_THRESHOLD`L237會判定為 falseguard skip 路徑裡 markOnline 已被呼叫)→ 不會誤顯示 overlay。健壯。
## I. 問題清單
### Critical
無。
### Major
無。
### Minor
| # | 檔案 | 行 | 問題 | 建議 |
|---|------|----|------|------|
| 1 | `frontend/src/hooks/use-shutdown-watcher.ts` | 113-115 | sessionStorage try/catch 吞 error 無 log異常發生時無法從 console 察覺 | catch 內加 `console.warn('[boot-id] sessionStorage unavailable, reload guard disabled')`,不改 control flow |
| 2 | `frontend/src/tests/hooks/use-shutdown-watcher.test.ts` | — | 無 test 覆蓋「sessionStorage.setItem throw → 仍 reload」邊界 | 用 `vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error(); })` 補 1 個 test`reloadSpy` 仍呼叫 1 次 |
### Suggestion
| # | 檔案 | 行 | 建議 |
|---|------|----|------|
| 1 | `frontend/src/hooks/use-shutdown-watcher.ts` | 95-98 | SSR guard 分支(`typeof window === 'undefined'`)無對應 testjsdom 下難模擬。可延後到專門的 SSR 測試環境補 |
## J. 結論
M8-9 Boot-ID + tab 自動重連實作完整、流程正確、測試充分。
- Store action `checkAndUpdateBootId` 三路徑語意與 TDD §9.3 一致,`mismatch` 刻意不改 store 的設計與 reload-後-新-tab 重走 first 路徑完美配合。
- Helper `handleBootIdCheck` DRY 封裝 guard + reload`pollOnce` / `retryServerHealth` 共用同一路徑。
- Reload loop guardsessionStorage在正常 / 異常 / 隱私視窗三種情境下都有正確 fall back不會卡 loop。
- M8-7 既有邏輯polling 模式切換、WebSocket、visibility、restart defer全數不變19 個 tests 全綠零回歸。
- 三個關鍵場景Restart / Quit / glitch讀 code 推論路徑全部正確,特別是 Quit 不會誤觸發 reload`handleBootIdCheck` 進入條件嚴格、glitch 同 bootId 會乾淨 recover。
- `tsc` / `build` / 改動檔 `lint` 全數 PASSpre-existing lint 問題不在本次範疇。
**結論:✅ 通過,不阻擋 M8-10**。2 個 Minor 與 1 個 Suggestion 可視為技術債,建議列入 M8-10 之後的測試補強清單處理,非必修。