依 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>
1007 lines
41 KiB
Markdown
1007 lines
41 KiB
Markdown
# v2/server-lifecycle.md — Server 生命週期與 Boot-ID 機制
|
||
|
||
> 所屬:TDD v2 §2.3
|
||
> 版本:v2.1(2026-04-14 吸收 PM 審閱 + R5-D + R5-E)
|
||
> 決策依據:R5-2(視窗關閉 = 結束 server,瀏覽器顯示 offline overlay)、R5-4(首次自動開瀏覽器)、R5-D1(OS 崩潰通知並存)、R5-D2(Linux 預設 auto-open OFF)、R5-D3(每次 Start 成功都開瀏覽器)、R5-E(階段化啟動 60 s 上限)、PM Q4(shutdown 7+1 秒)、三方共識 #10(watchServer Error state)、#14(boot-id 重連)
|
||
> 對應 milestone:M8-4(state machine + bindings)、M8-4b(階段化啟動管線,見 `v2/startup-pipeline.md`)、M8-9(boot-id)
|
||
> 相關文件:`v2/control-panel.md`(state machine 定義於該處)、`v2/web-ui-offline-overlay.md`(瀏覽器端)、`v2/startup-pipeline.md`(R5-E 階段化啟動)
|
||
|
||
---
|
||
|
||
## 1. 目的
|
||
|
||
把 v1 的「隱式啟動、隱式 watch、隱式關閉」升級為明確的 Start / Stop / Restart / Error 狀態機,並建立 **boot-id** 機制讓瀏覽器 tab 能偵測 server 重啟後自動 reload。
|
||
|
||
---
|
||
|
||
## 2. 完整生命週期時間軸
|
||
|
||
### 2.1 App 冷啟動(R5-4 + R5-D3 + R5-E 階段化)
|
||
|
||
**R5-E 上限**:整段啟動流程不得超過 **60 s**,否則進 Error state。watcher goroutine 負責監視並發 `startup:error` event。
|
||
|
||
**R5-E 階段分配與預算**(樂觀 / 悲觀 / 最壞,單位秒):
|
||
|
||
| # | 階段 | labelKey | 樂觀 | 悲觀 | 最壞 | Soft Timeout(20 s) |
|
||
|---|------|---------|-----|-----|------|---------------------|
|
||
| 1 | 初始化 Wails 控制台 | `startup.stage.1.label` | 0.1 | 0.2 | 0.5 | 不太可能超 |
|
||
| 2 | 檢查 Python runtime | `startup.stage.2.label` | 0.5 | 1.5 | 4.0 | 可能超(首次 wheels extract) |
|
||
| 3 | 啟動本機伺服器 | `startup.stage.3.label` | 1.5 | 3.0 | 5.0 | 不太可能超 |
|
||
| 4 | 偵測 Kneron 裝置 | `startup.stage.4.label` | 0.5 | 1.5 | 2.0 | 不太可能超 |
|
||
| 5 | 開啟瀏覽器 | `startup.stage.5.label` | 0.2 | 0.5 | 1.0 | 不太可能超 |
|
||
| 6 | 等待 Web UI 連線 | `startup.stage.6.label` | 0.8 | 1.5 | 3.0 | 可能超(慢裝置) |
|
||
| — | **合計** | — | **3.6** | **8.2** | **15.5** | 硬上限 60 s |
|
||
|
||
(原 PM §11-2 提出的「10 秒可達性」已被 R5-E 取代為 60 s 上限 + 階段化進度;上方估算沿用 v2.0 互審的分析結果。)
|
||
|
||
**冷啟動(首次安裝後第一次)時間軸**:
|
||
|
||
```
|
||
t=0.000 使用者雙擊 visionA-local.app / .exe / .AppImage
|
||
t=0.000 Wails OnStartup 觸發
|
||
└─ pipeline = NewStartupPipeline(); pipeline.Start(1) emit startup:progress(stage=1, running)
|
||
t=0.010 migrateOldDataDirs() ← app.go:145 沿用
|
||
t=0.020 acquireSingleInstance(dataDir) ← app.go:154 沿用
|
||
t=0.030 startIPCServer() ← app.go:174 沿用
|
||
t=0.080 seedUserDataDir() ← app.go:182 沿用(data dir seed 8 個預置 .nef)
|
||
t=0.090 pipeline.Complete(1) emit startup:progress(stage=1, completed)
|
||
t=0.090 pipeline.Start(2) emit startup:progress(stage=2, running)
|
||
t=0.100 ctrl.Start() ← 新:startup 最後呼叫 ServerController.Start
|
||
├─ state: Stopped → Starting emit server:state-change
|
||
├─ ensurePythonRuntime() ← 沿用 v1(stage 2 的主要耗時)
|
||
t=1.600 pipeline.Complete(2) emit startup:progress(stage=2, completed)
|
||
├─ pipeline.Start(3) emit startup:progress(stage=3, running)
|
||
├─ ensureDriverInstalled() ← 沿用 v1(Windows)
|
||
├─ pickPort(3721) ← 沿用 v1(首次啟動允許 fallback 到 3722+)
|
||
├─ exec.Command(server binary, --host 127.0.0.1 --port N --data-dir D)
|
||
├─ cmd.StdoutPipe() + cmd.StderrPipe()
|
||
├─ cmd.Start()
|
||
├─ go logPump(stdoutPipe, "stdout", stdoutFile, ...)
|
||
├─ go logPump(stderrPipe, "stderr", stderrFile, ...)
|
||
├─ waitHealthy(port, 30s) ← 沿用 v1
|
||
├─ writeIPCPort(dataDir, port) ← 沿用 v1
|
||
├─ go watchServer(...) ← 沿用 v1(但失敗行為改成 Error state + OS 通知)
|
||
└─ state: Starting → Running emit server:state-change
|
||
t=4.100 pipeline.Complete(3) emit startup:progress(stage=3, completed)
|
||
t=4.100 pipeline.Start(4) emit startup:progress(stage=4, running)
|
||
t=4.600 GET /api/devices 第一次回應 (無硬體秒回)
|
||
t=4.600 pipeline.Complete(4) emit startup:progress(stage=4, completed)
|
||
t=4.600 pipeline.Start(5) emit startup:progress(stage=5, running)
|
||
└─ 檢查 prefs.AutoOpenBrowser(R5-D3:每次 Start 成功都開;預設依 R5-D2 分平台)
|
||
macOS/Windows 預設 true → OpenInBrowser("")
|
||
Linux 預設 false → 不開
|
||
t=4.700 pipeline.Complete(5) emit startup:progress(stage=5, completed)
|
||
t=4.700 pipeline.Start(6) emit startup:progress(stage=6, running)
|
||
t=4.700 瀏覽器 tab 開始載入 Next.js SPA
|
||
t=5.500 Next.js fetch /api/system/boot-id → 記錄 initial boot-id
|
||
t=5.500 Next.js 建立 WebSocket 連線 → server 的 WebSocket hub OnClientConnected callback
|
||
t=5.500 pipeline.Complete(6) emit startup:progress(stage=6, completed)
|
||
pipeline.Ready() emit startup:ready
|
||
t=5.500 Wails 控制台淡出啟動進度面板(300 ms ease)→ 主控台 UI 顯示
|
||
```
|
||
|
||
### 2.1a 日常啟動(非首次)
|
||
|
||
| 情境 | 階段 2 (Python) | 階段 3 (server) | 其他 | 總計(樂觀) |
|
||
|------|----------------|----------------|------|-------------|
|
||
| 首次安裝 | 1.5 s(wheels extract) | 3.0 s | 3.0 s | ~7.5 s |
|
||
| **日常啟動** | 0.3 s(wheels 已 extract) | 1.5 s(binary warm) | 2.0 s | **~3.8 s** |
|
||
|
||
**結論**:日常啟動預期 2-4 秒可就緒;滿足 PM AC-2.1「日常啟動 ≤ 5 s」的目標。慢速裝置或帶毒掃描軟體可能到 6-8 秒,仍遠低於 60 s 硬上限。
|
||
|
||
### 2.2 使用者按 Restart
|
||
|
||
```
|
||
t=0.000 使用者在控制台按 [⟲ Restart]
|
||
t=0.005 JS: RestartServer() binding 呼叫
|
||
t=0.010 Go: ServerController.Restart()
|
||
├─ Stop():
|
||
│ ├─ state: Running → Stopping emit server:state-change
|
||
│ ├─ SIGTERM server subprocess
|
||
│ ├─ wait exit (max 5s)
|
||
│ ├─ 若 timeout → SIGKILL
|
||
│ ├─ proc = nil
|
||
│ └─ state: Stopping → Stopped emit server:state-change
|
||
t=0.500 Stop() 完成(server 優雅退出通常 < 1 s)
|
||
└─ Start():
|
||
├─ state: Stopped → Starting
|
||
├─ spawn 新 server 子程序(新 boot-id!)
|
||
├─ waitHealthy
|
||
└─ state: Starting → Running
|
||
t=3.000 新 server Running
|
||
|
||
瀏覽器 tab 同時間:
|
||
t=0.500 ~ t=3.000 期間:polling /api/system/boot-id → ECONNREFUSED(連續失敗)
|
||
連續失敗 < 3 次 → 不顯示 Offline Overlay
|
||
t=3.000 polling 下一次成功 → 回傳新 boot-id
|
||
偵測到 boot-id 變了 → window.location.reload()
|
||
t=3.200 瀏覽器 reload 完,UI 重新連上新 server
|
||
```
|
||
|
||
**關鍵設計**:Restart 期間瀏覽器 tab 的 ECONNREFUSED 不立即顯示 overlay(須連續 3 次失敗 = 15 s),而正常 Restart 約 3 s 完成,這段期間使用者看到的是 Next.js 的 loading state / 既有畫面,而非 overlay。Restart 完後 boot-id 變了,瀏覽器 force reload。
|
||
|
||
### 2.3 使用者關閉 Wails 視窗(R5-2 + PM Q4 + Minor 4 WebSocket 廣播)
|
||
|
||
**PM Q4 決策**:shutdown grace period = **7 秒**,且在開始 SIGTERM 的 **1 秒內**若仍未完成,顯示「停止中…」modal(toast/overlay 形式)告知使用者。
|
||
|
||
**Minor 4**:SIGTERM 之前先透過 WebSocket 對所有已連線的 Web UI tab 廣播 `server:shutdown-imminent` 事件,讓瀏覽器 tab 立刻顯示 Offline Overlay,避免 race condition。
|
||
|
||
```
|
||
t=0.000 使用者按 × 關閉 Wails 視窗
|
||
t=0.000 Wails OnBeforeClose 觸發(v2 新增 handler)
|
||
├─ preventClose = false(R5-2:不 hide-to-tray,真的要關)
|
||
└─ Wails 開始 shutdown 序列
|
||
t=0.005 Wails OnShutdown 觸發(對應 a.shutdown(ctx),沿用 v1 app.go:194-209)
|
||
├─ watchCancel()
|
||
├─ ipcListener.Close()
|
||
├─ removeWailsIPCPort(dataDir)
|
||
├─ ctrl.Stop()
|
||
│ ├─ state: Running → Stopping emit server:state-change
|
||
│ ├─ Minor 4:透過 server 的 WebSocket hub 廣播 server:shutdown-imminent
|
||
│ │ (payload: { reason: "app-closing" })
|
||
│ │ 瀏覽器 tab 收到 → 立即顯示 <ServerOfflineOverlay>
|
||
│ │ (不用等 3 次 polling 失敗)
|
||
│ ├─ SIGTERM server
|
||
│ ├─ 啟動 7 秒 grace timer 與 1 秒 modal timer
|
||
│ │
|
||
t=1.005 │ │── 1 秒 modal timer 到(若 server 仍未 exit)
|
||
│ │ └─ Wails 控制台 log panel 上方 overlay 顯示「停止中…」modal
|
||
│ │ i18n key: statusCard.stoppingModal
|
||
│ │
|
||
t=~0.2 │ │── 通常 server 在 200-500 ms 內 cmd.Wait() return(正常情境)
|
||
t=7.005 │ │── 7 秒 grace timer 到(罕見:server 卡死)
|
||
│ │ └─ SIGKILL server process
|
||
│ │
|
||
│ └─ state: Stopping → Stopped
|
||
└─ releaseLock()
|
||
t=7.200 Wails app 退出(最壞情況)
|
||
t=0.600 通常情境:server 200 ms 內 exit,整段 shutdown < 1 s,不會觸發 modal 也不會觸發 SIGKILL
|
||
|
||
瀏覽器 tab(使用者可能沒關):
|
||
t=0.005 收到 WebSocket server:shutdown-imminent → 立即顯示 Offline Overlay
|
||
(舊行為:連續 3 次 boot-id polling 失敗 = 15 s 才顯示,race 風險已解)
|
||
t=0.100+ polling /api/system/boot-id → ECONNREFUSED;overlay 已在畫面上不再重複觸發
|
||
```
|
||
|
||
**為什麼是 7 秒而不是 10 秒**:
|
||
- v2.0 互審時 Architect 提議 10 秒,PM 在審閱中決定 7 秒,理由:使用者關閉視窗時希望「秒級」回饋,10 秒過久容易讓使用者以為當機,再去強制關閉整個 app
|
||
- 7 秒 + 1 秒 modal 的組合:絕大多數情境 < 1 秒完成(不顯示 modal);稍慢情境 1-7 秒完成(顯示 modal 讓使用者知道還在停);卡死情境 7 秒強制 SIGKILL(使用者不會等超過 7 秒)
|
||
- server 端 `shutdownFn` 原本是 10 秒 timeout;這裡需要對齊為 7 秒,避免 server 還在自己的 timeout 期間時 Wails 已經 SIGKILL 它。見 §8 的修改說明
|
||
|
||
---
|
||
|
||
## 3. Port 分配策略
|
||
|
||
### 3.1 冷啟動(首次 StartServer)
|
||
|
||
呼叫 `pickPort(defaultPreferredPort=3721)`(`app.go:450`)。若 3721 被佔用,自動往下找(3722, 3723…,最多 20 個)。這是原 v1 行為,冷啟動時**允許 fallback**。
|
||
|
||
### 3.2 Restart(F-2 強制保留舊 port)
|
||
|
||
**v2.1 變更**:Restart Server 時**必須保留**舊 port,**不可** fallback。具體:
|
||
|
||
```go
|
||
func (c *ServerController) Restart() error {
|
||
c.mu.Lock()
|
||
oldPort := 0
|
||
if c.proc != nil {
|
||
oldPort = c.proc.port
|
||
}
|
||
c.mu.Unlock()
|
||
|
||
if err := c.Stop(); err != nil && err.Error() != "" {
|
||
return err
|
||
}
|
||
// 呼叫 Start 時帶入 preferred port
|
||
return c.startWithPort(oldPort)
|
||
}
|
||
|
||
func (c *ServerController) startWithPort(preferredPort int) error {
|
||
// ... 如常的 Starting transition
|
||
// 差別:呼叫 pickPort(preferredPort, /*forceMatch=*/true)
|
||
// 若 preferred 被佔用 → 不 fallback,回 error → state: Starting → Error
|
||
// + sendCrashNotification("Port 3721 被佔用,Restart 失敗")
|
||
}
|
||
```
|
||
|
||
**為什麼 Restart 不允許 fallback**:Restart 對應的典型使用情境是「server 狀態怪怪的,按一下重啟」,使用者期待原本開著的瀏覽器 tab 重新連上。若 Restart 換 port,瀏覽器 tab 會連不上舊 port、而 Next.js URL 是 static build 寫死 path(port 由 runtime inject),使用者體驗崩壞。保留舊 port 則瀏覽器 tab 透過 boot-id 偵測到變化後 reload,URL 原 port 仍有效 → 自動連回。
|
||
|
||
**Port 被佔用時的處理**:極罕見(Stop 剛 release、被第三方程式秒搶的情況),直接進 Error state + 發 OS 通知 + Wails 控制台 banner。使用者手動處理(殺掉佔用的程式、或按 Stop 後再 Start 讓它 fallback)。
|
||
|
||
### 3.3 冷啟動 vs Restart 決策矩陣(v2.1 定版)
|
||
|
||
| 情境 | 允許 port fallback? | 說明 |
|
||
|------|----------------------|------|
|
||
| **冷啟動**(app 啟動時的第一次 Start) | ✅ 允許(3721 → 3722 → … 最多 20 個) | 原 v1 行為 |
|
||
| 使用者在控制台按 **Start**(從 Stopped / Error 手動啟動)| ✅ 允許(視同冷啟動) | 沒有既有瀏覽器 tab 在等固定 port |
|
||
| 使用者在控制台按 **Restart**(從 Running)| ❌ **不允許**(F-2 強制保留舊 port)| 保護既有瀏覽器 tab URL 不失效 |
|
||
| watchServer 連續失敗 → Error → 使用者按 Start | ✅ 允許(視同冷啟動)| 同上 |
|
||
| **RestartStartupSequence**(Wails 控制台 Startup Error state 的「Retry」按鈕)| ✅ 允許(視同冷啟動)| 此時整個啟動流程重跑,瀏覽器 tab 還沒載入,無保留 port 的必要 |
|
||
|
||
**關鍵實作要點**:
|
||
- Restart 前先記住 `c.proc.port` → 呼叫 `c.startWithPort(oldPort)` → `pickPort` 時帶 `forceMatch=true`
|
||
- 若 port 被其他程序佔用(少見)→ 進 Error state + 呼叫 `sendCrashNotification("Restart failed: port %d occupied")`
|
||
- RestartStartupSequence 則直接 `c.Start()`(無 preferred port,允許 fallback)
|
||
|
||
### 3.4 Restart 完成後的瀏覽器 tab 行為
|
||
|
||
既然 Restart 時 port 不變,boot-id 偵測到變化 → `window.location.reload()` → 使用 URL 原 port → 新 server 正好也在該 port 聽 → 連上。
|
||
|
||
若 Restart 失敗(port 被佔)→ 進 Error state → 瀏覽器 tab 的 polling 連續失敗 → 15 s 後顯示 Offline Overlay(也可能早於此透過 WebSocket shutdown-imminent 廣播觸發)。
|
||
|
||
---
|
||
|
||
## 4. Stdout / Stderr pipe 捕捉機制
|
||
|
||
### 4.1 現況(v1 `app.go:484-516`)
|
||
|
||
```go
|
||
stdoutLog, _ := os.OpenFile(..., APPEND, 0o644)
|
||
stderrLog, _ := os.OpenFile(..., APPEND, 0o644)
|
||
cmd.Stdout = stdoutLog // 直接把 os.File 當 writer,kernel 直接 dup
|
||
cmd.Stderr = stderrLog
|
||
```
|
||
|
||
**問題**:Wails Go 拿不到 server log 的任何 byte,控制台 log panel 無法即時顯示。
|
||
|
||
### 4.2 新方案(v2)
|
||
|
||
```go
|
||
// server_control.go 的 startServerV2() 函式
|
||
cmd := exec.Command(binPath, args...)
|
||
cmd.Dir = filepath.Dir(binPath)
|
||
configureSysProcAttr(cmd)
|
||
cmd.Env = env // 沿用 v1 的 VISIONA_BUNDLE_BIN_DIR / VISIONA_PYTHON 注入
|
||
|
||
// v2:用 StdoutPipe / StderrPipe,不直接給 os.File
|
||
stdoutPipe, err := cmd.StdoutPipe()
|
||
if err != nil {
|
||
return fmt.Errorf("StdoutPipe: %w", err)
|
||
}
|
||
stderrPipe, err := cmd.StderrPipe()
|
||
if err != nil {
|
||
return fmt.Errorf("StderrPipe: %w", err)
|
||
}
|
||
|
||
// 開磁碟檔(給 logPump 寫)
|
||
stdoutLog, _ := os.OpenFile(filepath.Join(logsDir, "server.stdout.log"),
|
||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||
stderrLog, _ := os.OpenFile(filepath.Join(logsDir, "server.stderr.log"),
|
||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||
|
||
if err := cmd.Start(); err != nil {
|
||
stdoutPipe.Close()
|
||
stderrPipe.Close()
|
||
return fmt.Errorf("exec.Start: %w", err)
|
||
}
|
||
|
||
// v2:兩個 goroutine 同時 pump
|
||
pumpDone := make(chan struct{}, 2)
|
||
go a.logPump(stdoutPipe, "stdout", stdoutLog, pumpDone) // pumpDone 寫入 1
|
||
go a.logPump(stderrPipe, "stderr", stderrLog, pumpDone) // pumpDone 寫入 1
|
||
```
|
||
|
||
### 4.3 logPump 結束條件
|
||
|
||
- server process exit → pipe EOF → scanner loop 結束 → flush remaining batch → close(done)
|
||
- 外部 Close()(例如 Stop() 的 SIGKILL 路徑)→ scanner Err → 結束
|
||
|
||
**兩個 pump 都結束後 `pumpDone` 會收到 2 次**。ServerController 不需要等 pump 全部結束再改 state(state 在 Stop() 裡面改),pump 會在背景自動清理。
|
||
|
||
### 4.4 與現有 server WebSocket broadcaster 的關係
|
||
|
||
現有 server 有 `pkg/logger/broadcaster.go` 透過 `/ws/server-logs` 把 log 推給 Next.js Web UI 的 Settings 頁。這**不受影響**:
|
||
|
||
| log 流 | 寫入 | 讀取者 |
|
||
|-------|------|-------|
|
||
| stdout / stderr | Wails logPump 捕捉 | Wails 控制台 log panel |
|
||
| Gin logger middleware | `broadcasterLogger` 同時寫 stdout + ws broadcaster | 控制台(透過 stdout)+ Next.js Settings(透過 WS) |
|
||
| 業務 log(logger.Info 等)| 透過 server/pkg/logger 寫 stdout + WS broadcaster | 同上 |
|
||
|
||
→ 控制台看到的是「stdout/stderr 全部」,Next.js Settings 頁看到的是「logger + Gin」。兩個檢視不衝突。
|
||
|
||
---
|
||
|
||
## 5. ServerController 實作細節
|
||
|
||
### 5.1 欄位與互斥
|
||
|
||
```go
|
||
type ServerController struct {
|
||
app *App
|
||
|
||
mu sync.Mutex // 保護 state / proc / startedAt / lastError
|
||
state ServerState
|
||
proc *ServerProcess
|
||
startedAt time.Time
|
||
lastError string
|
||
|
||
// 保證同時只有一個 transition 在進行(Start/Stop/Restart 互斥)
|
||
txMu sync.Mutex
|
||
}
|
||
```
|
||
|
||
`txMu` 不同於 `mu`:`mu` 保護 struct field 的快速讀寫;`txMu` 在 Start / Stop / Restart / watchServer 改 state 的整段邏輯內持有,避免「Start 還在跑、使用者又按 Stop」這類 race。
|
||
|
||
### 5.2 Start / Stop / Restart 防呆
|
||
|
||
```go
|
||
func (c *ServerController) Start() error {
|
||
c.txMu.Lock()
|
||
defer c.txMu.Unlock()
|
||
|
||
c.mu.Lock()
|
||
s := c.state
|
||
c.mu.Unlock()
|
||
if s == ServerStateRunning || s == ServerStateStarting || s == ServerStateStopping {
|
||
return fmt.Errorf("cannot start: current state=%s", s)
|
||
}
|
||
// ... 真的啟動
|
||
}
|
||
|
||
func (c *ServerController) Stop() error {
|
||
c.txMu.Lock()
|
||
defer c.txMu.Unlock()
|
||
|
||
c.mu.Lock()
|
||
s := c.state
|
||
proc := c.proc
|
||
c.mu.Unlock()
|
||
if s == ServerStateStopped || s == ServerStateError {
|
||
return nil
|
||
}
|
||
if s == ServerStateStarting {
|
||
// 特別情境:Starting 中使用者就按 Stop — 等 Start 跑完再 stop
|
||
// 實作:直接先等 c.txMu 會拿到鎖的時候,Start 已結束
|
||
// (所以走到這裡 s 不可能是 Starting,除非 txMu 沒保護到)
|
||
return fmt.Errorf("wait for Start to finish")
|
||
}
|
||
if proc == nil {
|
||
return nil
|
||
}
|
||
// ... 真的停
|
||
}
|
||
```
|
||
|
||
實務上 Start 過程大概 1-3 秒,使用者在 UI 上 action bar 對應的按鈕會在 Starting 狀態被 disable,不太會誤按。但程式層仍要防呆。
|
||
|
||
### 5.3 `setState` 與事件發送
|
||
|
||
```go
|
||
func (c *ServerController) setState(s ServerState, errMsg string) {
|
||
c.mu.Lock()
|
||
c.state = s
|
||
c.lastError = errMsg
|
||
if s == ServerStateRunning {
|
||
c.startedAt = time.Now()
|
||
}
|
||
status := c.app.snapshotStatus() // GetServerStatus 的純讀版本
|
||
c.mu.Unlock()
|
||
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", status)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. watchServer 改為 Error state
|
||
|
||
詳見 `v2/control-panel.md` §4.7。核心 diff:
|
||
|
||
```diff
|
||
-if failures >= 3 {
|
||
- if a.ctx != nil {
|
||
- wailsRuntime.EventsEmit(a.ctx, "server:dead", map[string]any{...})
|
||
- }
|
||
- a.reportFatal("server died", ...)
|
||
- return
|
||
-}
|
||
+if failures >= 3 {
|
||
+ a.ctrl.setState(ServerStateError, "health check failed 3 times")
|
||
+ if a.ctx != nil {
|
||
+ wailsRuntime.EventsEmit(a.ctx, "server:error", map[string]any{
|
||
+ "reason": "health check failed 3 times",
|
||
+ "port": sp.port,
|
||
+ })
|
||
+ }
|
||
+ // R5-D1:發 OS 原生通知讓使用者在 Wails 視窗最小化時也能收到
|
||
+ // sendCrashNotification 實作見 §10;非阻塞 goroutine,失敗不影響流程
|
||
+ go sendCrashNotification(
|
||
+ "visionA Local — Server 崩潰",
|
||
+ "本機伺服器停止回應。請打開 visionA Local 查看錯誤並按 Restart。",
|
||
+ )
|
||
+ // 不 os.Exit;watchServer 自己 return,等使用者在控制台按 Restart
|
||
+ return
|
||
+}
|
||
```
|
||
|
||
**ServerProcess 的清理**:進 Error state 不代表 process 一定已死。可能:
|
||
- process crash(正常 Error state 主要情境)→ `cmd.Wait()` 會 return,pipe EOF → logPump 自清理
|
||
- process hang(heartbeat 無回應但進程還活著)→ Error state 記錄後 ServerController 會在使用者按 Start / Restart 時先強制 SIGKILL 舊 proc 再起新 proc
|
||
|
||
為了穩健,`ServerController.Start()` 在 Starting 前先檢查 `c.proc != nil` 且 `c.proc.cmd.Process` 還活著的話,**強制** `c.proc.stop()` 清理掉再繼續:
|
||
|
||
```go
|
||
func (c *ServerController) Start() error {
|
||
c.txMu.Lock()
|
||
defer c.txMu.Unlock()
|
||
|
||
// 清理殘留 process(Error state 進來時常見)
|
||
c.mu.Lock()
|
||
if c.proc != nil {
|
||
oldProc := c.proc
|
||
c.proc = nil
|
||
c.mu.Unlock()
|
||
oldProc.stop()
|
||
} else {
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
// ...真的啟動
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Wails OnBeforeClose handler(新)
|
||
|
||
`visiona-local/main.go` 修改:
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"embed"
|
||
|
||
"github.com/wailsapp/wails/v2"
|
||
"github.com/wailsapp/wails/v2/pkg/options"
|
||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||
)
|
||
|
||
//go:embed all:frontend
|
||
var assets embed.FS
|
||
|
||
func main() {
|
||
app := NewApp()
|
||
|
||
err := wails.Run(&options.App{
|
||
Title: "visionA Local — Edge AI Workspace",
|
||
Width: 1280,
|
||
Height: 800,
|
||
MinWidth: 960,
|
||
MinHeight: 640,
|
||
AssetServer: &assetserver.Options{
|
||
Assets: assets,
|
||
},
|
||
OnStartup: app.startup,
|
||
OnShutdown: app.shutdown,
|
||
OnBeforeClose: func(ctx context.Context) (prevent bool) {
|
||
// R5-2:關視窗 = 結束 server + 結束 app。不 hide-to-tray、不跳確認對話框。
|
||
// 回 false 允許 Wails 繼續 shutdown 流程(會呼叫 OnShutdown)。
|
||
return false
|
||
},
|
||
Bind: []interface{}{
|
||
app,
|
||
},
|
||
})
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
}
|
||
```
|
||
|
||
**為什麼明確寫 `OnBeforeClose` 而不是依 Wails default**:
|
||
- Wails v2 default 是「直接關」,與我們要的一致
|
||
- 但明確寫出來可以在未來 R5-2 若反覆(使用者想改)時有一個明確的 hook point
|
||
- 也讓 reviewer 一眼看到「關視窗時我們不做額外處理」
|
||
|
||
`app.shutdown(ctx)` 繼續沿用 v1 的邏輯(`app.go:194-209`),唯一差別是把 `a.stopServer()` 換成 `a.ctrl.Stop()`。
|
||
|
||
---
|
||
|
||
## 8. Graceful shutdown 順序(PM Q4:7 秒 + 1 秒 modal)
|
||
|
||
```
|
||
Wails OnBeforeClose → return false
|
||
↓
|
||
Wails OnShutdown (app.shutdown)
|
||
↓
|
||
1. a.watchCancel() ← 停 health check goroutine,避免它看到 server 死掉再 emit error
|
||
2. a.ipcListener.Close() ← 停 Wails 自己的 IPC server
|
||
3. removeWailsIPCPort(a.dataDir) ← 刪 wails-ipc-port 檔
|
||
4. a.ctrl.Stop() ← 停 server 子程序(7 秒 grace period)
|
||
├─ state: Running → Stopping
|
||
├─ 透過 server WebSocket hub 廣播 server:shutdown-imminent(Minor 4)
|
||
├─ SIGTERM
|
||
├─ 啟動 1 秒 modal timer(goroutine)
|
||
│ ├─ 1 秒內若 server 仍未 exit → emit Wails event "shutdown:modal-show"
|
||
│ └─ 前端顯示「停止中…」modal
|
||
├─ cmd.Wait() 或 7 秒 timeout
|
||
├─ SIGKILL if needed(7 秒到還沒 exit)
|
||
├─ logPump goroutine 自行 EOF 結束
|
||
└─ state: Stopping → Stopped
|
||
5. a.releaseLock() ← 釋放 single-instance lock
|
||
↓
|
||
Wails 真的退出
|
||
```
|
||
|
||
### 8.1 Wails 與 server 端 timeout 對齊
|
||
|
||
- Wails 的 `shutdownGracePeriod`(`app.go:46`):**7 秒**(v2.0 曾提議 10 秒,PM Q4 決定 7 秒)
|
||
- server 端 `server/main.go:166-173` 的 `shutdownFn` timeout:原本是 10 秒,需要對齊成 **6 秒**(比 Wails 少 1 秒,確保 server 自己先完成 graceful cleanup,而不是被 Wails SIGKILL)
|
||
- server 端若 6 秒內未完成,也會自己 os.Exit,Wails 的 cmd.Wait() 立即 return
|
||
|
||
```diff
|
||
// server/main.go shutdownFn
|
||
-shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
+shutdownCtx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
||
```
|
||
|
||
### 8.2 1 秒 modal 的實作
|
||
|
||
```go
|
||
func (sp *ServerProcess) stop() {
|
||
// ... SIGTERM 先打出去
|
||
|
||
done := make(chan struct{})
|
||
go func() {
|
||
_ = sp.cmd.Wait()
|
||
close(done)
|
||
}()
|
||
|
||
modalTimer := time.NewTimer(1 * time.Second)
|
||
graceTimer := time.NewTimer(7 * time.Second)
|
||
defer modalTimer.Stop()
|
||
defer graceTimer.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-done:
|
||
return // 正常 exit
|
||
case <-modalTimer.C:
|
||
if sp.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(sp.app.ctx, "shutdown:modal-show", nil)
|
||
}
|
||
case <-graceTimer.C:
|
||
_ = sp.cmd.Process.Kill() // SIGKILL
|
||
<-done // 等 Wait return
|
||
return
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**使用者體驗**:
|
||
- 一般情境(< 1 s 完成):看不到 modal,Wails 秒關
|
||
- 稍慢情境(1-7 s):看到「停止中…」modal,最多等 7 s
|
||
- 卡死情境(> 7 s):7 s 時強制 SIGKILL,使用者總等待 7-7.5 s
|
||
|
||
### 8.3 modal 的前端 UI
|
||
|
||
Wails 前端(控制台 vanilla JS)訂閱 `shutdown:modal-show` event,在畫面中央顯示一個簡單 overlay(半透明背景 + 中央卡片),文案:
|
||
|
||
```
|
||
┌───────────────────────────────┐
|
||
│ │
|
||
│ 🔄 正在停止 server … │
|
||
│ │
|
||
│ 請稍候,最多 7 秒即會完成。 │
|
||
│ │
|
||
└───────────────────────────────┘
|
||
```
|
||
|
||
i18n key:`statusCard.stoppingModal.title` / `statusCard.stoppingModal.body`。文案細節交 Design Spec v2.1 敲定。
|
||
|
||
---
|
||
|
||
## 9. Boot-ID 機制
|
||
|
||
### 9.1 Server 端(`server/main.go` + `system_handler.go`)
|
||
|
||
**定版決策(v2.1)**:使用純標準庫 `crypto/rand` 生成 16 bytes → hex string(32 字元),**不引入** `github.com/google/uuid` 依賴。每次 `StartServer()` 成功後產生新 boot-id,寫入 global state 並透過 `GET /api/system/boot-id` 暴露。
|
||
|
||
`server/main.go` 在啟動時產生 boot-id,傳入 `SystemHandler`:
|
||
|
||
```diff
|
||
import (
|
||
...
|
||
+ "crypto/rand"
|
||
+ "encoding/hex"
|
||
)
|
||
|
||
+func newBootID() string {
|
||
+ b := make([]byte, 16)
|
||
+ _, _ = rand.Read(b[:16]) // crypto/rand 不會真的失敗(即使失敗也給 zero-value,不影響功能)
|
||
+ return hex.EncodeToString(b) // 32 字元 hex string
|
||
+}
|
||
|
||
func main() {
|
||
cfg := config.Load()
|
||
+ bootID := newBootID()
|
||
logger := pkglogger.New(cfg.LogLevel)
|
||
...
|
||
// Create system handler with injected version and restart function
|
||
- systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn)
|
||
+ systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn, bootID)
|
||
```
|
||
|
||
**為什麼不用 `google/uuid`**:
|
||
- 避免新增外部依賴(go.mod 越乾淨越好)
|
||
- `crypto/rand` + `hex` 都是標準庫,零成本
|
||
- 32 字元 hex vs 36 字元 UUID,傳輸量略小
|
||
- UUID v4 本質上也是 16 bytes 隨機 + 格式化,換成 hex 只是省掉 dash
|
||
|
||
`server/internal/api/handlers/system_handler.go` 增加:
|
||
|
||
```diff
|
||
type SystemHandler struct {
|
||
startTime time.Time
|
||
version string
|
||
buildTime string
|
||
pythonBin string
|
||
shutdownFn func()
|
||
depsCache []deps.Dependency
|
||
+ bootID string
|
||
}
|
||
|
||
-func NewSystemHandler(version, buildTime, pythonBin string, shutdownFn func()) *SystemHandler {
|
||
+func NewSystemHandler(version, buildTime, pythonBin, bootID string, shutdownFn func()) *SystemHandler {
|
||
return &SystemHandler{
|
||
...
|
||
+ bootID: bootID,
|
||
}
|
||
}
|
||
|
||
+func (h *SystemHandler) BootID(c *gin.Context) {
|
||
+ c.JSON(200, gin.H{
|
||
+ "success": true,
|
||
+ "data": gin.H{"bootId": h.bootID, "startedAt": h.startTime.UnixMilli()},
|
||
+ })
|
||
+}
|
||
```
|
||
|
||
`server/internal/api/router.go`:
|
||
|
||
```diff
|
||
api.GET("/system/health", systemHandler.HealthCheck)
|
||
api.GET("/system/info", systemHandler.Info)
|
||
+api.GET("/system/boot-id", systemHandler.BootID)
|
||
```
|
||
|
||
#### 9.1a Gin logger middleware 的 SkipPaths(v2.1 二次審閱補齊)
|
||
|
||
瀏覽器每個 tab 每 10 秒 poll 一次 `/api/system/boot-id`,正常模式下 health-check 也會被業務 code 輪詢。這兩個 endpoint 若每次都寫入 Gin access log,會把 server log 噴到滿是高頻輪詢訊息、淹沒真正有用的業務 log。
|
||
|
||
**定案**:Gin logger middleware 必須設定 `SkipPaths`,跳過 `/api/system/boot-id` 和 `/api/system/health`(= `HealthCheck` endpoint 的 path,請依實際 router mount 路徑確認)。
|
||
|
||
實作位置:`server/internal/api/router.go`(router 初始化的地方):
|
||
|
||
```go
|
||
import (
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
func NewRouter(...) *gin.Engine {
|
||
router := gin.New()
|
||
|
||
// v2.1:跳過高頻輪詢 endpoint 的 access log
|
||
router.Use(gin.LoggerWithConfig(gin.LoggerConfig{
|
||
SkipPaths: []string{
|
||
"/api/system/boot-id",
|
||
"/api/system/health",
|
||
},
|
||
}))
|
||
router.Use(gin.Recovery())
|
||
|
||
// ... 其他 middleware 與 route 註冊
|
||
return router
|
||
}
|
||
```
|
||
|
||
**注意**:
|
||
- `SkipPaths` 是**完整字串比對**(不支援 regex / wildcard),要填入前端實際請求的 path
|
||
- 業務 log(`logger.Info` / `logger.Warn` 等)不受影響,仍然正常寫入 → 這個 skip 只影響 Gin 的 access log 層
|
||
- WebSocket broadcaster 的 log 也不受影響(另一條管道)
|
||
|
||
### 9.2 響應格式
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"bootId": "6fa8c72e2b4b4b148e9a1f0d4a2f7e31",
|
||
"startedAt": 1744656180123
|
||
}
|
||
}
|
||
```
|
||
|
||
### 9.3 瀏覽器 tab 端
|
||
|
||
見 `v2/web-ui-offline-overlay.md`。核心:`src/stores/system-store.ts` 或新的 `useBootIdWatcher.ts` hook(v2.1 定版):
|
||
|
||
1. 初次成功回應 → 記錄 `initialBootId`
|
||
2. 往後每次 **10 s poll**(normal 模式):
|
||
- 成功 + bootId 不變 → 沒事
|
||
- 成功 + bootId **變了** → `window.location.reload()`
|
||
- 失敗 1 次 → `consecutiveFailures++`
|
||
- 失敗 ≥ **2 次** → 顯示 `<ServerOfflineOverlay>`,切 polling interval 為 **3 s**(active retry 模式)
|
||
- 後續成功 → 清 failures + dismiss overlay + 切回 10 s interval
|
||
|
||
---
|
||
|
||
## 10. OS 崩潰通知(R5-D1)
|
||
|
||
**觸發條件**:任一以下情境發生時,除了走 Error state + 控制台 banner 外,**同時**發一則 OS 原生通知。兩者並存,不互相取代:
|
||
|
||
1. watchServer 連續 3 次失敗 → Error state
|
||
2. StartServer 失敗(Python runtime 找不到、server binary 不存在、port 被佔、startServerV2 return error)
|
||
3. 階段化啟動總時 > 60 s(R5-E 硬上限)
|
||
4. Restart 因 port 被佔進 Error state(§3.2 F-2)
|
||
|
||
**實作檔案**:`visiona-local/notify.go`
|
||
|
||
### 10.1 介面
|
||
|
||
```go
|
||
package main
|
||
|
||
// sendCrashNotification 發送一則 OS 原生通知。非阻塞、最佳努力送達。
|
||
// 三平台各自用系統工具:
|
||
// macOS → osascript -e 'display notification "body" with title "title"'
|
||
// Linux → notify-send "title" "body" (libnotify)
|
||
// Windows → powershell BurntToast 或 msg * (依環境 fallback)
|
||
//
|
||
// 呼叫失敗(工具不存在、權限被關)時僅 log 到 stderr,不回傳 error,
|
||
// 不影響呼叫端的流程。
|
||
//
|
||
// 建議呼叫方式:go sendCrashNotification(...)(fire-and-forget)
|
||
func sendCrashNotification(title, body string)
|
||
```
|
||
|
||
### 10.2 macOS 實作
|
||
|
||
```go
|
||
//go:build darwin
|
||
|
||
func sendCrashNotification(title, body string) {
|
||
// 需要對 title / body 做單引號跳脫避免 osascript 語法錯誤
|
||
safeTitle := strings.ReplaceAll(title, `"`, `\"`)
|
||
safeBody := strings.ReplaceAll(body, `"`, `\"`)
|
||
script := fmt.Sprintf(`display notification "%s" with title "%s" sound name "Funk"`, safeBody, safeTitle)
|
||
cmd := exec.Command("osascript", "-e", script)
|
||
if err := cmd.Run(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[notify] osascript failed: %v\n", err)
|
||
}
|
||
}
|
||
```
|
||
|
||
**注意事項**:
|
||
- macOS 10.14+ 開始 osascript display notification 需要使用者授權(系統偏好 → 通知與專注模式),首次會彈出授權對話框
|
||
- 若使用者拒絕,後續呼叫靜默失敗(這是預期行為,不做額外處理)
|
||
- 不依賴 terminal-notifier 等第三方工具(避免 vendor)
|
||
|
||
### 10.3 Linux 實作
|
||
|
||
```go
|
||
//go:build linux
|
||
|
||
func sendCrashNotification(title, body string) {
|
||
// notify-send 是 libnotify-bin 提供的標準工具,多數桌面環境預裝
|
||
cmd := exec.Command("notify-send", "-u", "critical", "-i", "dialog-error", title, body)
|
||
if err := cmd.Run(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[notify] notify-send failed: %v\n", err)
|
||
// 不做第二段 fallback — Linux 桌面環境差異大,
|
||
// 若 notify-send 不存在就認命;控制台 banner 仍會顯示
|
||
}
|
||
}
|
||
```
|
||
|
||
### 10.4 Windows 實作
|
||
|
||
```go
|
||
//go:build windows
|
||
|
||
func sendCrashNotification(title, body string) {
|
||
// 優先用 PowerShell BurntToast(系統級 toast,原生)
|
||
// 次選 msg *(老派但無條件可用,需要 Terminal Services 服務)
|
||
psScript := fmt.Sprintf(`
|
||
if (Get-Module -ListAvailable -Name BurntToast) {
|
||
Import-Module BurntToast -ErrorAction SilentlyContinue
|
||
New-BurntToastNotification -Text '%s','%s' -AppLogo ''
|
||
} else {
|
||
# 若沒裝 BurntToast 模組,直接用老派 msg * 廣播
|
||
msg * /TIME:10 "%s - %s"
|
||
}
|
||
`, escapePSString(title), escapePSString(body), escapePSString(title), escapePSString(body))
|
||
|
||
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
|
||
configureSysProcAttr(cmd) // 沿用現有的 hide-window flag
|
||
if err := cmd.Run(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[notify] powershell notify failed: %v\n", err)
|
||
}
|
||
}
|
||
|
||
func escapePSString(s string) string {
|
||
return strings.ReplaceAll(s, "'", "''")
|
||
}
|
||
```
|
||
|
||
**注意事項**:
|
||
- BurntToast 不是系統預裝,但大多數開發者 / 進階使用者都有裝。沒裝就降級用 `msg *`
|
||
- `msg *` 在 Windows 10/11 Home 版可能被限制,此時都失敗 → 最佳努力送達的設計,可接受
|
||
- 不引入 Go Windows API 的 ToastNotification Manager(COM 呼叫複雜,維護成本高)
|
||
|
||
### 10.5 與控制台 Error banner 的關係
|
||
|
||
| 狀態 | 控制台 banner | OS 通知 |
|
||
|------|-------------|--------|
|
||
| 使用者打開 Wails 視窗 | 一眼可見 | 附帶提醒(可能被忽略)|
|
||
| 使用者把 Wails 最小化 | 看不到 | **主要告知管道** |
|
||
| 使用者關閉 Wails | 不適用(已關)| 不適用(app 已退)|
|
||
|
||
**結論**:兩者互補,不取代。`go sendCrashNotification(...)` 與 `setState(ServerStateError, ...)` **同時**呼叫,不做二選一判斷(例如「使用者正在看控制台就不發 OS 通知」這種複雜邏輯不做)。
|
||
|
||
---
|
||
|
||
## 11. Preferences 持久化(PM §11-1 回答)
|
||
|
||
### 11.1 檔案位置與格式
|
||
|
||
- **路徑**:`<dataDir>/preferences.json`
|
||
- macOS:`~/Library/Application Support/visiona-local/preferences.json`
|
||
- Linux:`~/.local/share/visiona-local/preferences.json`(XDG_DATA_HOME)
|
||
- Windows:`%APPDATA%/visiona-local/preferences.json`
|
||
- **格式**:JSON,UTF-8,含 BOM 否也可以(load 時用 `encoding/json` 預設處理)
|
||
- **檔案內容範例**:
|
||
|
||
```json
|
||
{
|
||
"autoOpenBrowser": true,
|
||
"locale": "zh-TW",
|
||
"logRingSize": 2000
|
||
}
|
||
```
|
||
|
||
### 11.2 Go struct 定義
|
||
|
||
見 `v2/control-panel.md` §4.3 的 `Preferences` struct。關鍵欄位:
|
||
|
||
| 欄位 | 型別 | 預設 | 說明 |
|
||
|------|------|-----|------|
|
||
| `AutoOpenBrowser` | `bool` | macOS/Windows: `true`;Linux: `false`(R5-D2)| Start 成功後是否開瀏覽器(每次都開,R5-D3)|
|
||
| `Locale` | `string` | `""` | 空字串 → 自動偵測(navigator.language);否則覆寫 |
|
||
| `LogRingSize` | `int` | `0` | 0 → 使用預設 2000;正整數 → 自訂 ring buffer 大小 |
|
||
|
||
### 11.3 `DefaultPreferences()` 實作
|
||
|
||
```go
|
||
// visiona-local/preferences.go
|
||
|
||
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
)
|
||
|
||
type Preferences struct {
|
||
AutoOpenBrowser bool `json:"autoOpenBrowser"`
|
||
Locale string `json:"locale,omitempty"`
|
||
LogRingSize int `json:"logRingSize,omitempty"`
|
||
}
|
||
|
||
// DefaultPreferences 依平台回傳預設值。
|
||
// R5-D2:Linux 預設關 AutoOpenBrowser(桌面環境差異大);
|
||
// macOS / Windows 預設開。
|
||
func DefaultPreferences() Preferences {
|
||
return Preferences{
|
||
AutoOpenBrowser: runtime.GOOS != "linux",
|
||
Locale: "",
|
||
LogRingSize: 0, // 0 = 使用 logBufferCap 預設 2000
|
||
}
|
||
}
|
||
|
||
func preferencesPath(dataDir string) string {
|
||
return filepath.Join(dataDir, "preferences.json")
|
||
}
|
||
|
||
// LoadPreferences 讀取 preferences.json。
|
||
// 讀取失敗 / 檔案不存在 / JSON 損毀 → 回傳 DefaultPreferences() + nil error。
|
||
// (讀取失敗不應阻止 app 啟動)
|
||
func LoadPreferences(dataDir string) Preferences {
|
||
path := preferencesPath(dataDir)
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
// 檔案不存在 / 無權限:fallback 到預設
|
||
return DefaultPreferences()
|
||
}
|
||
var p Preferences
|
||
if err := json.Unmarshal(data, &p); err != nil {
|
||
// JSON 損毀:fallback 到預設
|
||
// 不刪壞檔(保留讓使用者 debug),直接用預設覆寫 in-memory
|
||
return DefaultPreferences()
|
||
}
|
||
return p
|
||
}
|
||
|
||
// SavePreferences 寫 preferences.json。
|
||
// 採 atomic write-rename:
|
||
// 1. os.WriteFile(tmpPath, data, 0o644) 寫到 .preferences.json.tmp
|
||
// 2. os.Rename(tmpPath, realPath) 原子替換
|
||
// 避免寫到一半 crash 造成檔案損毀。
|
||
func SavePreferences(dataDir string, p Preferences) error {
|
||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
data, err := json.MarshalIndent(p, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
realPath := preferencesPath(dataDir)
|
||
tmpPath := realPath + ".tmp"
|
||
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
|
||
return err
|
||
}
|
||
if err := os.Rename(tmpPath, realPath); err != nil {
|
||
_ = os.Remove(tmpPath) // cleanup 失敗的 tmp
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
### 11.4 何時讀取 / 寫入
|
||
|
||
| 時機 | 動作 |
|
||
|------|------|
|
||
| App startup 初始化 `a.prefs` | `a.prefs = LoadPreferences(a.dataDir)` |
|
||
| 使用者在控制台切換 Preferences toggle → `SetPreferences` binding | `SavePreferences(a.dataDir, p)` + 更新 `a.prefs` |
|
||
| App shutdown | 不做任何事(每次變更都即時 save,不需要 shutdown flush)|
|
||
|
||
### 11.5 與 R5-D3 配合
|
||
|
||
每次 `StartServer()` 成功後:
|
||
|
||
```go
|
||
if c.app.prefs.AutoOpenBrowser {
|
||
_ = c.app.OpenInBrowser("")
|
||
}
|
||
```
|
||
|
||
- Restart 時一樣檢查(R5-D3 明示每次都開)
|
||
- `autoOpenedThisSession` flag **已移除**,不再有 per-session-once 概念
|
||
|
||
### 11.6 記憶體估算說明(PM §11-3 的 450 MB 指標澄清)
|
||
|
||
**PM 原提問**:AC-1.4 / NFR idle RAM ≤ 450 MB 是否合理?
|
||
|
||
**Architect 分析**(互審時已答):
|
||
- Wails 控制台(WebView2 / WKWebView):~120 MB(樂觀 80 MB / 悲觀 180 MB)
|
||
- Go server 子程序(Gin + handler):~60 MB(樂觀 40 MB / 悲觀 100 MB)
|
||
- Python runtime(bundled)+ KneronPLUS SDK(已載入):~180 MB(樂觀 150 MB / 悲觀 250 MB)
|
||
- 其他(ffmpeg idle 不佔、single-instance lock、IPC server):~10 MB
|
||
- **合計樂觀**:~370 MB;**合計悲觀**:~500 MB
|
||
|
||
**澄清(新加)**:450 MB 目標**不含**瀏覽器 tab 的記憶體消耗。
|
||
|
||
- 瀏覽器 tab 跑 Next.js Web UI,記憶體由系統瀏覽器(Chrome / Safari / Edge)管理,不在 visionA Local 進程樹內
|
||
- 因此 `ps aux | grep visiona-local` 看到的 RSS 總和是「Wails + Go server + Python」三者,不包含瀏覽器 tab
|
||
- 若使用者用 Chrome 開多個 Web UI tab,每個 tab 額外 ~100-200 MB(瀏覽器自己的事)
|
||
- NFR ≤ 450 MB 指標檢驗時,應以「visionA Local 啟動並 server Running、無瀏覽器 tab 連線」為量測條件
|
||
|
||
**結論**:樂觀情境明確達標,悲觀情境可能超過 450 MB 50 MB 左右。M8-10 smoke test 時量測實際值,若超出 > 10%(即 > 500 MB),啟動優化工作(例如 Python 延遲載入 SDK、Gin handler 減少 cache 大小)。
|
||
|
||
---
|
||
|
||
## 12. 待確認
|
||
|
||
1. **Wails v2 的 `OnBeforeClose` 在 Linux AppImage 下是否正常觸發?** — 以往經驗 OnBeforeClose 在某些 Linux WM(i3, xmonad)下 flaky。M8-10 驗收時實機測試,若有問題 fallback 為 OnShutdown 裡做所有清理(OnShutdown 一定會觸發)。
|
||
2. **`shutdownGracePeriod` 7 s 與 server 端 6 s 的對齊實務** — server 端 6 s timeout 可能在大量批次 IO flush 時不夠。實測,若發現 server 經常被 SIGKILL(7 s 已到但 server 還沒自己 exit),把 server timeout 調回 8 s、Wails 改 9 s(PM Q4 的 7+1 改為 9+1)。需回報給 PM 重新敲定。
|
||
3. **Boot-ID 依賴(已定版)** — v2.1 二次審閱後定版:用純 `crypto/rand` 16 bytes → hex string(32 字元)生成 boot-id,**不引** `github.com/google/uuid`。實作範例見 §9.1。無需開發時再討論。
|
||
4. **Linux `notify-send` 不存在時的 fallback** — 目前沒有 fallback,直接認命。若 M8-10 測試發現 Ubuntu minimal 安裝缺 libnotify-bin,考慮在 AppImage bundle 內自帶或於 postinstall 提示。暫不處理,視實測結果決定。
|