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

720 lines
29 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.

# v2/startup-pipeline.md — R5-E 階段化啟動管線
> 所屬TDD v2 §2.9v2.1 新增)
> 版本v2.12026-04-14 R5-E 實作細節)
> 決策依據R5-E1 ~ R5-E6AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度)
> 對應 milestoneM8-4bBackend Go + Frontend vanilla JS
> 相關文件:`v2/control-panel.md` §3.1(啟動進度面板 UI、`v2/server-lifecycle.md` §2.1(冷啟動時間軸)
---
## 0. 目的與決策回顧
v2.0 PM §11-2 提問 AC-1.3「10 秒可達性」Architect 樂觀 4 s / 悲觀 8 s / 最壞 11 s。PM 審閱後將此硬指標**取代**為 R5-E 的新規則:
| R5-E | 內容 |
|------|------|
| R5-E1 | AC-1.3 上限 **60 秒**(軟硬門檻) |
| R5-E2 | 分成 **6 個階段**,逐階段顯示進度 |
| R5-E3 | 單一階段卡 > **20 秒**soft timeout→ 顯示「階段 N 正在重試」副文字,**不中斷流程** |
| R5-E4 | 總時 > **60 秒**hard timeout→ 進 Error state |
| R5-E5 | 階段文字由 Design Spec v2.1 決定(本文件只定 event schema + labelKey |
| R5-E6 | 「瀏覽器就緒」定義 = WebSocket 連上 server`OnClientConnected` 第一次觸發) |
v2.0 的樂觀/悲觀估算仍有意義,作為每階段的預算參考(見 `server-lifecycle.md` §2.1 的階段預算表)。
---
## 1. Event Schema
Wails 控制台vanilla JS透過 `EventsOn` 訂閱以下 4 個 event。Payload 使用 JSON-serializable Go struct。
### 1.1 `startup:progress`
每個階段的狀態變化都 emit 一次,可能是 `running`(階段開始)、`completed`(階段完成)、`failed`(階段失敗)。
```go
type StartupProgressEvent struct {
Stage int `json:"stage"` // 1-6
TotalStages int `json:"totalStages"` // 固定 6
LabelKey string `json:"labelKey"` // i18n key見 §2
Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped"
StartedAt int64 `json:"startedAt"` // Unix ms該階段開始時間
}
```
**Status 值**v2.1 二次審閱新增 `skipped`
- `pending`階段尚未開始pipeline render 初始狀態)
- `running`階段進行中watcher 會對此階段檢查 soft / hard timeout
- `completed`:階段成功完成
- `failed`:階段失敗 → pipeline 停止、進 Error state
- `skipped`v2.1 新增):該階段依偏好設定或平台規則被跳過,不需執行也不檢查 timeout
- 目前使用情境:階段 5「開啟瀏覽器」在 `prefs.AutoOpenBrowser == false`Linux 預設 OFF或使用者手動關閉→ status=`skipped`
- 前端收到 skipped → 顯示 ⏭ 圖示 + 文字「跳過依偏好設定Design Spec v2.1 §4.1 已定)
- Watcher 看到 skipped 狀態時不檢查 soft timeout進入下一階段的邏輯同 completed
前端 render 邏輯:收到 event → 更新 `#startup-stage-<N>` DOM 的 status icon 與時間。
### 1.2 `startup:stage-timeout`
某階段 > 20 秒soft timeout未完成時 emit 一次(**不重複 emit**)。僅作提示,不中斷流程。
```go
type StartupStageTimeoutEvent struct {
Stage int `json:"stage"`
SoftTimeoutSeconds int `json:"softTimeoutSeconds"` // 固定 20
}
```
前端 render 邏輯:收到 event → 在該階段行下方插入 `<p class="startup__retry-hint">{i18n("startup.retrying", { stage })}</p>`
### 1.3 `startup:error`
任一階段失敗或總時 > 60 秒 emit 一次。之後 pipeline 停止,後續不會再有任何 `startup:*` event。
```go
type StartupErrorEvent struct {
Stage int `json:"stage"`
Error string `json:"error"` // 技術性錯誤訊息
Cause string `json:"cause"` // "stage-failure" | "total-timeout"
}
```
前端 render 邏輯:切到 Error state 顯示,同時 `server:state-change` 會被觸發(因為 `ctrl.setState(Error, ...)` 已呼叫)。
### 1.4 `startup:ready`
6 個階段都 `completed` 後 emitpayload 為空。
```go
// 無 structEventsEmit(ctx, "startup:ready", nil)
```
前端 render 邏輯淡出啟動進度面板300 ms ease→ 顯示主控台 UI。
---
## 2. i18n Key 清單
文案由 Design Spec v2.1 敲定。TDD 只定義 key 名稱:
| Key | 用途 |
|-----|------|
| `startup.title` | 標題「正在啟動 visionA Local」 |
| `startup.stage.1.label` | 階段 1初始化 Wails 控制台 |
| `startup.stage.2.label` | 階段 2檢查 Python runtime |
| `startup.stage.3.label` | 階段 3啟動本機伺服器 |
| `startup.stage.4.label` | 階段 4偵測 Kneron 裝置 |
| `startup.stage.5.label` | 階段 5開啟瀏覽器 |
| `startup.stage.6.label` | 階段 6等待 Web UI 連線 |
| `startup.retrying` | 「第 {stage} 階段正在重試 …」副文字 |
| `startup.elapsed` | 「已耗時 {seconds} s · 上限 60 s」 |
| `startup.error.stageFailure` | 「第 {stage} 階段失敗:{error}」 |
| `startup.error.totalTimeout` | 「啟動超過 60 秒,請檢查系統資源」 |
檔案位置:`visiona-local/frontend/i18n/zh-TW.json` / `en-US.json`
---
## 3. 6 階段對應的 Go 實作點
| # | 階段 | 在哪裡 `pipeline.Start(N)` | 在哪裡 `pipeline.Complete(N)` |
|---|------|---------------------------|------------------------------|
| 1 | 初始化 Wails 控制台 | `app.go:startup` 最前面(`a.ctx != nil` 之後第一行)| 同一個 function 的 `seedUserDataDir()` 返回後 |
| 2 | 檢查 Python runtime | `startServerV2` 開頭、在 `ensurePythonRuntime()` 前 | `ensurePythonRuntime()` 返回後 |
| 3 | 啟動本機伺服器 | 階段 2 完成後立即 | `waitHealthy(port, 30s)` 返回後server HTTP OK|
| 4 | 偵測 Kneron 裝置 | 階段 3 完成後立即 | 呼叫 server 的 `GET /api/devices` 第一次收到 response無論是否有硬體秒回即算完成|
| 5 | 開啟瀏覽器 | 階段 4 完成後 | 呼叫 `OpenInBrowser("")` 返回後(不等瀏覽器真的開,只等 `open`/`start`/`xdg-open` 命令 return`AutoOpenBrowser=false` 則**不呼叫 OpenInBrowser**,直接 emit status=`skipped` 並進入階段 6 |
| 6 | 等待 Web UI 連線 | 階段 5 完成後 | server 的 WebSocket hub `OnClientConnected` callback 第一次觸發(透過 HTTP 或 channel 通知 Wails|
**階段 6 的實作細節v2.1 定版sentinel file 方案)**
**決定採用 sentinel file**`<dataDir>/.first-ws-connected`,理由見下方「為什麼不用 channel / callback 直接串」。
**流程**
1. **Server 端**WebSocket hub 的 `OnClientConnected` callback 第一次觸發時:
```go
// server/internal/websocket/hub.go
func (h *Hub) OnClientConnected(c *Client) {
h.firstConnOnce.Do(func() {
sentinelPath := filepath.Join(h.dataDir, ".first-ws-connected")
f, err := os.Create(sentinelPath)
if err == nil {
_, _ = fmt.Fprintf(f, "bootId=%s\nts=%d\n", h.bootID, time.Now().UnixMilli())
_ = f.Close()
}
// 檔案內容存 boot-id + timestamp 便於 debug寫失敗不影響功能Wails 端 poll 不到也會超時 fail
})
// ... 其他既有邏輯
}
```
使用 `sync.Once` 確保僅第一次連線觸發;後續連線不重複寫檔。
2. **Wails 端**`StartupPipeline.watcher` goroutine 每秒 tick 時額外檢查 sentinel 檔案:
```go
// 當 current == 6 時,每次 tick 檢查 sentinel 檔案
if cur == 6 {
sentinelPath := filepath.Join(p.app.dataDir, ".first-ws-connected")
if _, err := os.Stat(sentinelPath); err == nil {
p.Complete(6) // → markReady() → emit startup:ready
return
}
}
```
3. **下次 Start 前清檔**
- `RestartStartupSequence()` 執行時先 `os.Remove(sentinelPath)`(避免殘留檔案導致新階段 6 瞬間完成)
- `ServerController.Stop()` 時也清檔(正常停機的清理)
- 冷啟動時不需特別清:第一次啟動時檔案不存在,第二次冷啟動前 app 已經整個退出看情況v2.1 決定 **每次 `StartServer` 的前置步驟就呼叫一次 `os.Remove(sentinelPath)`**(最保險,重複 remove 不會錯,反正 `os.IsNotExist` 當正常情況)
**為什麼用 sentinel file 而非 channel / callback 直接串**
- **Go server 和 Wails app 是兩個 process**server 是由 `exec.Command(server binary, ...)` spawn 出來的子程序,跟 Wails 的 Go runtime 完全隔離 → 不能共享 Go channel、mutex 或任何 runtime 記憶體
- **IPC 替代方案比較**
- HTTP long-poll endpoint → 需要佔一個 HTTP connection + 處理 timeout實作較重
- Unix domain socket / named pipe → 跨平台macOS/Linux/Windows實作差異大Windows 下處理 pipe 權限繁瑣
- Sentinel file → 跨平台(`os.Stat` 到處都行)、零依賴、檔案內容可存 boot-id + timestamp 做 debug → **最簡**
- **可觀測性**:使用者遇到問題時可以直接檢查 `<dataDir>/.first-ws-connected` 是否存在,判斷階段 6 是否真的完成
**備選方案**(若未來 Go server 改為 in-process module不再 spawn 子程序):可以改走 Go channel / callback 直接串屆時在本文件註記即可v2.1 的前提是子程序模型。
---
## 4. StartupPipeline Go struct
檔案:`visiona-local/startup_pipeline.go`
```go
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
const (
startupTotalStages = 6
startupSoftTimeout = 20 * time.Second
startupHardTimeout = 60 * time.Second
startupWatcherTickMs = 1000
)
type stageState struct {
status string // "pending" | "running" | "completed" | "failed"
startedAt time.Time
completedAt time.Time
softTimeoutEmitted bool
}
type StartupPipeline struct {
app *App
mu sync.Mutex
stages [startupTotalStages + 1]stageState // 1-indexed
current int // 0 = not started, 1-6 = in progress, 7 = ready, -1 = failed
startedAt time.Time
watcherCancel context.CancelFunc
watcherDone chan struct{}
}
func NewStartupPipeline(app *App) *StartupPipeline {
return &StartupPipeline{
app: app,
current: 0,
}
}
// Start 啟動整個 pipeline從階段 1 開始),並開啟 watcher goroutine。
// 只能呼叫一次。
func (p *StartupPipeline) Start(ctx context.Context) {
p.mu.Lock()
p.startedAt = time.Now()
p.current = 1
p.stages[1].status = "running"
p.stages[1].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(1)
watcherCtx, cancel := context.WithCancel(ctx)
p.watcherCancel = cancel
p.watcherDone = make(chan struct{})
go p.watcher(watcherCtx)
}
// Complete 標記當前階段完成,並自動切到下一階段(若還有)。
func (p *StartupPipeline) Complete(stage int) {
p.mu.Lock()
if p.current != stage || p.current <= 0 {
p.mu.Unlock()
return // 重複呼叫或順序錯誤,忽略
}
p.stages[stage].status = "completed"
p.stages[stage].completedAt = time.Now()
p.mu.Unlock()
p.emitProgress(stage)
if stage == startupTotalStages {
p.markReady()
return
}
// 進入下一階段
p.mu.Lock()
next := stage + 1
p.current = next
p.stages[next].status = "running"
p.stages[next].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(next)
}
// SkipStage 標記某階段為 "skipped",並自動切到下一階段(行為類似 Complete
// v2.1 新增:用於階段 5 在 AutoOpenBrowser=false 時跳過 OpenInBrowser 呼叫。
// Watcher 看到 skipped 狀態時不檢查 soft / hard timeout。
func (p *StartupPipeline) SkipStage(stage int) {
p.mu.Lock()
if p.current != stage || p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "skipped"
p.stages[stage].completedAt = time.Now()
p.mu.Unlock()
p.emitProgress(stage) // emit status=skipped
if stage == startupTotalStages {
p.markReady()
return
}
p.mu.Lock()
next := stage + 1
p.current = next
p.stages[next].status = "running"
p.stages[next].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(next)
}
// Fail 標記當前階段失敗pipeline 停止。
func (p *StartupPipeline) Fail(stage int, err error) {
p.mu.Lock()
if p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "failed"
p.current = -1
p.mu.Unlock()
p.emitProgress(stage)
p.emitError(stage, err, "stage-failure")
p.stopWatcher()
}
// markReady 所有階段完成後觸發。
func (p *StartupPipeline) markReady() {
p.mu.Lock()
p.current = startupTotalStages + 1
p.mu.Unlock()
if p.app.ctx != nil {
wailsRuntime.EventsEmit(p.app.ctx, "startup:ready", nil)
}
p.stopWatcher()
}
func (p *StartupPipeline) emitProgress(stage int) {
p.mu.Lock()
st := p.stages[stage]
p.mu.Unlock()
if p.app.ctx == nil {
return
}
// 使用 goroutine + select 避免阻塞(萬一 Wails IPC 慢)
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:progress", StartupProgressEvent{
Stage: stage,
TotalStages: startupTotalStages,
LabelKey: fmt.Sprintf("startup.stage.%d.label", stage),
Status: st.status,
StartedAt: st.startedAt.UnixMilli(),
})
}()
}
func (p *StartupPipeline) emitError(stage int, err error, cause string) {
if p.app.ctx == nil {
return
}
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:error", StartupErrorEvent{
Stage: stage,
Error: err.Error(),
Cause: cause,
})
}()
// 同步通知 ServerController 進 Error state
if p.app.ctrl != nil {
p.app.ctrl.setState(ServerStateError, err.Error())
}
// R5-D1發 OS 通知
go sendCrashNotification(
"visionA Local — 啟動失敗",
fmt.Sprintf("第 %d 階段失敗:%s", stage, err.Error()),
)
}
// watcher 每秒檢查 soft timeout、hard timeout、階段 6 sentinel file。
func (p *StartupPipeline) watcher(ctx context.Context) {
defer close(p.watcherDone)
ticker := time.NewTicker(startupWatcherTickMs * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.mu.Lock()
if p.current <= 0 || p.current > startupTotalStages {
p.mu.Unlock()
return
}
cur := p.current
st := p.stages[cur]
curStatus := st.status
sinceStage := time.Since(st.startedAt)
sinceTotal := time.Since(p.startedAt)
softEmitted := st.softTimeoutEmitted
p.mu.Unlock()
// v2.1:階段 6 sentinel file 檢查(取代既有的 OnClientConnected IPC
if cur == 6 {
sentinelPath := filepath.Join(p.app.dataDir, ".first-ws-connected")
if _, err := os.Stat(sentinelPath); err == nil {
p.Complete(6)
return
}
}
// v2.1skip hard / soft timeout 的情境(必須在 hard timeout 檢查前先判斷)
// 1) 該階段已標記為 "skipped"(例如階段 5 在 AutoOpenBrowser=false 時)
// 2) 階段 6 且 AutoOpenBrowser=false → 使用者必須手動點「Open in Browser」才會觸發 WebSocket
// 連線,等待時間可能很長(使用者去倒杯咖啡),不計入 60 s 上限,也不觸發 soft timeout
skipTimeout := false
if curStatus == "skipped" {
skipTimeout = true
}
if cur == 6 && p.app.prefs != nil && !p.app.prefs.AutoOpenBrowser {
skipTimeout = true
}
// Hard timeout總時 > 60 s— 跳過時不檢查
if !skipTimeout && sinceTotal > startupHardTimeout {
p.Fail(cur, fmt.Errorf("startup total timeout: %s > %s", sinceTotal, startupHardTimeout))
p.emitError(cur, fmt.Errorf("total timeout"), "total-timeout")
return
}
if skipTimeout {
continue // 不檢查 soft timeout
}
// Soft timeout單一階段 > 20 s
if sinceStage > startupSoftTimeout && !softEmitted {
p.mu.Lock()
p.stages[cur].softTimeoutEmitted = true
p.mu.Unlock()
if p.app.ctx != nil {
go func(stage int) {
wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-timeout", StartupStageTimeoutEvent{
Stage: stage,
SoftTimeoutSeconds: int(startupSoftTimeout.Seconds()),
})
}(cur)
}
}
}
}
}
func (p *StartupPipeline) stopWatcher() {
if p.watcherCancel != nil {
p.watcherCancel()
}
}
```
**關鍵設計**
1. **1-indexed stages**:陣列多配一格避免 off-by-one`stages[1]` ~ `stages[6]` 使用
2. **`current` sentinel 值**`0`=未啟動、`1-6`=進行中、`7`=ready、`-1`=已失敗watcher 看到非 1-6 立即 return避免 leak
3. **非阻塞 emit**:每個 `EventsEmit` 都走 `go func()`,確保 Wails IPC 慢不拖累啟動流程
4. **`softTimeoutEmitted` flag**:確保 `startup:stage-timeout` 每個階段最多 emit 一次
5. **watcher 生命週期**`ctx.Done()` 或 `stopWatcher()` 觸發時退出;`markReady()` 與 `Fail()` 都會呼叫 `stopWatcher()`
---
## 5. 與 `startup(ctx)` 的整合
`visiona-local/app.go` 的 `startup()` 函式修改(示意):
```go
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// 初始化階段化啟動管線
a.pipeline = NewStartupPipeline(a)
a.pipeline.Start(ctx) // emit startup:progress(stage=1, running)
// 階段 1既有的初始化
if err := a.migrateOldDataDirs(); err != nil { ... }
if err := a.acquireSingleInstance(); err != nil { ... }
if err := a.startIPCServer(); err != nil { ... }
if err := a.seedUserDataDir(); err != nil { ... }
a.pipeline.Complete(1) // emit startup:progress(stage=1, completed) + startup:progress(stage=2, running)
// 載入 preferences必須在 ctrl.Start 之前ctrl.Start 會讀 prefs.AutoOpenBrowser
a.prefs = LoadPreferences(a.dataDir)
// 階段 2-6由 ctrl.Start → startServerV2 內部呼叫 pipeline.Complete(2..6)
if err := a.ctrl.Start(); err != nil {
// 失敗情境已由 pipeline.Fail / emitError 處理,這裡不需額外動作
return
}
// 成功pipeline.Complete(6) → markReady() → emit startup:ready
}
```
`server_control.go:startServerV2` 內部穿插 `pipeline.Complete(N)` / `pipeline.Start(N+1)` 的呼叫,在對應階段的 boundary 執行。
---
## 6. 前端Wails 控制台 vanilla JS
檔案:`visiona-local/frontend/components/startup-panel.js`~100 行)
```javascript
// startup-panel.js
import { t } from '../i18n/loader.js';
const el = () => document.getElementById('startup-panel');
const stagesEl = () => document.getElementById('startup-stages');
const elapsedEl = () => document.getElementById('startup-elapsed');
let startedAt = 0;
let elapsedTimer = null;
export function initStartupPanel() {
// 初始 render 6 個階段為 pending
const container = stagesEl();
container.innerHTML = '';
for (let i = 1; i <= 6; i++) {
const row = document.createElement('div');
row.id = `startup-stage-${i}`;
row.className = 'startup__stage startup__stage--pending';
row.innerHTML = `
<span class="startup__icon"></span>
<span class="startup__label">${i}. ${t(`startup.stage.${i}.label`)}</span>
<span class="startup__time"></span>
<p class="startup__retry-hint" hidden></p>
`;
container.appendChild(row);
}
startedAt = Date.now();
elapsedTimer = setInterval(updateElapsed, 500);
}
function updateElapsed() {
const seconds = Math.floor((Date.now() - startedAt) / 1000);
elapsedEl().textContent = t('startup.elapsed', { seconds });
}
export function updateStartupPanel(e) {
// e = { stage, totalStages, labelKey, status, startedAt, retrying? }
const row = document.getElementById(`startup-stage-${e.stage}`);
if (!row) return;
row.className = `startup__stage startup__stage--${e.status}`;
const icon = row.querySelector('.startup__icon');
icon.textContent = {
pending: '⏳',
running: '🔄',
completed: '✅',
failed: '❌',
retrying: '🔄',
}[e.status] || '⏳';
if (e.status === 'completed' || e.status === 'failed') {
const elapsed = ((Date.now() - e.startedAt) / 1000).toFixed(1);
row.querySelector('.startup__time').textContent = `(${elapsed} s)`;
}
if (e.status === 'retrying' || e.softTimeoutSeconds) {
const hint = row.querySelector('.startup__retry-hint');
hint.textContent = t('startup.retrying', { stage: e.stage });
hint.hidden = false;
}
}
export function showStartupError(e) {
// 最終切 Error state 會由 server:state-change 處理;這裡只做視覺標記
const row = document.getElementById(`startup-stage-${e.stage}`);
if (row) {
row.className = 'startup__stage startup__stage--failed';
row.querySelector('.startup__icon').textContent = '❌';
}
// 停止 elapsed timer
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; }
}
export function hideStartupPanel() {
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; }
const panel = el();
if (!panel) return;
// 300 ms 淡出
panel.classList.add('startup__panel--hiding');
setTimeout(() => {
panel.hidden = true;
panel.classList.remove('startup__panel--hiding');
}, 300);
}
```
`app.js` 在 `main()` 最前面呼叫 `initStartupPanel()`,然後訂閱 4 個 event見 `control-panel.md` §5 的更新)。
---
## 7. 驗收條件
| # | 情境 | 預期 |
|---|------|------|
| 1 | 樂觀冷啟動(所有階段 ~1 s| 4-5 s 內看到進度面板 6 階段逐一 completed → 淡出顯示主控台 |
| 2 | 日常啟動(非首次,~3 s| 進度面板 min-display-time 1 s 後淡出 |
| 3 | 悲觀冷啟動Python wheels extract 慢)| 8-10 s 內完成,階段 2 可能顯示較久但不觸發 retry hint |
| 4 | 階段 2 卡 25 smock 測試)| 看到「第 2 階段正在重試 …」副文字、但流程繼續 |
| 5 | 階段 3 失敗server binary 不存在)| 階段 3 變 ❌、收到 OS 通知、切 Error state |
| 6 | 總時 > 60 smock 每階段等 12 s| 60 s 時切 Error state + emit `startup:error` |
| 7 | `AutoOpenBrowser=false`Linux 預設)| 階段 5 立即 complete跳過 OpenInBrowser 實際呼叫)|
| 8 | WebSocket 30 s 內無 client 連上(罕見)| 階段 6 失敗 → Error state |
| 9 | 進度面板淡出不卡住主控台 | 300 ms ease 後主控台 UI 顯示,無視覺 artifact |
---
## 8. Retry 機制RestartStartupSequencev2.1 新增)
**觸發情境**Wails 控制台進入 Startup Error state階段失敗或 60 s hard timeout使用者在 Error state 面板點「Retry」按鈕Design Spec v2.1 §3.7 定義)。這個行為**不是** `RestartServer()`(後者只重啟 server 子程序,保留 port而是**整個啟動流程重跑**。
### 8.1 Binding`RestartStartupSequence`
```go
// visiona-local/app.go
// RestartStartupSequence resets the entire startup pipeline and tries again.
// Triggered by user clicking "Retry" button in Wails console Error state.
//
// 與 RestartServer 的差別:
// RestartServer : Stop → Start保留 port、沿用既有 StartupPipeline instance
// RestartStartupSequence : ForceKill → 清狀態 → 重建 StartupPipeline → 從階段 2 跑(階段 1 直接 emit completed
func (a *App) RestartStartupSequence() error {
// Step 1: 停止當前的 watcher goroutine避免舊 watcher 把剛重跑的階段誤判為 soft timeout
if a.pipelineCancelFn != nil {
a.pipelineCancelFn()
a.pipelineCancelFn = nil
}
// Step 2: 強制殺掉 server 子程序(不等 graceful period我們是在 recover failure
// 直接 ForceKill 比 Stop() 快Stop() 會走 7 s grace period 對這個情境沒必要
if a.ctrl != nil {
a.ctrl.ForceKill() // 新增 method內部 SIGKILL + 清 c.proc + setState(Stopped) 但不 emit crash notification
}
// Step 3: Reset state machine to Stopped避免 Error state 殘留)
if a.ctrl != nil {
a.ctrl.setState(ServerStateStopped, "")
}
// Step 4: 清 sentinel filecritical — 否則階段 6 會誤判為瞬間完成)
sentinelPath := filepath.Join(a.dataDir, ".first-ws-connected")
_ = os.Remove(sentinelPath)
// Step 5: 重建 StartupPipeline 並呼叫 StartServer
a.startupPipeline = NewStartupPipeline(a)
// 階段 1「初始化 Wails 控制台」已經是 running 狀態(我們是 Wails app 本身,不需要重做),
// 直接 emit completed 不重跑
a.startupPipeline.mu.Lock()
a.startupPipeline.startedAt = time.Now()
a.startupPipeline.current = 1
a.startupPipeline.stages[1].status = "completed"
a.startupPipeline.stages[1].startedAt = time.Now()
a.startupPipeline.stages[1].completedAt = time.Now()
a.startupPipeline.mu.Unlock()
a.startupPipeline.emitProgress(1) // emit stage=1 completed
// 啟動 watcher goroutine
watcherCtx, cancel := context.WithCancel(a.ctx)
a.pipelineCancelFn = cancel
a.startupPipeline.watcherDone = make(chan struct{})
go a.startupPipeline.watcher(watcherCtx)
// 切到階段 2 並真的跑
a.startupPipeline.mu.Lock()
a.startupPipeline.current = 2
a.startupPipeline.stages[2].status = "running"
a.startupPipeline.stages[2].startedAt = time.Now()
a.startupPipeline.mu.Unlock()
a.startupPipeline.emitProgress(2)
// Step 6: 呼叫 StartServer內部會依序 Complete(2..6)
// Retry 情境允許 port fallback視同 cold start見 server-lifecycle.md §3.3
return a.ctrl.Start()
}
```
### 8.2 前端整合
Wails 控制台的 Error state 面板顯示:
- 錯誤訊息(來自 `startup:error` event 的 `error` 欄位)
- 失敗階段(`stage` 欄位)
- 「Retry」按鈕 → `RestartStartupSequence().catch(showError)`
- 「Export log」按鈕 → `ExportLog()`(方便使用者回報問題時附 log
- 「Quit」按鈕 → `runtime.Quit(ctx)`
點 Retry 後:
1. 控制台 UI 切回 Starting state隱藏 Error 面板、顯示 Startup Progress 面板)
2. 重跑 `initStartupPanel()` 將 6 個階段重新 render 為 pending階段 1 立即變 completed
3. 訂閱者會陸續收到 `startup:progress` 事件更新 DOM
### 8.3 驗收
| # | 情境 | 預期 |
|---|------|------|
| R1 | 階段 2 失敗 → 點 Retry → server 恢復可啟動 | Retry 後階段 1 顯示 completed、階段 2-6 依序完成 → `startup:ready` |
| R2 | 階段 2 失敗 → 點 Retry → 仍然失敗Python binary 還是壞的)| 再次進 Error state不會無限重試 |
| R3 | 60 s hard timeout → 點 Retry | 整個流程計時歸零,新一輪 60 s 上限 |
| R4 | Retry 時 port 3721 被其他程式佔用 | 允許 fallback 到 3722/3723… |
| R5 | 連點 Retry 兩次 | 第二次在 `pipelineCancelFn != nil` 檢查時安全地 cancel 舊 watcher不會殘留 goroutine |
| R6 | 階段 6 還在 pending 時點 Retry | ForceKill 掉 server → sentinel file 被清 → 從階段 2 重跑 |
---
## 9. 待確認
1. **階段 6 的「WebSocket 首次連線」實作方式(已定版)** — v2.1 二次審閱定案:採用 sentinel file`<dataDir>/.first-ws-connected`)。詳見 §3 階段 6 章節。無需開發時再討論。
2. **進度面板 min-display-time** — 若啟動 < 1 s進度面板閃一下就消失不好看。建議設 1 s min-display-time即使 `startup:ready` 已 emit面板也要留 1 s 才淡出)。實作細節可放在 `hideStartupPanel()` 判斷 `Date.now() - startedAt < 1000` 時延遲執行
3. **R5-E5 文案** — Design Spec v2.1 尚未敲定i18n key 已預留。Design Agent 完成後可直接填入 `i18n/*.json`
4. **watcher goroutine 與 `ctrl.Stop()` 的交互** — 若使用者在啟動中間按了 Stop不太可能Starting 狀態下 action bar 禁用pipeline 要能 cancel。目前靠 `stopWatcher()` + `ctrl.setState(Error)` 處理,實測後若有 race 再補強
5. **`ForceKill` method 需新增到 ServerController** — RestartStartupSequence 依賴這個 method`server_control.go` 要加一個非同步版 Stop直接 SIGKILL 不走 7 s grace不發 crash notificationM8-4b 執行時補上