依 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>
434 lines
14 KiB
Go
434 lines
14 KiB
Go
package main
|
||
|
||
// startup_pipeline.go — M8-4b:6 階段啟動進度 + soft/hard timeout + watcher
|
||
//
|
||
// TDD ground truth:
|
||
// - .autoflow/04-architecture/v2/startup-pipeline.md(518 行完整規格)
|
||
// - R5-E1 ~ R5-E6(AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度)
|
||
//
|
||
// 設計重點:
|
||
// 1. 6 個階段(1 = Wails console / 2 = Python runtime / 3 = server / 4 = devices /
|
||
// 5 = open browser / 6 = wait Web UI WebSocket)
|
||
// 2. soft timeout = 20 秒(每階段),emit "startup:stage-timeout" 但不中斷
|
||
// 3. hard timeout = 60 秒(總時),emit "startup:error" + 進 Error state
|
||
// 4. 階段 5/6 在 AutoOpenBrowser=false 時:
|
||
// - 階段 5 → status="skipped"
|
||
// - 階段 6 → 不檢查 timeout(使用者必須手動點 Open in Browser 才會建立 WebSocket)
|
||
// 5. 階段 6 透過 sentinel file `<dataDir>/.first-ws-connected` 偵測;
|
||
// server 端 Hub 在第一個 WS client 連上時寫檔(見 server/internal/api/ws/hub.go)
|
||
// 6. RestartStartupSequence binding:Retry 按鈕用,5 步驟重置整個流程
|
||
//
|
||
// 1-indexed stages:陣列多配一格避免 off-by-one,stages[1] ~ stages[6] 使用。
|
||
// current sentinel 值:0 = 未啟動、1-6 = 進行中、7 = ready、-1 = 已失敗。
|
||
|
||
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
|
||
startupWatcherTick = 1 * time.Second
|
||
)
|
||
|
||
// startupSentinelFileName 是 server 端寫入、Wails 端 poll 的檔名。
|
||
// 路徑:<dataDir>/.first-ws-connected
|
||
const startupSentinelFileName = ".first-ws-connected"
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Event payload
|
||
// -----------------------------------------------------------------------
|
||
|
||
// StartupProgressEvent 對應 startup-pipeline.md §1.1。
|
||
// 每階段 status 變化(running / completed / failed / skipped)都會 emit 一次。
|
||
type StartupProgressEvent struct {
|
||
Stage int `json:"stage"` // 1-6
|
||
TotalStages int `json:"totalStages"` // 固定 6
|
||
LabelKey string `json:"labelKey"` // 如 "startup.stage.2.label"
|
||
Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped"
|
||
StartedAt int64 `json:"startedAt"` // Unix ms,該階段 startedAt
|
||
}
|
||
|
||
// StartupStageTimeoutEvent — soft timeout 提示,不中斷流程,只 emit 一次/階段。
|
||
type StartupStageTimeoutEvent struct {
|
||
Stage int `json:"stage"`
|
||
SoftTimeoutSeconds int `json:"softTimeoutSeconds"` // 固定 20
|
||
}
|
||
|
||
// StartupErrorEvent — 階段失敗或總時 hard timeout。emit 後 pipeline 停止。
|
||
type StartupErrorEvent struct {
|
||
Stage int `json:"stage"`
|
||
Error string `json:"error"`
|
||
Cause string `json:"cause"` // "stage-failure" | "total-timeout"
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// stageState — 單一階段的內部狀態
|
||
// -----------------------------------------------------------------------
|
||
|
||
type stageState struct {
|
||
status string // "pending" | "running" | "completed" | "failed" | "skipped"
|
||
startedAt time.Time
|
||
completedAt time.Time
|
||
softTimeoutEmitted bool
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// StartupPipeline — 主 struct
|
||
// -----------------------------------------------------------------------
|
||
|
||
// StartupPipeline 管理 6 階段啟動流程。
|
||
// 與 ServerController 解耦:StartupPipeline 只 emit Wails event,
|
||
// 它對 server lifecycle 的影響由 ServerController(透過 emitError → setState)負責。
|
||
type StartupPipeline struct {
|
||
app *App
|
||
|
||
mu sync.Mutex
|
||
stages [startupTotalStages + 1]stageState // 1-indexed
|
||
current int // 0=未啟動、1-6=進行中、7=ready、-1=failed
|
||
startedAt time.Time
|
||
|
||
// watcher goroutine 控制。pipelineCancelFn 由 app.go 持有;本 struct 只記 done channel。
|
||
watcherCancel context.CancelFunc
|
||
watcherDone chan struct{}
|
||
}
|
||
|
||
// NewStartupPipeline 建立新的 pipeline。並未啟動 watcher,必須呼叫 Start。
|
||
func NewStartupPipeline(app *App) *StartupPipeline {
|
||
return &StartupPipeline{
|
||
app: app,
|
||
current: 0,
|
||
}
|
||
}
|
||
|
||
// Start 啟動整個 pipeline,從階段 1 開始 emit running,並開啟 watcher goroutine。
|
||
// 只能呼叫一次(重複呼叫會建立多個 watcher 是合法的,但會浪費資源 — 上層應避免)。
|
||
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)
|
||
}
|
||
|
||
// CompleteStage 標記 stage 為 completed,並進入下一階段(若還有)。
|
||
// 若 stage == startupTotalStages → 觸發 markReady。
|
||
//
|
||
// 順序錯誤(重複呼叫或階段不對)→ 安靜 ignore,避免被 race condition 害到。
|
||
func (p *StartupPipeline) CompleteStage(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 標記 stage 為 skipped,並進入下一階段。
|
||
// 用於:階段 5 在 prefs.AutoOpenBrowser=false 時跳過 OpenInBrowser 呼叫。
|
||
// Watcher 看到 status=skipped → 不檢查 soft timeout(也不檢查 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)
|
||
|
||
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)
|
||
}
|
||
|
||
// FailStage 標記 stage 為 failed,pipeline 停止並進 Error state。
|
||
//
|
||
// 副作用(透過 emitError):
|
||
// - emit "startup:error" Wails event
|
||
// - 若 ctrl 存在 → setState(Error)(前端會收到 server:state-change)
|
||
// - 發 OS 通知(R5-D1)
|
||
func (p *StartupPipeline) FailStage(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 6 階段都完成後觸發。emit "startup:ready" 並停 watcher。
|
||
func (p *StartupPipeline) markReady() {
|
||
p.mu.Lock()
|
||
p.current = startupTotalStages + 1
|
||
p.mu.Unlock()
|
||
|
||
if p.app != nil && p.app.ctx != nil {
|
||
// 同步 emit(成功路徑沒有 IPC backlog 風險)
|
||
wailsRuntime.EventsEmit(p.app.ctx, "startup:ready", nil)
|
||
}
|
||
p.stopWatcher()
|
||
}
|
||
|
||
// emitProgress 取當前 stage 狀態 snapshot 並 emit "startup:progress" event。
|
||
// 用 goroutine 包起來避免 Wails IPC 慢拖累呼叫者(CompleteStage / SkipStage 等)。
|
||
func (p *StartupPipeline) emitProgress(stage int) {
|
||
p.mu.Lock()
|
||
if stage <= 0 || stage > startupTotalStages {
|
||
p.mu.Unlock()
|
||
return
|
||
}
|
||
st := p.stages[stage]
|
||
p.mu.Unlock()
|
||
|
||
if p.app == nil || p.app.ctx == nil {
|
||
return
|
||
}
|
||
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(),
|
||
})
|
||
}()
|
||
}
|
||
|
||
// emitError emit "startup:error" 並通知 ctrl 進 Error state + 發 OS 通知。
|
||
// cause 取值:"stage-failure" | "total-timeout"
|
||
func (p *StartupPipeline) emitError(stage int, err error, cause string) {
|
||
if p.app == nil {
|
||
return
|
||
}
|
||
if p.app.ctx != nil {
|
||
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 通知(fire-and-forget)
|
||
go sendCrashNotification(
|
||
"visionA Local — 啟動失敗",
|
||
fmt.Sprintf("第 %d 階段失敗:%s", stage, err.Error()),
|
||
)
|
||
}
|
||
|
||
// watcher 每秒 tick:
|
||
// 1. 階段 6 時檢查 sentinel file → 存在則 CompleteStage(6)
|
||
// 2. 檢查 hard timeout(總時 > 60 s)→ FailStage + emitError(total-timeout)
|
||
// 3. 檢查 soft timeout(單一階段 > 20 s)→ emit "startup:stage-timeout"
|
||
//
|
||
// skip timeout 規則:
|
||
// - 該階段已 skipped → 完全不檢查 soft / hard
|
||
// - 階段 6 + AutoOpenBrowser=false → 完全不檢查 soft / hard
|
||
//
|
||
// 退出條件:ctx.Done()、current 已不在 1-6 範圍(ready 或 failed)。
|
||
func (p *StartupPipeline) watcher(ctx context.Context) {
|
||
defer close(p.watcherDone)
|
||
ticker := time.NewTicker(startupWatcherTick)
|
||
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()
|
||
|
||
// 階段 6:每次 tick 檢查 sentinel file
|
||
if cur == 6 {
|
||
if p.checkSentinelFile() {
|
||
p.CompleteStage(6)
|
||
return
|
||
}
|
||
}
|
||
|
||
// skip timeout 判斷
|
||
skipTimeout := false
|
||
if curStatus == "skipped" {
|
||
skipTimeout = true
|
||
}
|
||
if cur == 6 && p.app != nil && !p.app.prefs.AutoOpenBrowser {
|
||
skipTimeout = true
|
||
}
|
||
|
||
// hard timeout(總時 > 60 s)
|
||
if !skipTimeout && sinceTotal > startupHardTimeout {
|
||
err := fmt.Errorf("startup total timeout: %s > %s", sinceTotal, startupHardTimeout)
|
||
// 直接 mark failed + emit 兩個 event,不走 FailStage 因為 cause 不一樣
|
||
p.mu.Lock()
|
||
p.stages[cur].status = "failed"
|
||
p.current = -1
|
||
p.mu.Unlock()
|
||
p.emitProgress(cur)
|
||
p.emitError(cur, err, "total-timeout")
|
||
p.stopWatcher()
|
||
return
|
||
}
|
||
|
||
if skipTimeout {
|
||
continue
|
||
}
|
||
|
||
// soft timeout(單一階段 > 20 s)
|
||
if sinceStage > startupSoftTimeout && !softEmitted {
|
||
p.mu.Lock()
|
||
p.stages[cur].softTimeoutEmitted = true
|
||
p.mu.Unlock()
|
||
|
||
if p.app != nil && p.app.ctx != nil {
|
||
stageRef := cur
|
||
go func() {
|
||
wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-timeout", StartupStageTimeoutEvent{
|
||
Stage: stageRef,
|
||
SoftTimeoutSeconds: int(startupSoftTimeout.Seconds()),
|
||
})
|
||
}()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// checkSentinelFile 檢查 <dataDir>/.first-ws-connected 是否存在。
|
||
// 存在 → 階段 6 完成(server 端的 WebSocket Hub 寫了這個檔,代表第一個 client 已連上)
|
||
func (p *StartupPipeline) checkSentinelFile() bool {
|
||
if p.app == nil || p.app.dataDir == "" {
|
||
return false
|
||
}
|
||
path := filepath.Join(p.app.dataDir, startupSentinelFileName)
|
||
_, err := os.Stat(path)
|
||
return err == nil
|
||
}
|
||
|
||
// stopWatcher 主動取消 watcher goroutine。重複呼叫安全(cancel 之後再 cancel 是 no-op)。
|
||
func (p *StartupPipeline) stopWatcher() {
|
||
if p.watcherCancel != nil {
|
||
p.watcherCancel()
|
||
}
|
||
}
|
||
|
||
// HasFailedStage 回傳 pipeline 是否已有任何階段被標記為 failed。
|
||
//
|
||
// M8-4b 補丁(M-1 修復):startInternal 用這個判斷「pipeline 是否已經 FailStage 過」,
|
||
// 如果是則 skip 自己的 sendCrashNotification / setState(Error),避免使用者看到兩個
|
||
// 獨立的錯誤通知(一個由 pipeline.emitError 發、一個由 startInternal fallback 發)。
|
||
//
|
||
// 語義:current == -1(整個 pipeline 已停在 failed)或任一 stage status == "failed"。
|
||
func (p *StartupPipeline) HasFailedStage() bool {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if p.current == -1 {
|
||
return true
|
||
}
|
||
for i := 1; i <= startupTotalStages; i++ {
|
||
if p.stages[i].status == "failed" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// IsInColdStart 回傳 pipeline 是否正在「冷啟動中」(stage 1-6 running)。
|
||
//
|
||
// M8-4b 補丁(M-2 修復):startInternal 的 R5-D3 openBrowser 邏輯用這個判斷
|
||
// 「目前是否走冷啟動 pipeline 路徑」。若是,由 runStartupStage5 / pipeline stage 5 hook
|
||
// 負責 openBrowser,startInternal 跳過避免瀏覽器開兩次(Linux 會開兩個 tab)。
|
||
//
|
||
// 非冷啟動場景:
|
||
// - pipeline == nil(單測或 Wails 未初始化)
|
||
// - current == 0(尚未 Start)
|
||
// - current == 7(已 ready,屬於 RestartServer 直接 Start 的情境)
|
||
// - current == -1(已 failed)
|
||
//
|
||
// 這些場景下 startInternal 仍需要自己呼叫 openBrowser。
|
||
func (p *StartupPipeline) IsInColdStart() bool {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
return p.current >= 1 && p.current <= startupTotalStages
|
||
}
|
||
|
||
// removeSentinelFile 移除 sentinel file。失敗時不回 error(檔案不存在是正常情況)。
|
||
// 由 RestartStartupSequence、StartServer 前置、shutdown 呼叫。
|
||
func removeSentinelFile(dataDir string) {
|
||
if dataDir == "" {
|
||
return
|
||
}
|
||
path := filepath.Join(dataDir, startupSentinelFileName)
|
||
_ = os.Remove(path)
|
||
}
|