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

12 KiB
Raw Blame History

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(改動檔零 errorbuild 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。
  • DRYpollOnceL146retryServerHealthL347都走同一 helper沒有重複邏輯。
  • Response shape 正確BootIdResponse typeL46-52完整對應 TDD §9.2 { success, data: { bootId, startedAt } }。L140 / L342 對 json.successjson.data?.bootId 雙重驗證,若缺欄位改走 recordFailure,不會把 undefined 丟進比對路徑。
  • 回傳語意true = 繼續後續流程first / matchfalse = 已觸發 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 1catch 空 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 的 recordFailurepolling → overlay 行為不變。
  • WebSocket server:shutdown-imminentL225-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 節奏。
  • retryServerHealthL328-358M8-7 既有函式)被 patch 成走 handleBootIdCheck,對 overlay 的「手動 retry 按鈕」呼叫者透明。

既有測試M8-7、model-store、lib/api全數通過 → 零回歸。

E. Test 品質

Store testssystem-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 testsuse-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 + markOnlineL101-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測試間無耦合。

SuggestionSSR 情境(typeof window === 'undefined')走 false pathL95-98目前 hook test 不覆蓋。因為 jsdom 下必定有 window要另寫 node env test。優先級低M8-9 可接受不補。

Minor 2task brief 提到「sessionStorage 失敗私密視窗catch 後仍 reload」的邊界 case但 hook test 5 個中沒有直接模擬 sessionStorage.setItem throw 的情境。實作正確L100-115 catch fall through 到 reload但無 regression guard。建議補一個 testsessionStorage.setItemvi.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-existinguse-server-health.tsuse-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 正確標記。
  • windowsessionStoragewindow.location.reload 全部在 useEffectL155handleBootIdCheck 函式內使用,且 L95-98 有 typeof window === 'undefined' guard雙重保險因為 hook 只會在 client 執行但 retryServerHealth 可能被 server component 誤呼)。
  • retryServerHealth 走同一 handleBootIdCheckSSR 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 連續 ECONNREFUSEDrecordFailure 累計 → consecutiveFailures ≥ 2 → subscribe callbackL186-201自動切 active-retry。
  4. Server re-spawn → 新 boot-id。
  5. 下一次 pollOnceres.ok + json.success + data.bootIdhandleBootIdCheckstore 裡舊 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 deferdefer 只在 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 → recordFailureconsecutiveFailures = 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_THRESHOLDL237會判定為 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 個 testreloadSpy 仍呼叫 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 + reloadpollOnce / retryServerHealth 共用同一路徑。
  • Reload loop guardsessionStorage在正常 / 異常 / 隱私視窗三種情境下都有正確 fall back不會卡 loop。
  • M8-7 既有邏輯polling 模式切換、WebSocket、visibility、restart defer全數不變19 個 tests 全綠零回歸。
  • 三個關鍵場景Restart / Quit / glitch讀 code 推論路徑全部正確,特別是 Quit 不會誤觸發 reloadhandleBootIdCheck 進入條件嚴格、glitch 同 bootId 會乾淨 recover。
  • tsc / build / 改動檔 lint 全數 PASSpre-existing lint 問題不在本次範疇。

結論: 通過,不阻擋 M8-10。2 個 Minor 與 1 個 Suggestion 可視為技術債,建議列入 M8-10 之後的測試補強清單處理,非必修。