jim800121chen ff9bbc81ed feat(local-tool): M9-4.5 — server SIGTERM + Wails OnBeforeClose firmware-aware shutdown
A 階段尾端 milestone、雙層防護避免使用者在 firmware 升級進行中關閉 app 造成 dongle brick。

Server 端 (3 改):
- main.go: SIGTERM/SIGINT goroutine 加 firmware-aware preamble
- server/internal/firmware/shutdown.go: 新 211 行(AwaitActiveTasksOrTimeout + 3 interfaces + shutdownBroadcastTask minimal struct + toBroadcastTasks helper)
- server/internal/firmware/shutdown_test.go: 新 384 行、8 tests

Wails 端 (3 新 + 2 改):
- visiona-local/main.go: OnBeforeClose 從 inline → app.OnBeforeClose
- visiona-local/app.go: App struct 加 firmwareCloseGuard
- visiona-local/firmware_close_guard.go: 新 244 行(CloseGuard + OnBeforeClose + ConfirmForceClose)
- visiona-local/firmware_close_guard_test.go: 新 280 行、8 tests
- visiona-local/query_firmware_active_tasks.go: 新 111 行(HTTP helper、fail-open、1s timeout)
- visiona-local/query_firmware_active_tasks_test.go: 新 250 行、7 tests

行為:
- Server SIGTERM 有 active task → broadcast `server:shutdown-pending` to "system" room → RequestShutdown + WaitForActiveTasks(220s) → 走原本 shutdownFn
- Wails OnBeforeClose 有 active task → emit Wails event `app:firmware-in-progress` + return true 擋住關閉
- ConfirmForceClose binding 給 frontend 第二層 FORCE 確認用、走 graceful 7+1s shutdown(不是 SIGKILL bypass、雙層防護)

Reviewer 兩輪審查:
- Round 1: 0 Critical / 1 Major / 3 Minor / 4 Suggestion
- 第 2 輪修法(3 sub-agent 平行):
  - Architect: TDD §8.6 改 event 名 `firmware:shutdown-rejected` → `server:shutdown-pending`、標題「拒絕」→「延遲」、補 payload schema 註明 tasks 不含 startTs
  - Design: control-panel.md §6a 改「SIGKILL bypass」→「graceful 7+1s 雙層防護」、補「為何不採 SIGKILL」5 點設計理由、§6a.11 IPC 規格對齊
  - Backend: MaxShutdownWait 180s → 220s(KL720 200s upgrade + 20s buffer)+ broadcast 過濾 startTs(shutdownBroadcastTask minimal struct + toBroadcastTasks helper)

測試:
- server: go test ./... -race 全綠(firmware 2.7s + api/ws/handlers)
- wails: go test ./... -race 全綠(visiona-local 11.2s、21 tests)
- 合計新增 23 unit tests race-clean、0 regression

下一步: M9-5 三平台實機驗證 + 順手修 MJ3(backend smoke test schema phase→stage / firmware:progress→firmware_progress)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:07:29 +08:00

668 lines
44 KiB
Markdown
Raw 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.

# v2.1 — Wails Server Control Panel 設計規格
> 本章對應 R5-1 / R5-2 / R5-3 / R5-4 / R5-5 / R5-5a / **R5-D1 / R5-D3 / R5-E**v2.1 補丁)
> 上層索引:`../design-spec-v2.md`
> 版本:**v2.1** · 更新日期2026-04-14
> 相關:`v2/startup-progress.md`R5-E 階段化啟動進度面板Starting state 時顯示)
---
## 1. 定位與職責
Wails Server Control Panel以下稱「控制台」是 visionA-local 雙擊開啟後看到的**第一個畫面**,也是使用者**唯一可以關 server** 的地方。它的職責:
- Server lifecycle 管理Start / Stop / Restart
- 即時 log 顯示、過濾、複製、匯出
- 一鍵開啟瀏覽器 Web UI
- 顯示 server 狀態port、PID、uptime、version
- 錯誤狀態的視覺呈現與自助排除入口
**控制台不做的事**(與 v1 清單一致、R5 複核):
- 不管 device、model、inference那是 Web UI 的事)
- 不顯示 Mock 模式切換R5-5拿掉 Mock 切換)
- 不提供語言切換(跟隨系統 locale見 §9
- 不提供 Dark Mode 切換(跟隨系統)
---
## 2. 視窗規格
| 項目 | 值 | 說明 |
|------|----|------|
| 預設寬度 | `720 px` | log 一行約 90-100 字元可顯示 |
| 預設高度 | `560 px` | log 區塊可顯示 18-20 行 |
| 最小寬度 | `560 px` | 防止 primary controls 擠壞 |
| 最小高度 | `420 px` | log 區最少 6 行 |
| 可調整大小 | 是 | 拖角落縮放 |
| 最大化 | 保留 | |
| 最小化 | 保留 | |
| 置頂 | 否 | |
| Title bar | 原生 | 不做自訂 title bar |
| 視窗標題 | `visionA-local · Server Control` | |
| 視窗 icon | 沿用 `frontend/icon.png` | |
| 初始位置 | 螢幕中央(首次)/上次位置(後續) | 記憶寫到 `~/Library/Application Support/visiona-local/control-panel.json` |
---
## 3. 佈局 Wireframe最終版
```
┌───────────────────────────────────────────────────────────────────┐
│ ● visionA-local · Server Control [ ][ □ ][ × ]│ ← Title bar原生
├───────────────────────────────────────────────────────────────────┤
│ │
│ ┌────┐ visionA-local v0.1.0 │
│ │LOGO│ ● Running · Browser opened │ ← Header
│ └────┘ Port: 3721 Uptime: 00:12:43 PID: 45821 │
│ │
├───────────────────────────────────────────────────────────────────┤
│ │
│ [ 🌐 Open in Browser ] [ Start ] [ ⋯ Manage ▾ ] │ ← Primary controls
│ │
│ ☑ Follow tail ☑ Show timestamps 🔍 [ Filter ... ] │ ← Log controls
│ │
├───────────────────────────────────────────────────────────────────┤
│ 10:23:41 INFO HTTP server listening on 127.0.0.1:3721 │
│ 10:23:41 INFO wails ipc ready │
│ 10:23:42 INFO device scan: found 1 Kneron KL520 │
│ 10:23:43 INFO GET /api/devices 200 (4ms) │ ← Log panel
│ 10:23:45 INFO GET /api/models 200 (2ms) │ (等寬字體
│ 10:23:58 WARN python sidecar restart (attempt 1) │ 可捲動
│ 10:23:59 INFO python sidecar ready │ 等級著色)
│ 10:24:12 INFO inference session start: classification │
│ ... │
│ │
│ │
├───────────────────────────────────────────────────────────────────┤
│ [ Clear ] [ Copy ] [ Export log ] [ Open log folder ] │ ← Log actions
│ │
│ Lines: 142 / 2000 ⚠ Closing this window will stop │ ← Footer
│ the server. │
└───────────────────────────────────────────────────────────────────┘
```
**和 v1 分析稿(`design-analysis-round2-refactor.md` §B2的差異**
- 拿掉 log 控制列上方「Mock 模式切換」區v1 分析稿其實沒有這個,只是 `controlPanelSection` 清單有——R5-5a 確認砍)
- Primary controls 從 4 顆精簡為 3 顆(`Start` / `Stop` / `Restart` 三顆合併為 `Start` + overflow menu
- Header 狀態列文字擴充,加入 "Browser opened"(首次 auto-open 後的視覺回饋,見 §5
- Footer 新增「關閉視窗會停止 server」持久提示R5-2 明確決策,用持久文字取代每次彈對話框)
---
## 4. 元件清單
### 4.1 Header高度 80 px
| 元素 | 類型 | 尺寸 / 位置 | 狀態 | 備註 |
|------|------|------------|------|------|
| Brand logo | `<img>` | 40×40 px左側 padding 16 | static | 沿用 `frontend/icon.png` |
| Product name | `<h1>` | 16 px / SemiBold | static | 文字:`visionA-local` |
| Version tag | `<span>` | 12 px / muted-foreground | static | 文字:`v{major}.{minor}.{patch}`,右上角 |
| Status indicator | `<span>` + `<svg>` | 圓點 8 px | 見 §5 狀態機 | 顏色綁 semantic tokens |
| Status text | `<span>` | 14 px / Medium | 見 §5 | 例:`Running · Browser opened` |
| Server meta | `<dl>` | 12 px / muted | 6 個欄位 | Port / Uptime / PIDUptime 每秒刷新) |
### 4.2 Primary controls高度 48 px
| 按鈕 | 變體 | 大小 | 啟用條件 | 備註 |
|------|------|------|---------|------|
| **Open in Browser** | `primary` filled | `md` | 僅 `Running` | 最左、最顯眼,附 🌐 icon |
| **Start** | `outline` | `md` | `Stopped` / `Error` | 附 ▶ icon |
| **Manage ▾** | `outline` + dropdown | `md` | `Running` | 展開後包含 `Stop server``Restart server` |
**Manage overflow menu 內容**
```
┌──────────────────────────┐
│ Stop server │ ← destructive 色彩提示
│ Restart server │ ← 普通
├──────────────────────────┤
│ Open log folder │ ← 重複項(方便直接存取)
└──────────────────────────┘
```
**為什麼 Primary CTA 是 "Open in Browser"**Design Rationale
- R5-4 決定首次啟動會自動開瀏覽器一次
- 使用者後續可能關 browser tab環境整理、記憶體、誤關
- 「關了想重開」是日常第二高頻操作(第一高頻是雙擊 app 本身,已被 auto-open 覆蓋)
- Start/Stop/Restart 只在出事時才點
- 結論:**Open in Browser 保留為 primary**(沿用第一輪 B3 提案)
**Stop 放進 overflow 的原因**
- 避免誤按導致 server 中斷 + Web UI 爆掉
- Stop 放在 dropdown 多一個「點擊 > 選擇」保護,等效輕度確認
- 不做「你確定要 Stop」modal減少 UX 摩擦
### 4.3 Log controls高度 40 px
| 元素 | 類型 | 預設 | 行為 |
|------|------|------|------|
| `Follow tail` | `<checkbox>` | ✅ ON | 使用者往上捲動時**自動關閉**,捲到最底自動重啟。附提示 `Jump to latest` pill |
| `Show timestamps` | `<checkbox>` | ✅ ON | 關閉後 log 行去掉時間戳 |
| `Filter` | `<input type="search">` | 空 | 即時字串過濾,無 regex`⌘F` / `Ctrl+F` 聚焦 |
### 4.4 Log panel高度剩餘 flex-grow
| 屬性 | 值 |
|------|---|
| 字體 | `font.mono`SF Mono / Consolas / Menlo |
| 字級 | `12 px` |
| 行高 | `1.5` |
| 背景 | `color.surface-1`Light`oklch(0.99 0 0)`Dark`oklch(0.18 0 0)` |
| 選取背景 | `color.primary/20` |
| **最大行數** | **2000**ring buffer超過舊的 drop對齊 TDD v2 Go server 常數,~400KB 記憶體可忽略) |
| 寫檔 | **無**TDD v2 採 in-memory ring bufferlog 不落地;使用者若需保存用 `Export log` 手動匯出) |
| 滑入動畫 | 60 ms fade-in`prefers-reduced-motion` 時關閉) |
| 選取冰結 | 使用者拖選文字時自動暫停 auto-scroll |
**為什麼取消落地寫檔與 rotate**
- TDD v2 決定 Go server 採 in-memory ring buffer容量 2000 行)統一管理 log**不落地滾動檔案**
- rotate 7 天 / 10MB 需要 `lumberjack.v2` 或自刻定時掃描 + size 比較,非 M8 scope 且會增加技術債
- 使用者如需保存 log → `Export log` 按鈕§4.5)原生 save dialog 匯出當下 buffer 內容
- 使用者如需檢視/清理 → `Open log folder` 保留,指向 `<dataDir>/logs/`(若未來重新啟用落地再用;目前該資料夾可能為空)
- 未來若有落地需求 → 放 M9+ 迭代,不影響 v2.1 交付
**等級著色**(和 Web UI semantic token 一致):
| Level | Token | Light 範例 | Dark 範例 |
|-------|-------|-----------|----------|
| DEBUG | `color.muted-foreground` | `#6b7280` | `#9ca3af` |
| INFO | `color.foreground` | `#111827` | `#e5e7eb` |
| WARN | `color.warning` | `#b45309` | `#fbbf24` |
| ERROR | `color.destructive` | `#b91c1c` | `#f87171` |
### 4.5 Log actions高度 40 px
| 按鈕 | 類型 | 功能 |
|------|------|------|
| `Clear` | `ghost` small | 清空畫面 log不動檔案二次確認toast「Log cleared」5 秒內可 undo |
| `Copy` | `ghost` small | 複製全部可見 log 到剪貼簿首次點擊時提示「Log 可能包含檔名與裝置資訊」一次 |
| `Export log` | `ghost` small | 原生 save dialog預設檔名 `visiona-local-{yyyyMMdd-HHmmss}.log` |
| `Open log folder` | `ghost` small | 呼叫 OS 開 `~/Library/Application Support/visiona-local/logs/` |
### 4.6 Footer高度 32 px
| 元素 | 位置 | 文字 / 樣式 |
|------|------|-----------|
| 行數統計 | 左 | `Lines: {current} / 2000`12px muted |
| 關閉提示(預設) | 右 | `⚠ Closing this window will stop the server.`12px muted |
| 關閉提示韌體進行中、v2.2 新增) | 右 | `🚫 韌體更新中、關閉視窗可能造成裝置損毀`12px `color.destructive` SemiBold取代預設提示對應 i18n key `control.footer.closeWarningFirmwareActive`、詳見 §6a.8 |
**持久提示為什麼不彈 modal**R5-2 解釋):
- 使用者已明確決策「關閉 = 結束 server」
- 每次關都彈 modal 只會煩,且使用者按過幾次就會盲目點「確定」失去意義
- 持久 footer 文字是「被動告知」而非「主動打斷」,符合 Jakob Nielsen 錯誤預防原則
- 使用者如果真的不想關,看到 footer 提示就會改按最小化
- 若使用者仍誤關,下次開啟 (R5-4 自動起 server + 自動開瀏覽器) 只需 3-5 秒就回到原狀,損失可控
---
## 5. Server 狀態機(五態視覺化)
### 5.1 狀態定義v2.1 修訂)
| State | 觸發條件 | 持續時間 |
|-------|---------|---------|
| `Starting` | 控制台剛開啟 / 使用者按 Start / Restart 過程中 | **通常 4-15 秒,上限 60 秒**R5-E1 |
| `Running` | 6 階段全部完成(含 WebSocket 連上R5-E6 | 主要 state |
| `Stopping` | 使用者按 Stop / 視窗關閉中 | 通常 <2 |
| `Stopped` | Stop 完成尚未 Restart | state |
| `Error` | **啟動階段**任一階段超時 20 秒進 Retry 提示**總計超過 60 **R5-E4仍未就緒 Error<br>**運行階段**`/api/health` 連續失敗達閾值 / sidecar crash 超過 auto-restart 上限 | 停留直到使用者介入 |
### 5.2 視覺對照表v2.1 修訂)
| State | 圓點顏色 | Icon | Status text 範例 | 附加元素 |
|-------|---------|------|----------------|---------|
| **Starting** | `color.warning` 琥珀 | 旋轉 spinner | `Starting · Stage {n}/6` | **log panel 上方浮出「啟動進度面板」**(見 `v2/startup-progress.md`<br>Primary controls 全部 disabled |
| Running | `color.success` 綠 | — | `Running``Running · Browser opened`Toggle ON 首次顯示 10 秒) | 啟動進度面板 fade-outOpen in Browser enabled |
| Running自動開瀏覽器瞬間| `color.success` 綠 | → 淡入 ✓ icon 2 秒 → fade out | `Running · Browser opened` 持續 10 秒後自動變回 `Running` | — |
| Stopping | `color.warning` 琥珀 | 旋轉 spinner | `Stopping...` | 所有 primary controls disabled |
| Stopped | `color.muted-foreground` 灰 | — | `Stopped` | 只有 `Start` 按鈕可按 |
| Error | `color.destructive` 紅 | ⚠ | `Error: {簡短原因}` | **見 §6 錯誤面板**;若從啟動階段進入 Error啟動進度面板切換為 Error 狀態(見 `startup-progress.md §5` |
### 5.3 狀態轉場動畫
- 圓點顏色過渡300 ms ease-out
- Spinner 旋轉1 s linear infinite
- `Running · Browser opened` 出現fade + slide-in-left 200 ms停留 10 秒fade-out 200 ms
- `prefers-reduced-motion: reduce` → 全部動畫降為 0 ms 跳變
---
## 6. Error State 面板R5 共識)
當 Server 進入 `Error` 時,控制台 log panel 上方(介於 log controls 和 log panel 中間)**浮出一個 error banner**log 面板不消失。
### 6.1 Wireframe
```
├───────────────────────────────────────────────────────────────────┤
│ ☑ Follow tail ☑ Show timestamps 🔍 [ Filter ... ] │
├───────────────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ ⚠ Server failed to start │ │
│ │ │ │
│ │ Python sidecar exited with code 1 after 3 retries. │ │
│ │ Last error: ModuleNotFoundError: kneron_plus │ │
│ │ │ │
│ │ [ Restart Server ] [ View log details ↓ ] [ Report... ] │ │
│ └───────────────────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────────────┤
│ 10:24:12 ERROR python sidecar exited code=1 │ ← Log panel 照常顯示
│ 10:24:13 ERROR ... │
│ ... │
├───────────────────────────────────────────────────────────────────┤
```
### 6.2 元件
| 元素 | 類型 | 細節 |
|------|------|------|
| Banner 容器 | `<div role="alert">` | 背景 `color.destructive/10`,邊框 1px `color.destructive/30`,圓角 `radius.md`padding 16 |
| 警告 icon | `<svg>` | 20×20`color.destructive` |
| 標題 | `<strong>` | 14 px SemiBold`color.destructive` |
| 說明 | `<p>` | 13 px`color.foreground`,最多 2 行,溢出 `…` |
| `Restart Server` | Button `primary` `sm` | 點擊 → 呼叫內部 restartbanner 轉為 Starting state |
| `View log details ↓` | Button `ghost` `sm` | 點擊 → 自動捲動 log panel 到最後一條 ERROR 行並 flash 2 次 |
| `Report...` | Button `ghost` `sm` | **【hold】** 現階段**先不實作**。待 PM 提供 GitHub Issue repo URL 後再恢復。Design 原意:開預設瀏覽器到 GitHub Issue 新增頁面,預填錯誤摘要 + 環境資訊version、OS、最後 20 行 log**不含檔名 / 裝置 serial** |
**R5-D1 落地OS 原生通知並存)**
Error state 進入時,除了控制台 log panel 上方的 Error banner`role="alert"` 由 Wails WebView 內部顯示)**另外發送一次 OS 原生 non-blocking 通知**。這是 R5-D1 使用者決策:控制台可能被最小化、或在另一個桌面 / 虛擬桌面,使用者不一定會看到 bannerOS 通知作為次要冗餘提醒仍有價值。
| 平台 | 通知機制 | Fallback |
|------|---------|---------|
| macOS | `osascript -e 'display notification "..." with title "visionA-local"'`toast 非 dialog | — |
| Windows | `wailsRuntime.SendNotification`(優先) | `msg *` 命令列 |
| Linux | `notify-send` | `zenity --notification` |
**行為細節**
- 通知內容:`標題 = visionA-local Server Error` / `內文 = {error.title}: {error.description 前 60 字}`
- **non-blocking**:不阻塞 UI不彈 modal與先前 v1 的 `showNativeError()`(給 startup 致命錯誤用的 modal dialog區分
- **不重複發**:同一次 Error state 只發一次通知,使用者按 Restart Server 或 State 變回 Starting 後才重置「本次已發」flag
- **技術實作**:新增 Go 檔案 `visiona-local/notify.go`,函式 `sendCrashNotification(title, body string) error`;對應 TDD v2 `control-panel.md §4.7`
### 6.3 Dismiss 條件
Banner **不可手動關閉**(避免使用者忽略問題)。只有下列條件自動消失:
- 使用者按 `Restart Server` 且 server 成功進入 `Running`
- 使用者手動修復環境後按 Start 成功
---
## 6a. 韌體進行中關閉攔截v2.2 新增、對應 firmware-management §14.4 第 6 點)
### 6a.1 為什麼需要這個
依 R5-2「關閉控制台 = 結束 server」但有一個例外場景**韌體升級 / 切換進行中**。
- 韌體切換 / 降版是寫 flash 的破壞性操作、中斷會造成裝置永久損毀brick
- 如果使用者在韌體切換進行中關掉 Wails 控制台、Wails close handler 預設會送 SIGTERM 給 server 結束 Python sidecar
- Python sidecar 正在 `update_kdp_firmware_from_files` 中段被砍 = **flash 寫一半就停 = brick**
依 Architect TDD §8.6v2.2 新增、server 已實作「降版進行中拒絕 graceful shutdown」邏輯
- server 收到 SIGTERM → 檢查 `firmware.Service.HasActiveTask()`
- 有 active task → 拒絕 shutdown、回傳 `firmwareInProgress: true` 給 Wails close handler
- 沒 active task → 正常 graceful shutdown既有 7+1s pattern
控制台側需提供對應 UI 攔截關閉動作、警告使用者風險。
### 6a.2 觸發時機
下列任一情況、使用者試圖關閉 Wails 控制台時:
- 按視窗右上角 `×` 關閉鈕
- `⌘W` / `Ctrl+W` 鍵盤快捷鍵
- `⌘Q` / 系統選單「結束」macOS/ 系統匣「結束」Windows
- 強制最小化 + 結束程式(透過 dock / taskbar
→ 控制台先 query server `/api/firmware/status`(或對應 IPC method
- 回傳 `{active: false}` → 正常走 R5-2 流程(結束 server + 關視窗)
- 回傳 `{active: true, taskInfo: {deviceName, stage, etaSeconds}}` → 觸發本節攔截 modal
### 6a.3 攔截 Modal Wireframe
```
┌─ ⚠ 韌體更新進行中 ───────────────────────────────────────┐
│ │
│ 🚫 強制關閉可能造成裝置永久損毀 │
│ │
│ KL520 #1 的韌體切換正在進行中。 │
│ 現在強制關閉應用程式會中斷韌體寫入、可能造成裝置 │
│ 永久損毀brick、無法救援。 │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ 目前階段:寫入韌體(階段 3 / 4 │ │
│ │ 預估剩餘:約 22 秒 │ │
│ │ ████████████░░░░ 60% │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ⚠ 建議「繼續等待」、約 22 秒後即可安全關閉。 │
│ │
│ [ 繼續等待 ] [ 強制關閉 ] │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 6a.4 元件規格
| 元素 | 類型 | 樣式 |
|------|------|------|
| Modal 容器 | `<div role="alertdialog" aria-modal="true">` | 寬度 520px、`color.card` 背景、`color.destructive` 2px 邊框、`elevation.5` 陰影 |
| 標題 icon | `<svg>` | 24×24、`color.destructive` |
| 標題 | `<h2>` | 18px / SemiBold / `color.destructive`、「⚠ 韌體更新進行中」 |
| 主要警語 | `<strong>` | 16px / Bold / `color.destructive`、「🚫 強制關閉可能造成裝置永久損毀」 |
| 說明段落 | `<p>` | 14px / regular / `color.foreground` |
| 進度資訊區 | `<div>` | 背景 `color.muted/30`、padding 16、圓角 `radius.md` |
| 階段文字 | `<span>` | 13px / medium / `color.foreground` |
| 預估剩餘 | `<span>` | 13px / medium / `color.foreground`、每秒更新(從 server WebSocket progress event 推送)|
| 進度條 | `<progress>``<div role="progressbar">` | 高 8px、`color.destructive` fill強調危險`color.muted` track、圓角 `radius.full` |
| 建議文字 | `<p>` | 13px / regular / `color.warning`、附 ⚠ icon |
| **「繼續等待」按鈕** | Button `primary` `md` | **預設聚焦**focus、Enter 觸發、樣式為主要 CTA |
| **「強制關閉」按鈕** | Button `destructive ghost` `md` | 邊框 `color.destructive`、文字 `color.destructive`、背景透明(不主動誘導點擊)|
### 6a.5 互動行為
**「繼續等待」**(建議路徑):
- 點擊 / Enter → 關閉 modal、保留控制台視窗
- 控制台留在原狀態韌體進度面板繼續顯示、log 持續更新)
- 不結束 server、不關 Wails 視窗
- 韌體任務完成後、firmware-management §5.4 / §6.4 toast 出現、使用者可以再次嘗試關閉視窗(這次走正常 R5-2 流程)
**「強制關閉」**(危險路徑):
- 點擊 → **不立即關閉**、跳出第二層**最終確認 modal**
```
┌─ 🚫 確定要強制關閉? ────────────────────────────────┐
│ │
│ 強制關閉會: │
│ • 中斷正在進行的韌體寫入 │
│ • 可能造成裝置永久損毀(無法救援) │
│ • 即使重新插拔也無法復原 │
│ │
│ 請輸入「FORCE」確認你了解後果
│ ┌──────────────────────────────────────────┐ │
│ │ │ │
│ └──────────────────────────────────────────┘ │
│ (大小寫需完全相同) │
│ │
│ [ 返回 ] [ 確認強制關閉 ] │
│ │
└───────────────────────────────────────────────────────┘
```
- 「FORCE」輸入框比對與 firmware-management §6.1 「DOWNGRADE」相同邏輯嚴格 `===` 比對)
- 「確認強制關閉」按鈕在輸入 `FORCE` 前 disabled、輸入正確後變為 `destructive` 紅色
- 點擊 → 前端呼叫 Wails binding `App.ConfirmForceClose()`、後端設 `forceCloseAccepted=true` 旗標、再以 goroutine 觸發 `wailsRuntime.Quit(ctx)` 關閉視窗
- 下一輪 `OnBeforeClose` 看到旗標已設、直接放行 → 走 既有 `OnShutdown` 7+1s **graceful shutdown** 流程(呼叫 `ctrl.Stop()`、server 端再以 `SIGTERM` + `AwaitActiveTasksOrTimeout(180s)` 雙層防護收尾)
- **設計刻意採 graceful 而非 SIGKILL**
- 即使使用者已通過「FORCE」二次確認、仍給 server 一個有限的時間窗(最長 180s讓 Python sidecar 嘗試把當前 flash 寫入 block 收尾chip 內部 atomic-write boundary
- 多數情況下 firmware task 會在 180s 內自然完成、實際關閉延遲可能只有數秒
- 真正卡死的場景才會撐到 180s timeout、之後 server 強制走 shutdown、再 `os.Exit(0)`
- 比起直接 SIGKILL「砍 process = 砍寫入 = 必定 brick」、graceful 路徑至少給裝置一個「可能不 brick」的機會
- 使用者體感:點完「確認強制關閉」後視窗不會瞬間消失、可能停留數秒到 180s極端才關閉可在 modal 關閉前暫留 Stopping... 狀態提示
**ESC 鍵**:等同點「繼續等待」(安全路徑)、關 modal 保留視窗
**外部點擊**:不可關閉(捕獲事件、抖動 modal 200ms 提示)、與 firmware-management §6.1 同設計
### 6a.6 視覺強度層級對比
| 元素 | firmware-management §6.1 降版二次確認 | 本節 §6a.3 韌體進行中關閉攔截 |
|------|------------------------------------|---------------------------|
| 場景 | 降版**開始前** | 降版**進行中** + 使用者試圖中斷 |
| 風險等級 | 中(降版本身有 brick 風險、但 < 1%| 高(中斷正在寫 flash = 高機率 brick|
| 確認字串 | `DOWNGRADE`(指向操作意圖)| `FORCE`(指向強制中斷)|
| Modal 邊框 | 1px | **2px**(更強烈)|
| 預設聚焦 | 「DOWNGRADE」input | **「繼續等待」按鈕**(引導安全路徑)|
| 安全 CTA | 「取消」`ghost` | **「繼續等待」`primary`**(主要 CTA、強引導|
| 危險 CTA | 「確認切換」`destructive` filledinput 通過後)| 「強制關閉」`destructive ghost`(不主動誘導、需二次確認)|
### 6a.7 不重複攔截
- 同一個關閉嘗試只跳一次 modal
- 使用者選「繼續等待」後、韌體完成前如再次嘗試關閉 → 重新跑 6a.2 流程(再次 query server status
- 「強制關閉」確認後、modal 立即關閉、不留任何撤銷機會(已通過二次確認)
### 6a.8 與 Footer 持久提示 §4.6 的關係
- §4.6 「⚠ Closing this window will stop the server.」footer 提示**仍然顯示**
- 韌體進行中時、footer 提示**改為更嚴重的紅色版本**:「🚫 韌體更新中、關閉視窗可能造成裝置損毀」
- 對應 i18n key`control.footer.closeWarningFirmwareActive`
### 6a.9 i18n keys新增
| Key | zh-TW | en |
|-----|-------|----|
| `control.firmwareBlock.title` | 韌體更新進行中 | Firmware update in progress |
| `control.firmwareBlock.heading` | 🚫 強制關閉可能造成裝置永久損毀 | 🚫 Force-close may permanently damage the device |
| `control.firmwareBlock.description` | {deviceName} 的韌體{operation}正在進行中。現在強制關閉應用程式會中斷韌體寫入、可能造成裝置永久損毀brick、無法救援。 | {operation} on {deviceName} is in progress. Force-closing the application now will interrupt the firmware write and may permanently damage (brick) the device beyond recovery. |
| `control.firmwareBlock.operation.upgrade` | 升級 | upgrade |
| `control.firmwareBlock.operation.switch` | 切換 | switch |
| `control.firmwareBlock.stage` | 目前階段:{stage}(階段 {n} / {total}| Current stage: {stage} (stage {n} / {total}) |
| `control.firmwareBlock.eta` | 預估剩餘:約 {seconds} 秒 | Estimated remaining: ~{seconds}s |
| `control.firmwareBlock.recommendation` | ⚠ 建議「繼續等待」、約 {seconds} 秒後即可安全關閉。 | ⚠ Recommended: "Continue waiting". Safe to close in about {seconds}s. |
| `control.firmwareBlock.action.continueWaiting` | 繼續等待 | Continue waiting |
| `control.firmwareBlock.action.forceClose` | 強制關閉 | Force close |
| `control.firmwareBlock.confirm.title` | 🚫 確定要強制關閉? | 🚫 Confirm force close? |
| `control.firmwareBlock.confirm.risk.interrupt` | 中斷正在進行的韌體寫入 | Interrupt the ongoing firmware write |
| `control.firmwareBlock.confirm.risk.brick` | 可能造成裝置永久損毀(無法救援) | May permanently damage the device (unrecoverable) |
| `control.firmwareBlock.confirm.risk.noRecover` | 即使重新插拔也無法復原 | Cannot be recovered even by unplugging and reconnecting |
| `control.firmwareBlock.confirm.prompt` | 請輸入「FORCE」確認你了解後果 | Type "FORCE" to confirm you understand the consequences: |
| `control.firmwareBlock.confirm.placeholder` | FORCE | FORCE |
| `control.firmwareBlock.confirm.helpText` | (大小寫需完全相同) | (case-sensitive) |
| `control.firmwareBlock.confirm.action.back` | 返回 | Back |
| `control.firmwareBlock.confirm.action.forceClose` | 確認強制關閉 | Confirm force close |
| `control.footer.closeWarningFirmwareActive` | 🚫 韌體更新中、關閉視窗可能造成裝置損毀 | 🚫 Firmware update in progress — closing window may damage device |
合計新增 **19 個 i18n keys**zh-TW / en 各一套)。
### 6a.10 無障礙考量
| 項目 | 設計 |
|------|------|
| Modal 結構 | `role="alertdialog"`、screen reader 主動朗讀 |
| Focus management | modal 開啟時自動 focus 「繼續等待」按鈕、focus trap |
| Tab 順序 | 「繼續等待」→「強制關閉」 |
| ESC 鍵 | 等同「繼續等待」(安全路徑)|
| 危險視覺不單靠顏色 | 標題附 ⚠ icon + 「強制關閉」按鈕附文字描述(不只是紅色) |
| Reduced motion | `prefers-reduced-motion: reduce` → modal 動畫 0ms、進度條改靜態顯示 |
| 觸控目標 | 兩個按鈕 ≥ 44×44px |
| 對比 | 紅色 destructive 對 card 背景 ≥ 4.5:1WCAG AA、critical 信號不妥協)|
### 6a.11 與 Architect TDD §8.6 的銜接
- TDD §8.6 提供 server 端 `firmware.Service.HasActiveTask()` method 與 SIGTERM handler firmware-aware graceful 流程
- **韌體進行中偵測Wails close handler 端、實作 §8.6.2**
- `OnBeforeClose` hook 觸發後、Wails 後端呼叫 `queryFirmwareActiveTasks()`、以 HTTP GET `http://127.0.0.1:{serverPort}/api/firmware/active-tasks`1s timeout、fail-open取得 active task 清單
- 有 active task → Wails 後端透過 `wailsRuntime.EventsEmit(ctx, "app:firmware-in-progress", payload)` 推送事件給前端
- 前端 listen `app:firmware-in-progress`、payload `{ hasActive: true, tasks: [{ taskId, deviceId, deviceName, chip, direction, stage, elapsedMs, etaSeconds }] }` → 渲染本節 6a.3 攔截 modal
- **「強制關閉」確認後(實作 §8.6.2 第 6 點)**
- 前端輸入「FORCE」通過 → 呼叫 Wails binding `window.go.main.App.ConfirmForceClose()`
- 後端在 `firmwareCloseGuard` 設 `forceCloseAccepted=true`mu 保護、race-free、再以 goroutine 觸發 `wailsRuntime.Quit(ctx)`
- 下一輪 `OnBeforeClose` 被呼叫時、guard 看到旗標已設 → return `preventClose=false` 放行
- Wails 進入 `OnShutdown`、呼叫既有 `ctrl.Stop()` 走 R5-2 graceful7+1spath、server 端進入 SIGTERM handler、`AwaitActiveTasksOrTimeout(180s)` 雙層防護收尾
- **不採 SIGKILL bypass**:理由詳見 §6a.5(給 firmware task 一個收尾機會、降低 brick 機率)
- **fail-open 設計**query active-tasks 失敗server 沒起來 / 1s timeout / malformed JSON 等)→ 視為「無 active task」放行、靠 server 端 SIGTERM handler 雙層擋。Wails 層 false negative 不直接造成 brick、但使用者會看不到攔截 modal、改由 server 端在 system room broadcast `server:shutdown-pending` 事件通知前端M8-5 frontend 接 event 顯示 toast
- 詳細實作對齊 TDD §8.6.1§8.6.5
---
## 7. 啟動行為(對應 R5-4 / R5-D3 / R5-E
### 7.1 預設流程v2.1 修訂)
**v2.1 重要變更**Starting 狀態下控制台顯示**階段化啟動進度面板**(見 `v2/startup-progress.md`),不是只有一顆 spinner。下述「step」對應進度面板的 6 個階段。
```
1. 使用者雙擊 visionA-local.app
2. 控制台視窗開(螢幕中央 / 上次位置)
3. 控制台進入 Starting 狀態log panel 上方顯示「啟動進度面板」
→ 階段 1初始化控制台
→ 階段 2檢查 Python runtime 與驅動
→ 階段 3啟動本機伺服器等 /api/health 200
→ 階段 4偵測 Kneron 裝置
4. Server ready階段 3 完成訊號 = /api/health 200
5. 【每次 / Settings 為 ONmacOS/Windows 預設Linux 預設 OFF
階段 5「開啟瀏覽器」觸發 OS open browser
- macOS: `open http://127.0.0.1:3721/`
- Windows: `start http://127.0.0.1:3721/`
- Linux: `xdg-open http://127.0.0.1:3721/`
【Toggle OFF 時】階段 5 標記為「跳過(依偏好設定)」,不執行 OS open但仍推進
6. 階段 6等待 Web UI 連線
(等 WebSocket hub 收到第一個 client 連線R5-E6 決策)
【Toggle OFF 時】此階段改為「等待使用者手動點擊『在瀏覽器開啟』」
7. 所有 6 階段完成
→ 啟動進度面板淡出fade-out 200 ms
→ Status: Running
→ Status text 顯示 `Running · Browser opened` 10 秒Toggle ON 時)
或純 `Running`Toggle OFF 時,使用者尚未手動 Open
控制台留在背景(不最小化、不關閉)
8. 瀏覽器 tab 進入 Next.js First-Run wizard見 v2.4
```
**R5-D3 重點****每次**啟動(每次 Wails App process 新啟動)都會跑完整 6 階段流程並觸發 OS open不是只有首次。**Restart Server**(同一個 Wails process 內重啟 server不會重開瀏覽器 tab — 由 Offline Overlay 的自動重連處理(見 `v2/server-offline-overlay.md`)。
### 7.2 視覺回饋
第 6 步的 `Running · Browser opened` 是使用者看到控制台第一個確認 server 就緒的訊號。具體視覺:
- Status dot 綠色
- Status text 後方 fade-in 一個 ✓ icon`color.success`
- Text 改為 `Running · Browser opened`
- 10 秒後 ✓ icon 淡出text 縮為 `Running`
### 7.3 例外情境
| 情境 | 控制台行為 |
|------|----------|
| Server `Starting` 超過 5 秒 | 進入 Error state見 §6**不開瀏覽器** |
| Port 3721 被佔 | Server fallback 到 3722 / 3723Header 顯示 `Port: 3722 (default 3721 in use)`,瀏覽器開的 URL 同步換 |
| Settings「自動開瀏覽器」= OFF | Server `Running` 後不做 auto-open使用者需手動點 `Open in Browser` |
---
## 8. 深色模式處理
控制台深色模式**與 Web UI 同步**,機制:
- 讀取 OS 偏好:控制台是 Wails WebView直接用 CSS `prefers-color-scheme`
- CSS 變數切換:和 `frontend/src/app/globals.css` 用一樣的 `:root` / `[data-theme='dark']` block
- 不提供手動切換v1 決策延續)
**Dark 下額外考量**
- Log panel 背景 `oklch(0.18 0 0)`(比 surface 再暗 5%,模仿 terminal
- ERROR 紅色在 dark 下用 `oklch(0.72 0.19 25)`(避免過亮刺眼)
- 圓點狀態色全部用 dark variant確保 4.5:1 對比R4-3 降為盡力而為,但狀態色這種 critical 信號仍維持嚴格)
---
## 9. i18n key 清單(新元件)
控制台文字走 `desktop-control` namespace**獨立於** Next.js Web UI 的 i18n 檔(但抽自同一份辭典,避免兩處維護)。
### 9.1 新增 keyzh-TW / en
| Key | zh-TW | en |
|-----|-------|----|
| `control.title` | visionA-local · 伺服器控制台 | visionA-local · Server Control |
| `control.status.starting` | 啟動中... | Starting... |
| `control.status.running` | 執行中 | Running |
| `control.status.runningBrowserOpened` | 執行中 · 已開啟瀏覽器 | Running · Browser opened |
| `control.status.stopping` | 停止中... | Stopping... |
| `control.status.stopped` | 已停止 | Stopped |
| `control.status.error` | 錯誤:{reason} | Error: {reason} |
| `control.meta.port` | 連接埠 | Port |
| `control.meta.portFallback` | 連接埠:{port}(預設 {default} 被佔用) | Port: {port} (default {default} in use) |
| `control.meta.uptime` | 執行時間 | Uptime |
| `control.meta.pid` | 程序 ID | PID |
| `control.meta.version` | 版本 | Version |
| `control.action.openBrowser` | 在瀏覽器開啟 | Open in Browser |
| `control.action.start` | 啟動 | Start |
| `control.action.manage` | 管理 | Manage |
| `control.action.stopServer` | 停止伺服器 | Stop server |
| `control.action.restartServer` | 重新啟動伺服器 | Restart server |
| `control.log.followTail` | 自動跟隨最新 | Follow tail |
| `control.log.showTimestamps` | 顯示時間戳 | Show timestamps |
| `control.log.filterPlaceholder` | 過濾 log... | Filter... |
| `control.log.jumpToLatest` | 跳到最新 | Jump to latest |
| `control.log.clear` | 清空 | Clear |
| `control.log.clearToast` | 已清空 log可復原 | Log cleared (undo) |
| `control.log.copy` | 複製 | Copy |
| `control.log.copyPrivacyHint` | Log 可能包含檔名與裝置資訊,請注意分享對象 | Log may contain filenames and device info. Share with care. |
| `control.log.export` | 匯出 log | Export log |
| `control.log.openFolder` | 開啟 log 資料夾 | Open log folder |
| `control.log.lines` | 行數:{current} / {max} | Lines: {current} / {max} |
| `control.footer.closeWarning` | ⚠ 關閉此視窗會停止伺服器 | ⚠ Closing this window will stop the server |
| `control.error.title` | 伺服器無法啟動 | Server failed to start |
| `control.error.description` | {具體原因} | {reason} |
| `control.error.restartButton` | 重新啟動伺服器 | Restart Server |
| `control.error.viewLogDetails` | 檢視 log 詳情 | View log details |
| `control.error.reportButton` | 回報問題... | Report... |
### 9.2 刪除 key從現有 Next.js i18n 砍)
見 `v2/source-selector-update.md §3.2`。
---
## 10. 無障礙考量
| 項目 | 設計 |
|------|------|
| Keyboard navigation | 所有 interactive 元素 `tabindex` 合理序列Open in Browser → Start/Manage → Follow tail → Show timestamps → Filter → (log panel 可選取) → Clear → Copy → Export → Open folder |
| Focus ring | 沿用 Web UI token `ring.2 · color.primary`2 px outline-offset |
| Keyboard shortcut | `⌘F` / `Ctrl+F` 聚焦 filter`⌘C` / `Ctrl+C` 複製選取 log`⌘W` / `Ctrl+W` 關視窗R5-2結束 server |
| `⌘Q` | macOS 原生結束 app停 server + quit |
| Screen reader | Status indicator `<span role="status" aria-live="polite">`Error banner `<div role="alert">`log panel `<output aria-live="polite" aria-atomic="false">`(新行 append |
| ARIA label | 所有 icon button 有 `aria-label`(例如 Follow tail checkbox 的 trailing spinner 有 `aria-label="Auto-scrolling enabled"` |
| 色彩對比 | Status dot / ERROR level log / Error banner 強制 ≥ 4.5:1即使 A11y 整體降級為「盡力而為」critical 信號不妥協) |
| Reduced motion | `prefers-reduced-motion: reduce` → 關閉 spinner 旋轉(改為靜態點)、關閉 log 滑入動畫、關閉 Browser opened fade |
| 字級可縮放 | 使用 `rem` 而非 `px` 定義字級,支援 OS 字級偏好 |
---
## 11. 與 v1`design-analysis-round2-refactor.md`)的差異
| 面向 | v1 分析稿 | v2 正式規格 |
|------|----------|------------|
| 視窗職責 | 三方尚在討論 | 確定為雙 UIR5-1 |
| 關閉行為 | 待 D1 決策 | **關閉 = 結束 server**footer 持久提示R5-2 |
| Tray | 建議復活 tray | **不做**R5-3 |
| 首次啟動 | 建議自動開瀏覽器 | 採納自動開瀏覽器R5-4 |
| Primary controls | 4 顆 | 3 顆Stop/Restart 併入 Manage 下拉) |
| Header 狀態列 | 固定 `Running` | 首次啟動後動態 `Running · Browser opened` 10 秒 |
| Error 狀態視覺 | 未設計 | 新增 Error banner§6 |
| Mock 切換 | 未納入 scope | **明確砍除**R5-5a |
| Log 上限 | 1000 行 | 1000 行(維持) |
| Log 寫檔 | 7 天 / 10MB rotate | 維持 |
| 語系 | 跟隨系統 | 跟隨系統(維持) |
---
## 12. v2 → v2.1 Diff2026-04-14
| # | 位置 | v2 | v2.1 | 來源 |
|---|------|----|----|------|
| 1 | §4.4 Log panel 最大行數 | 1000 行 | **2000 行**(對齊 TDD v2 ring buffer | Architect Review Minor m-1 |
| 2 | §4.4 Log 寫檔 | rotate 7 天 / 10 MB | **無落地寫檔**in-memory ring buffer使用者透過 Export log 手動匯出) | Architect Review Minor m-12 |
| 3 | §4.6 Footer 行數顯示 | `Lines: {current} / 1000` | `Lines: {current} / 2000` | Architect Review Minor m-1 |
| 4 | §5 狀態機 | Starting 只有 spinner1-5 秒 | Starting 顯示**階段化啟動進度面板**4-15 秒(上限 60 秒R5-E1 | R5-E |
| 5 | §6.2 Error banner | `Report...` 正常按鈕 | **【hold】** 待 PM 提供 GitHub Issue repo URL 後再恢復 | Architect Review Minor m-11 / G-3 |
| 6 | §6.2 新增 | — | **R5-D1 OS 原生通知並存**Error state 發 non-blocking toast notification不是 modal | R5-D1 / Architect Review Minor m-4 |
| 7 | §7.1 第 5 步 | 「首次 / Settings 為 ON」 | **「每次 / Settings 為 ON」**,新增 Linux 預設 OFF 說明,流程改為 6 階段化 | R5-D3 + R5-E |
| 8 | §7.1 新增 | — | 引用新檔 `v2/startup-progress.md`R5-E 階段化啟動進度面板) | R5-E |
## 13. v2.1 → v2.2 Diff2026-05-25
| # | 位置 | v2.1 | v2.2 | 來源 |
|---|------|----|----|------|
| 1 | §6a 新增整節 | — | **§6a 韌體進行中關閉攔截**(兩層 modal + 「FORCE」二次確認 + 19 個 i18n keys + 6a.8 footer 紅色變體) | firmware-management §14.4 第 6 點 + Architect TDD §8.6 + Design 三方互審吸收 |
---
**下一步**:交 M8-5 Frontend Agent 實作Wails 控制台 + 啟動進度面板 + 韌體進行中關閉攔截 modal交 Reviewer 審查 control-panel.md + startup-progress.md + firmware-management.md 整體一致性。