依 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>
29 KiB
v2/startup-pipeline.md — R5-E 階段化啟動管線
所屬:TDD v2 §2.9(v2.1 新增) 版本:v2.1(2026-04-14 R5-E 實作細節) 決策依據:R5-E1 ~ R5-E6(AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度) 對應 milestone:M8-4b(Backend 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(階段失敗)。
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 stateskipped(v2.1 新增):該階段依偏好設定或平台規則被跳過,不需執行也不檢查 timeout- 目前使用情境:階段 5「開啟瀏覽器」在
prefs.AutoOpenBrowser == false時(Linux 預設 OFF,或使用者手動關閉)→ status=skipped - 前端收到 skipped → 顯示 ⏭ 圖示 + 文字「跳過(依偏好設定)」(Design Spec v2.1 §4.1 已定)
- Watcher 看到 skipped 狀態時不檢查 soft timeout;進入下一階段的邏輯同 completed
- 目前使用情境:階段 5「開啟瀏覽器」在
前端 render 邏輯:收到 event → 更新 #startup-stage-<N> DOM 的 status icon 與時間。
1.2 startup:stage-timeout
某階段 > 20 秒(soft timeout)未完成時 emit 一次(不重複 emit)。僅作提示,不中斷流程。
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。
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 後 emit,payload 為空。
// 無 struct,EventsEmit(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 直接串」。
流程:
-
Server 端:WebSocket hub 的
OnClientConnectedcallback 第一次觸發時:// 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確保僅第一次連線觸發;後續連線不重複寫檔。 -
Wails 端:
StartupPipeline.watchergoroutine 每秒 tick 時額外檢查 sentinel 檔案:// 當 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 } } -
下次 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
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.1:skip 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-indexed stages:陣列多配一格避免 off-by-one,
stages[1]~stages[6]使用 currentsentinel 值:0=未啟動、1-6=進行中、7=ready、-1=已失敗;watcher 看到非 1-6 立即 return,避免 leak- 非阻塞 emit:每個
EventsEmit都走go func(),確保 Wails IPC 慢不拖累啟動流程 softTimeoutEmittedflag:確保startup:stage-timeout每個階段最多 emit 一次- watcher 生命週期:
ctx.Done()或stopWatcher()觸發時退出;markReady()與Fail()都會呼叫stopWatcher()
5. 與 startup(ctx) 的整合
visiona-local/app.go 的 startup() 函式修改(示意):
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 行)
// 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 s(mock 測試) | 看到「第 2 階段正在重試 …」副文字、但流程繼續 |
| 5 | 階段 3 失敗(server binary 不存在) | 階段 3 變 ❌、收到 OS 通知、切 Error state |
| 6 | 總時 > 60 s(mock 每階段等 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 機制(RestartStartupSequence,v2.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
// 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 file(critical — 否則階段 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:errorevent 的error欄位) - 失敗階段(
stage欄位) - 「Retry」按鈕 →
RestartStartupSequence().catch(showError) - 「Export log」按鈕 →
ExportLog()(方便使用者回報問題時附 log) - 「Quit」按鈕 →
runtime.Quit(ctx)
點 Retry 後:
- 控制台 UI 切回 Starting state(隱藏 Error 面板、顯示 Startup Progress 面板)
- 重跑
initStartupPanel()將 6 個階段重新 render 為 pending(階段 1 立即變 completed) - 訂閱者會陸續收到
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. 待確認
- 階段 6 的「WebSocket 首次連線」實作方式(已定版) — v2.1 二次審閱定案:採用 sentinel file(
<dataDir>/.first-ws-connected)。詳見 §3 階段 6 章節。無需開發時再討論。 - 進度面板 min-display-time — 若啟動 < 1 s,進度面板閃一下就消失不好看。建議設 1 s min-display-time(即使
startup:ready已 emit,面板也要留 1 s 才淡出)。實作細節可放在hideStartupPanel()判斷Date.now() - startedAt < 1000時延遲執行 - R5-E5 文案 — Design Spec v2.1 尚未敲定,i18n key 已預留。Design Agent 完成後可直接填入
i18n/*.json - watcher goroutine 與
ctrl.Stop()的交互 — 若使用者在啟動中間按了 Stop(不太可能,Starting 狀態下 action bar 禁用),pipeline 要能 cancel。目前靠stopWatcher()+ctrl.setState(Error)處理,實測後若有 race 再補強 ForceKillmethod 需新增到 ServerController — RestartStartupSequence 依賴這個 method,server_control.go要加一個非同步版 Stop:直接 SIGKILL 不走 7 s grace、不發 crash notification。M8-4b 執行時補上