依 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>
12 KiB
Reviewer 審查 M8-9 Boot-ID + tab 重連(2026-04-15)
摘要
- 結論:✅ 通過,無 Critical / Major 問題。
- 是否阻擋 M8-10:不阻擋,可直接進入 M8-10。
- 問題統計:Critical 0 / Major 0 / Minor 2 / Suggestion 1
- 親跑:
tscPASS、test19/19 PASS、lint(改動檔零 error)、build12 頁 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 正確:
BootIdResponsetype(L46-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 true,guard 不會被讀到 |
✅ |
異常情境(server 每次回不同 bootId):guard 命中同一 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 空 block(L113-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 defer(L232-242)、visibility change probe(L279-292)、cleanup(L303-323)全部不動。 - Active retry 3 s / normal 10 s 切換邏輯(L186-201)不動,boot-id 比對只發生在「成功取得 response」那條路徑,不影響 retry 節奏。
retryServerHealth(L328-358,M8-7 既有函式)被 patch 成走handleBootIdCheck,對 overlay 的「手動 retry 按鈕」呼叫者透明。
既有測試(M8-7、model-store、lib/api)全數通過 → 零回歸。
E. Test 品質
Store tests(system-store.test.ts)— 4 個
firstcall 情境 → 驗 return +bootId被設(L24-28)✅- 相同 bootId → 驗
'match'+ store 不動(L30-35)✅ - 不同 bootId → 驗
'mismatch'+ store 保留舊值(L37-43)✅ - Reset 後再呼叫 → 重新
'first',模擬 reload 後情境(L45-52)✅
TDD §12 要求的三路徑 + 邊界全數覆蓋。beforeEach(L14-22)確實 reset 所有欄位,隔離性 OK。
Hook tests(use-shutdown-watcher.test.ts)— 5 個
- 首次 fetch → setBootId + markOnline,無 reload(L65-74)✅
- 相同 id fetch → markOnline,無 reload(L76-85)✅
- 異 id fetch →
window.location.reload被呼叫 + guard 寫入 + store 不更新(L87-99)✅ - Loop guard 命中 → 不 reload + store 對齊新 id +
markOnline(L101-113)✅ - Fetch 失敗 →
recordFailure+ 無 reload(L115-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 path(L95-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 推論)
場景 1:Restart Server
- Wails
notifyShutdownImminent(reason=restart)→ WS broadcast。 - Browser tab 收到(
use-shutdown-watcher.ts:225-249)→restartDeferTimer啟動 10 s defer(L234-241),不立刻forceOffline。 - 每 10 s(normal)/ 3 s(active-retry)poll
/api/system/boot-id。Restart 期間 server 約 3 s 起步,fetch 連續ECONNREFUSED→recordFailure累計 → consecutiveFailures ≥ 2 → subscribe callback(L186-201)自動切 active-retry。 - Server re-spawn → 新 boot-id。
- 下一次
pollOnce→res.ok + json.success + data.bootId→handleBootIdCheck:store 裡舊 bootId ≠ 新 bootId → mismatch → guard 寫入 →window.location.reload()。 - 新 tab 載入 → Zustand store reset
bootId = null→ 首次 poll →'first'→setBootId+markOnline→ 正常運作。
路徑正確 ✅
場景 2:Quit
- Wails
notifyShutdownImminent(reason=quit)→ WS broadcast。 - Browser tab 收到 →
mapReason('quit') → 'quit'→ L245forceOffline('quit')立即執行(沒有 10 s defer,defer 只在reason === 'restart'分支,L232)。 - Polling 持續失敗(server 整組關掉)→ overlay 顯示「請重開 app」。
- 每次 poll fail →
recordFailure,不進入handleBootIdCheck(整個 L146 block 要res.ok + success + data.bootId才會進)。 - Server 沒回來,
reloadSpy永不會被呼叫。
路徑正確,不會誤觸發 reload ✅
場景 3:網路 glitch
- 單次 health check fail →
recordFailure,consecutiveFailures = 1,尚未 ≥ 2 門檻 → 不 forceOffline、不 切模式。 - 第二次失敗 → consecutiveFailures = 2 → subscribe callback 補上
forceOffline('healthcheck-failed')(L191-197)→ 切 active retry。 - Active retry 3 s 後 → server 還在 → 拿到同一 boot-id →
handleBootIdCheck回'match'(因為 store.bootId 沒被任何人改)→ L147 的if (!shouldContinue)false → 往下到 L148markOnline()(會同時 reset consecutiveFailures=0 + forcedOffline=false + offlineReason=null)→ subscribe callback 偵測到 shouldActiveRetry=false,下一次 polling tick 自動切回 normal 10 s。 reloadSpy不會被呼叫。
路徑正確,不會誤 reload ✅
額外確認:Restart 路徑下,如果 boot-id 變化先於 restartDeferTimer 10 s 到期 → handleBootIdCheck 會直接 reload,restartDeferTimer 還在 running。useEffect cleanup(L312-315)會 clear timer;但 reload 觸發後頁面立刻卸載,timer 會被 jsdom / 瀏覽器自然清掉,無副作用。若 reload 被 SSR / guard skip(極端情境),restartDeferTimer 仍會 10 s 後執行,此時 forceOffline('restart') 的 guard 檢查 !s.serverOnline || consecutiveFailures >= FAILURE_THRESHOLD(L237)會判定為 false(guard 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')無對應 test;jsdom 下難模擬。可延後到專門的 SSR 測試環境補 |
J. 結論
M8-9 Boot-ID + tab 自動重連實作完整、流程正確、測試充分。
- Store action
checkAndUpdateBootId三路徑語意與 TDD §9.3 一致,mismatch刻意不改 store 的設計與 reload-後-新-tab 重走 first 路徑完美配合。 - Helper
handleBootIdCheckDRY 封裝 guard + reload,pollOnce/retryServerHealth共用同一路徑。 - Reload loop guard(sessionStorage)在正常 / 異常 / 隱私視窗三種情境下都有正確 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全數 PASS;pre-existing lint 問題不在本次範疇。
結論:✅ 通過,不阻擋 M8-10。2 個 Minor 與 1 個 Suggestion 可視為技術債,建議列入 M8-10 之後的測試補強清單處理,非必修。