續 a209470 修 Windows 乾淨環境啟動問題。使用者回報:
- 紅 banner「伺服器無法啟動 / 啟動時間超過 60 秒」— 即 pipeline total-timeout
- 但上方狀態列顯示「執行中 :3721 PID 8568 uptime 00:00:44」— server 實際活著
- Settings popup 上疊 shutdown-modal「正在停止伺服器…」永遠卡住
三個獨立問題:
1. Stage 3 waitHealthy 在 Windows 首次啟動時,Defender real-time scan
會延遲 30-60 秒才讓 visiona-local-server.exe 真正 bind port。原本
30 秒 timeout 可能 stage-failure,且這段等候時間計入 pipeline 60 秒
total budget。修法:
(a) healthCheckTimeout 30 秒 → 60 秒
(b) startServerV2 的 waitHealthy call 在冷啟動時(IsInColdStart)
包進 Pause/Resume hard timeout — 和 Stage 2 Python bootstrap 同理,
首次 bootstrap 的 Windows Defender 掃描不該算進日常啟動預算。
Restart(pipeline 已 ready)維持嚴格計時,不 pause。
2. stopGraceful 只 emit "shutdown:modal-show" 沒有對稱的 hide event,
前端 popup 顯示後無法關閉(只能等應用重開)。修法:
(a) stopGraceful 用 defer emit "shutdown:modal-hide"(若曾 show)
(b) 前端 app.js 加對應 EventsOn listener 把 hidden attribute 設回
3. 配套:cwd bash working dir 會在 session 內持久(system prompt 明說
"working directory persists between commands"),但 env vars 不持久
— 非本次 commit 相關,僅自己的 mental note。
驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 重 build 163MB OK
給 Windows 驗證用的 log 位置:
%APPDATA%\visiona-local\logs\server.stdout.log — server 端 log
%APPDATA%\visiona-local\logs\server.stderr.log — server 端 panic / 崩潰
%APPDATA%\visiona-local\logs\wails.log — Wails app (appLog) 訊息
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1151 lines
35 KiB
Go
1151 lines
35 KiB
Go
package main
|
||
|
||
// server_control.go — M8-4:Server 生命週期狀態機 + bindings + logPump
|
||
//
|
||
// 本檔涵蓋 TDD:
|
||
// - v2/control-panel.md §4.2–§4.7:bindings、型別、state machine、logPump
|
||
// - v2/server-lifecycle.md §4–§6:stdout/stderr pipe 捕捉、ServerController 防呆、
|
||
// watchServer 改為 Error state
|
||
//
|
||
// 設計重點:
|
||
// 1. ServerController 用 txMu(transition lock)+ mu(field lock)雙鎖
|
||
// 2. Start / Stop / Restart 整段邏輯由 txMu 序列化,避免 race
|
||
// 3. 新的 startServerV2 用 StdoutPipe / StderrPipe 接 server 子行程,
|
||
// 配合 logPump goroutine 把每一行送進 LogBuffer + emit Wails event
|
||
// 4. Restart 強制保留舊 port(R5-F-2);cold start 允許 fallback
|
||
// 5. watchServer 崩潰時進 Error state,不 os.Exit;同時發 OS 通知(R5-D1)
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strconv"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||
)
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 型別定義
|
||
// -----------------------------------------------------------------------
|
||
|
||
// ServerState 是控制台顯示的高階狀態(TDD v2/control-panel.md §4.3)。
|
||
type ServerState string
|
||
|
||
const (
|
||
ServerStateIdle ServerState = "idle" // 初始,尚未啟動過
|
||
ServerStateStopped ServerState = "stopped" // 曾經 Running 過後又被停掉
|
||
ServerStateStarting ServerState = "starting" // 啟動中
|
||
ServerStateRunning ServerState = "running" // 正常運作
|
||
ServerStateStopping ServerState = "stopping" // 停止中(SIGTERM → grace → SIGKILL)
|
||
ServerStateError ServerState = "error" // 啟動失敗 / 健康檢查失敗
|
||
)
|
||
|
||
// ServerStatusV2 對應 control-panel.md §4.3。前端用這個結構更新 UI。
|
||
type ServerStatusV2 struct {
|
||
State ServerState `json:"state"`
|
||
Port int `json:"port,omitempty"`
|
||
URL string `json:"url,omitempty"`
|
||
PID int `json:"pid,omitempty"`
|
||
PythonBin string `json:"pythonBin,omitempty"`
|
||
PythonMode string `json:"pythonMode,omitempty"`
|
||
StartedAt int64 `json:"startedAt,omitempty"` // Unix ms
|
||
LastError string `json:"lastError,omitempty"`
|
||
}
|
||
|
||
// SystemInfo 給 Status card 顯示。
|
||
type SystemInfo struct {
|
||
AppVersion string `json:"appVersion"`
|
||
BuildTime string `json:"buildTime"`
|
||
DataDir string `json:"dataDir"`
|
||
LogsDir string `json:"logsDir"`
|
||
Platform string `json:"platform"`
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// ServerController — 狀態機 + Start/Stop/Restart
|
||
// -----------------------------------------------------------------------
|
||
|
||
// ServerController 持有 server 子行程的生命週期狀態。
|
||
// 它擁有 txMu(transition lock)+ mu(field lock)雙鎖:
|
||
// - txMu:Start / Stop / Restart 整段邏輯期間持有,保證互斥
|
||
// - mu:保護 state / proc / startedAt / lastError 的快速讀寫
|
||
type ServerController struct {
|
||
app *App
|
||
|
||
mu sync.Mutex
|
||
state ServerState
|
||
proc *ServerProcess
|
||
startedAt time.Time
|
||
lastError string
|
||
|
||
txMu sync.Mutex
|
||
}
|
||
|
||
// NewServerController 建立一個新的 controller。
|
||
func NewServerController(app *App) *ServerController {
|
||
return &ServerController{
|
||
app: app,
|
||
state: ServerStateIdle,
|
||
}
|
||
}
|
||
|
||
// State 回傳目前狀態(thread-safe snapshot)。
|
||
func (c *ServerController) State() ServerState {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.state
|
||
}
|
||
|
||
// setState 更新狀態並 emit Wails event。
|
||
// 注意:呼叫方不得持有 c.mu,本函式內部會取 c.mu。
|
||
func (c *ServerController) setState(s ServerState, errMsg string) {
|
||
c.mu.Lock()
|
||
c.state = s
|
||
c.lastError = errMsg
|
||
if s == ServerStateRunning {
|
||
c.startedAt = time.Now()
|
||
}
|
||
c.mu.Unlock()
|
||
|
||
// 取得最新 snapshot 後 emit
|
||
status := c.app.snapshotStatus()
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", status)
|
||
}
|
||
}
|
||
|
||
// Start 啟動 server 子程序。冷啟動或手動從 Stopped / Error / Idle 啟動用。
|
||
// 允許 port fallback。
|
||
func (c *ServerController) Start() error {
|
||
return c.startInternal(0)
|
||
}
|
||
|
||
// StartWithPort 用指定的 preferred port 啟動。
|
||
// preferredPort == 0 表示走 cold start 邏輯(允許 fallback)。
|
||
// preferredPort != 0 表示必須使用該 port(Restart 路徑),port 被佔就失敗進 Error。
|
||
func (c *ServerController) StartWithPort(preferredPort int) error {
|
||
return c.startInternal(preferredPort)
|
||
}
|
||
|
||
func (c *ServerController) startInternal(preferredPort int) error {
|
||
c.txMu.Lock()
|
||
defer c.txMu.Unlock()
|
||
|
||
// 1. 先檢查狀態
|
||
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)
|
||
}
|
||
|
||
// 2. 清理殘留 process(Error state 進來時常見)
|
||
c.mu.Lock()
|
||
if c.proc != nil {
|
||
oldProc := c.proc
|
||
c.proc = nil
|
||
c.mu.Unlock()
|
||
oldProc.stopGraceful() // 不發通知、快速清理
|
||
} else {
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
// 3. 切到 Starting
|
||
c.setState(ServerStateStarting, "")
|
||
|
||
// 4. 真的啟動
|
||
proc, err := c.app.startServerV2(preferredPort)
|
||
if err != nil {
|
||
// M8-4b 補丁 M-1:若 startup pipeline 已 FailStage(`pipeline.emitError` 已經
|
||
// setState(Error) + 發過 OS 通知),這條 fallback 路徑 skip 一次設定,避免:
|
||
// - server:state-change event 連發兩次(payload 相同、前端無傷但冗餘)
|
||
// - 使用者看到「啟動失敗」+「Server 啟動失敗」兩個不同的 OS 通知,誤以為
|
||
// 發生兩次獨立的錯誤
|
||
//
|
||
// 非冷啟動路徑(RestartServer 等 pipeline 已 ready,current==7)或 pipeline==nil
|
||
// 場景下,fallback 仍需要自己 setState + emit + 通知。
|
||
pipelineHandled := false
|
||
if c.app != nil && c.app.startupPipeline != nil {
|
||
pipelineHandled = c.app.startupPipeline.HasFailedStage()
|
||
}
|
||
|
||
if !pipelineHandled {
|
||
c.setState(ServerStateError, err.Error())
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:error", map[string]any{
|
||
"reason": err.Error(),
|
||
})
|
||
}
|
||
// R5-D1:OS 通知
|
||
go sendCrashNotification(
|
||
"visionA Local — Server 啟動失敗",
|
||
"請打開 visionA Local 查看錯誤詳情或按 Restart 重試。",
|
||
)
|
||
}
|
||
return err
|
||
}
|
||
|
||
c.mu.Lock()
|
||
c.proc = proc
|
||
c.mu.Unlock()
|
||
c.setState(ServerStateRunning, "")
|
||
|
||
// 5. R5-D3:每次 Start 成功都檢查 AutoOpenBrowser
|
||
//
|
||
// M8-4b 補丁 M-2:若目前處於冷啟動 pipeline 中(stage 1-6),openBrowser 改由
|
||
// runStartupStage5 統一負責,避免冷啟動時瀏覽器被開兩次:
|
||
// - 冷啟動:app.startup → ctrl.Start → startInternal → (原本這裡 open 1 次)
|
||
// → runStartupStage5 → openBrowser (再 open 1 次) → CompleteStage(5)
|
||
// Linux/xdg-open 會開兩個 tab;macOS/open 通常會聚合但 log 兩次。
|
||
// - RestartServer:pipeline 已 ready (current==7),IsInColdStart() 回 false,
|
||
// startInternal 仍負責自己 open(RestartServer 不走 runStartupStage5)。
|
||
// - RestartStartupSequence:pipeline 重建後走 Start → 這裡 skip,由上層的
|
||
// runStartupStage5 負責(見 app.go Step 5)。
|
||
inColdStart := false
|
||
if c.app != nil && c.app.startupPipeline != nil {
|
||
inColdStart = c.app.startupPipeline.IsInColdStart()
|
||
}
|
||
if !inColdStart && c.app.prefs.AutoOpenBrowser && proc != nil && proc.port > 0 {
|
||
url := fmt.Sprintf("http://127.0.0.1:%d", proc.port)
|
||
if err := openBrowser(url); err != nil {
|
||
fmt.Fprintf(os.Stderr, "[visiona-local] auto-open browser failed: %v\n", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Stop 優雅停止 server。7 秒 grace + 1 秒 modal event。
|
||
func (c *ServerController) Stop() error {
|
||
c.txMu.Lock()
|
||
defer c.txMu.Unlock()
|
||
|
||
// MAJ-1 修復:先 cancel watchServerV2 goroutine,避免它在 server 死後仍 poll
|
||
// health endpoint,3 次失敗(30s)後誤觸 handleWatchFailure → setState(Error)
|
||
// → 發 OS 崩潰通知。使用者按 Stop 後不該收到「Server 崩潰」警報。
|
||
c.cancelWatcher()
|
||
|
||
c.mu.Lock()
|
||
s := c.state
|
||
proc := c.proc
|
||
c.mu.Unlock()
|
||
|
||
if s == ServerStateStopped || s == ServerStateError || s == ServerStateIdle {
|
||
// 沒東西可停
|
||
return nil
|
||
}
|
||
if s == ServerStateStarting {
|
||
// 理論上 txMu 已經保證不會併發,但保險起見
|
||
return fmt.Errorf("cannot stop while starting; retry shortly")
|
||
}
|
||
|
||
// 切到 Stopping(必須先 set,讓前端按鈕 disable)
|
||
c.setState(ServerStateStopping, "")
|
||
|
||
if proc != nil {
|
||
proc.stopGraceful()
|
||
}
|
||
|
||
c.mu.Lock()
|
||
c.proc = nil
|
||
c.mu.Unlock()
|
||
c.setState(ServerStateStopped, "")
|
||
return nil
|
||
}
|
||
|
||
// cancelWatcher 取消正在跑的 watchServerV2 goroutine,並把 a.watchCancel 清掉。
|
||
// 必須在 txMu 已被持有的情況下呼叫(避免與 startServerV2 重新建立 watcher 競態)。
|
||
//
|
||
// MAJ-1 修復用 helper:Stop / ForceKill 進場後第一件事就呼叫此函式,
|
||
// 確保 watcher 不會在 transition 期間 race 地翻動 state。
|
||
func (c *ServerController) cancelWatcher() {
|
||
if c.app == nil {
|
||
return
|
||
}
|
||
c.app.mu.Lock()
|
||
if c.app.watchCancel != nil {
|
||
c.app.watchCancel()
|
||
c.app.watchCancel = nil
|
||
}
|
||
c.app.mu.Unlock()
|
||
}
|
||
|
||
// Restart = Stop → Start,強制保留舊 port(R5-F-2)。
|
||
func (c *ServerController) Restart() error {
|
||
// 記住舊 port
|
||
c.mu.Lock()
|
||
oldPort := 0
|
||
if c.proc != nil {
|
||
oldPort = c.proc.port
|
||
}
|
||
c.mu.Unlock()
|
||
|
||
// MAJ-4 補丁:通知瀏覽器 tab「server 要 restart 了」
|
||
// 前端收到 reason=restart → 延遲 10 秒再顯示 Offline Overlay,給 server 重啟時間。
|
||
// best-effort:失敗不影響 Stop / Start 流程。
|
||
if c.app != nil && oldPort > 0 {
|
||
ctx := context.Background()
|
||
if c.app.ctx != nil {
|
||
ctx = c.app.ctx
|
||
}
|
||
notifyShutdownImminent(ctx, oldPort, "restart")
|
||
}
|
||
|
||
if err := c.Stop(); err != nil {
|
||
return err
|
||
}
|
||
return c.StartWithPort(oldPort)
|
||
}
|
||
|
||
// ForceKill 非 graceful 直接 SIGKILL 目前的 proc(給 RestartStartupSequence 用)。
|
||
// 不 emit 崩潰通知,因為這是使用者主動要的「清乾淨重來」。
|
||
//
|
||
// 呼叫後 state → Stopped。
|
||
func (c *ServerController) ForceKill() error {
|
||
c.txMu.Lock()
|
||
defer c.txMu.Unlock()
|
||
|
||
// MAJ-1 修復:與 Stop() 同理,先 cancel watcher,避免 ForceKill 後 watcher
|
||
// 還在跑 → 30 秒後把 Stopped 翻成 Error + 發崩潰通知。
|
||
c.cancelWatcher()
|
||
|
||
c.mu.Lock()
|
||
proc := c.proc
|
||
c.proc = nil
|
||
c.mu.Unlock()
|
||
|
||
if proc != nil {
|
||
proc.forceKill()
|
||
}
|
||
c.setState(ServerStateStopped, "")
|
||
return nil
|
||
}
|
||
|
||
// handleWatchFailure 是 watchServer goroutine 偵測到連續失敗時呼叫的。
|
||
// 進 Error state + OS 通知,不 os.Exit。
|
||
//
|
||
// MAJ-2 修復:必須持有 txMu,與 Start/Stop/ForceKill/Restart 序列化,避免:
|
||
// - Stop 已把 state → Stopped,watcher 又把它翻回 Error
|
||
// - ForceKill 已把 state → Stopped,watcher 又翻回 Error 並發崩潰通知
|
||
//
|
||
// 進場後若 state 已不是 Running(代表使用者剛主動 Stop/ForceKill/Restart 完成),
|
||
// 直接 return — watcher 的失敗結論已過時。
|
||
//
|
||
// 死結風險評估:watchServerV2 是獨立 goroutine,由 startServerV2 啟動後與
|
||
// 啟動流程脫鉤,不會在持有 txMu 的情況下呼叫 handleWatchFailure,因此安全。
|
||
func (c *ServerController) handleWatchFailure(port int, reason string) {
|
||
c.txMu.Lock()
|
||
defer c.txMu.Unlock()
|
||
|
||
c.mu.Lock()
|
||
curState := c.state
|
||
proc := c.proc
|
||
c.mu.Unlock()
|
||
|
||
// 二次檢查:已不是 Running 代表 Stop / ForceKill / Restart 在我們等 txMu 時搶先了。
|
||
// 不能覆蓋已完成的 transition。
|
||
if curState != ServerStateRunning {
|
||
return
|
||
}
|
||
|
||
c.setState(ServerStateError, reason)
|
||
if c.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(c.app.ctx, "server:error", map[string]any{
|
||
"reason": reason,
|
||
"port": port,
|
||
})
|
||
}
|
||
// 非阻塞發 OS 通知
|
||
go sendCrashNotification(
|
||
"visionA Local — Server 崩潰",
|
||
"本機伺服器停止回應。請打開 visionA Local 查看錯誤並按 Restart。",
|
||
)
|
||
|
||
// 嘗試清理 process(可能還沒死)
|
||
if proc != nil {
|
||
proc.forceKill()
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// ServerProcess v2:擁有 pipe readers 與 pump 管線
|
||
// -----------------------------------------------------------------------
|
||
|
||
// stopGraceful 執行 7 秒 grace + 1 秒 modal 的 shutdown 流程。
|
||
// 對應 TDD v2/server-lifecycle.md §8.2 的 ServerProcess.stop()。
|
||
//
|
||
// 注意:這是 v2 新增的方法,與 v1 的 ServerProcess.stop() 並存以免動到太多舊路徑。
|
||
// v2 路徑(ctrl.Stop 呼叫)一律走這個。
|
||
func (p *ServerProcess) stopGraceful() {
|
||
if p == nil || p.cmd == nil || p.cmd.Process == nil {
|
||
return
|
||
}
|
||
// Windows 沒有 SIGTERM,直接 Kill
|
||
if runtime.GOOS == "windows" {
|
||
_ = p.cmd.Process.Kill()
|
||
} else {
|
||
_ = p.cmd.Process.Signal(syscall.SIGTERM)
|
||
}
|
||
|
||
done := make(chan struct{})
|
||
go func() {
|
||
_, _ = p.cmd.Process.Wait()
|
||
close(done)
|
||
}()
|
||
|
||
modalTimer := time.NewTimer(1 * time.Second)
|
||
graceTimer := time.NewTimer(shutdownGraceV2)
|
||
defer modalTimer.Stop()
|
||
defer graceTimer.Stop()
|
||
|
||
// 追蹤 modal 是否曾 show,return 時需要對稱地 hide(避免前端卡在
|
||
// 「正在停止伺服器…」popup)。
|
||
modalShown := false
|
||
defer func() {
|
||
if modalShown && p.app != nil && p.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-hide", nil)
|
||
}
|
||
}()
|
||
|
||
for {
|
||
select {
|
||
case <-done:
|
||
p.closeLogFiles()
|
||
return
|
||
case <-modalTimer.C:
|
||
if p.app != nil && p.app.ctx != nil {
|
||
wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-show", nil)
|
||
modalShown = true
|
||
}
|
||
case <-graceTimer.C:
|
||
_ = p.cmd.Process.Kill()
|
||
<-done
|
||
p.closeLogFiles()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// forceKill 立即 SIGKILL,不 graceful、不 emit event。
|
||
func (p *ServerProcess) forceKill() {
|
||
if p == nil || p.cmd == nil || p.cmd.Process == nil {
|
||
return
|
||
}
|
||
_ = p.cmd.Process.Kill()
|
||
_, _ = p.cmd.Process.Wait()
|
||
p.closeLogFiles()
|
||
}
|
||
|
||
// closeLogFiles 安全關閉 stdout/stderr log 檔。
|
||
func (p *ServerProcess) closeLogFiles() {
|
||
if p.stdoutLog != nil {
|
||
_ = p.stdoutLog.Close()
|
||
p.stdoutLog = nil
|
||
}
|
||
if p.stderrLog != nil {
|
||
_ = p.stderrLog.Close()
|
||
p.stderrLog = nil
|
||
}
|
||
}
|
||
|
||
// shutdownGraceV2 = 7 秒 grace period(PM Q4)。
|
||
const shutdownGraceV2 = 7 * time.Second
|
||
|
||
// -----------------------------------------------------------------------
|
||
// App.startServerV2 — 使用 pipe 捕捉 stdout/stderr
|
||
// -----------------------------------------------------------------------
|
||
|
||
// startServerV2 是 v2 版本的 server spawn,改為:
|
||
// 1. 使用 StdoutPipe / StderrPipe 讓 logPump 能逐行讀
|
||
// 2. 開兩個 goroutine 做 logPump(寫檔 + append LogBuffer + emit event)
|
||
// 3. 健康檢查成功後才 writeIPCPort
|
||
// 4. M8-4b:穿插 startupPipeline.CompleteStage(2..4) 的 hook
|
||
//
|
||
// preferredPort:
|
||
// 0 → cold start / manual Start,允許從 defaultPreferredPort 開始 fallback
|
||
// 非 0 → 強制指定 port(Restart 路徑),port 被佔就失敗
|
||
//
|
||
// 成功回傳 *ServerProcess(含 port / cmd / pipes);失敗回 nil + error。
|
||
//
|
||
// pipeline 注意事項:startServerV2 同時被冷啟動(pipeline 還在跑)和 RestartServer
|
||
// (pipeline 已 ready,hook 會被 CompleteStage 內的 current!=stage 檢查擋下)共用。
|
||
// 因此呼叫 hook 是 idempotent / 安全的。
|
||
func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) {
|
||
// 1. 決定 Python runtime — 對應啟動階段 2「檢查 Python runtime」
|
||
pyBin, pyMode, err := a.ensurePythonRuntime(a.pythonMode)
|
||
if err != nil {
|
||
// 階段 2 失敗
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.FailStage(2, fmt.Errorf("python runtime unavailable: %w", err))
|
||
}
|
||
return nil, fmt.Errorf("python runtime unavailable: %w", err)
|
||
}
|
||
a.mu.Lock()
|
||
a.pythonBin = pyBin
|
||
a.pythonModeR = pyMode
|
||
a.mu.Unlock()
|
||
// 階段 2 完成 → 自動進入階段 3 running
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.CompleteStage(2)
|
||
}
|
||
|
||
// 2. 首次啟動自動安裝 Kneron WinUSB driver(Windows only)
|
||
if pyBin != "" {
|
||
if derr := a.ensureDriverInstalled(pyBin); derr != nil {
|
||
fmt.Fprintln(os.Stderr, "[visiona-local] driver auto-install failed (非致命):", derr)
|
||
}
|
||
}
|
||
|
||
// 3. 挑 port
|
||
var port int
|
||
if preferredPort > 0 {
|
||
// Restart 路徑:強制保留,不 fallback
|
||
if !portAvailable(preferredPort) {
|
||
// 先試著清掉 stale 的 visiona-local-server
|
||
if killStaleServerOnPort(preferredPort) {
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
}
|
||
if !portAvailable(preferredPort) {
|
||
return nil, fmt.Errorf("Restart failed: port %d occupied", preferredPort)
|
||
}
|
||
port = preferredPort
|
||
} else {
|
||
p, perr := pickPort(defaultPreferredPort)
|
||
if perr != nil {
|
||
return nil, fmt.Errorf("no free port: %w", perr)
|
||
}
|
||
port = p
|
||
}
|
||
|
||
// 4. 定位 server binary
|
||
binPath, err := locateServerBinary()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("server binary not found: %w", err)
|
||
}
|
||
|
||
// 5. 組參數
|
||
args := []string{
|
||
"--host", "127.0.0.1",
|
||
"--port", strconv.Itoa(port),
|
||
"--data-dir", a.dataDir,
|
||
"--python-mode", string(pyMode),
|
||
}
|
||
if pyBin != "" {
|
||
args = append(args, "--python", pyBin)
|
||
}
|
||
|
||
// 6. 開磁碟 log 檔(append 模式)
|
||
logsDir := filepath.Join(a.dataDir, "logs")
|
||
_ = os.MkdirAll(logsDir, 0o755)
|
||
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)
|
||
|
||
// 7. 組 cmd
|
||
cmd := exec.Command(binPath, args...)
|
||
cmd.Dir = filepath.Dir(binPath)
|
||
configureSysProcAttr(cmd)
|
||
|
||
// 注入 bundle bin dir(ffmpeg / ffprobe)
|
||
env := os.Environ()
|
||
if binDir, err := locateBundleBinDir(); err == nil {
|
||
env = append(env, "VISIONA_BUNDLE_BIN_DIR="+binDir)
|
||
}
|
||
if pyBin != "" {
|
||
env = append(env, "VISIONA_PYTHON="+pyBin)
|
||
}
|
||
cmd.Env = env
|
||
|
||
// 8. v2:用 Pipe 而不是 cmd.Stdout = file
|
||
stdoutPipe, err := cmd.StdoutPipe()
|
||
if err != nil {
|
||
closeLogFilesSafe(stdoutLog, stderrLog)
|
||
return nil, fmt.Errorf("StdoutPipe: %w", err)
|
||
}
|
||
stderrPipe, err := cmd.StderrPipe()
|
||
if err != nil {
|
||
_ = stdoutPipe.Close()
|
||
closeLogFilesSafe(stdoutLog, stderrLog)
|
||
return nil, fmt.Errorf("StderrPipe: %w", err)
|
||
}
|
||
|
||
// 9. 啟動 process
|
||
if err := cmd.Start(); err != nil {
|
||
_ = stdoutPipe.Close()
|
||
_ = stderrPipe.Close()
|
||
closeLogFilesSafe(stdoutLog, stderrLog)
|
||
return nil, fmt.Errorf("exec.Start: %w", err)
|
||
}
|
||
|
||
proc := &ServerProcess{
|
||
cmd: cmd,
|
||
port: port,
|
||
stdoutLog: stdoutLog,
|
||
stderrLog: stderrLog,
|
||
app: a,
|
||
}
|
||
|
||
// 10. 啟動兩個 logPump goroutine
|
||
go a.logPump(stdoutPipe, "stdout", stdoutLog)
|
||
go a.logPump(stderrPipe, "stderr", stderrLog)
|
||
|
||
// 11. 等 health check — 對應啟動階段 3「啟動本機伺服器」
|
||
//
|
||
// 冷啟動時 pause hard timeout:Windows 首次執行 visiona-local-server.exe 會被
|
||
// Defender / EDR real-time scan 卡 30-60 秒,60 秒 healthCheckTimeout 本身足夠,
|
||
// 但不該把這段 scan 時間算進 startup pipeline 的 60 秒 total budget(那是日常
|
||
// 啟動預算,首次 bootstrap 應豁免,和 Stage 2 Python bootstrap 同理)。
|
||
//
|
||
// 判斷冷啟動:pipeline 處於 stage 1-6 範圍內(IsInColdStart)。
|
||
// RestartServer(pipeline 已 ready,current==7)不 pause,維持嚴格計時。
|
||
pausedForWait := false
|
||
if a.startupPipeline != nil && a.startupPipeline.IsInColdStart() {
|
||
a.startupPipeline.PauseHardTimeout()
|
||
pausedForWait = true
|
||
}
|
||
if err := waitHealthy(port, healthCheckTimeout); err != nil {
|
||
if pausedForWait {
|
||
a.startupPipeline.ResumeHardTimeout()
|
||
}
|
||
proc.forceKill()
|
||
removeIPCPort(a.dataDir)
|
||
// 階段 3 失敗
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.FailStage(3, fmt.Errorf("server did not become healthy: %w", err))
|
||
}
|
||
return nil, fmt.Errorf("server did not become healthy: %w", err)
|
||
}
|
||
if pausedForWait {
|
||
a.startupPipeline.ResumeHardTimeout()
|
||
}
|
||
// 階段 3 完成 → 自動進入階段 4 running
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.CompleteStage(3)
|
||
}
|
||
|
||
// 12. 寫 ipc-port 檔
|
||
writeIPCPort(a.dataDir, port)
|
||
|
||
// 12.5. M8-4b:階段 4「偵測 Kneron 裝置」— 探測 /api/devices 一次。
|
||
// 任何 HTTP response(含 5xx 或 timeout)都視為「server 已能 serve」,階段 4 完成。
|
||
// 真正的 device list 內容由前端負責處理(空 list 也算正常)。
|
||
if a.startupPipeline != nil {
|
||
a.probeDeviceListAndComplete(port)
|
||
}
|
||
|
||
// 13. 啟動 watchServer(失敗進 Error state)
|
||
watchCtx, cancel := context.WithCancel(context.Background())
|
||
a.mu.Lock()
|
||
if a.watchCancel != nil {
|
||
a.watchCancel()
|
||
}
|
||
a.watchCancel = cancel
|
||
a.mu.Unlock()
|
||
go a.watchServerV2(watchCtx, proc)
|
||
|
||
return proc, nil
|
||
}
|
||
|
||
// probeDeviceListAndComplete 對 /api/devices 發一次 GET 作為「偵測 Kneron 裝置」階段,
|
||
// 不論回應內容與狀態碼,只要拿到 HTTP response 就算階段 4 完成(我們只是想確認
|
||
// server 已能 serve 業務 endpoint,硬體有沒有插不重要 — 空 list 也合法)。
|
||
//
|
||
// timeout 設 2 秒。TDD §3 原意是「秒回即算完成」;實測 /api/devices handler 讀內部
|
||
// registry,毫秒級就回,2 秒 timeout 足以涵蓋正常 latency,不需要 5 秒保守值。
|
||
// 逾時也算「偵測完成」(避免硬體 driver 卡住整個啟動流程)。
|
||
func (a *App) probeDeviceListAndComplete(port int) {
|
||
if a.startupPipeline == nil {
|
||
return
|
||
}
|
||
url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port)
|
||
client := &http.Client{Timeout: 2 * time.Second}
|
||
resp, err := client.Get(url)
|
||
if resp != nil {
|
||
resp.Body.Close()
|
||
}
|
||
// 不論 err 或 status,都視為階段 4 完成(只是 probe)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "[visiona-local] startup stage 4: device probe non-fatal error: %v\n", err)
|
||
}
|
||
a.startupPipeline.CompleteStage(4)
|
||
}
|
||
|
||
func closeLogFilesSafe(files ...*os.File) {
|
||
for _, f := range files {
|
||
if f != nil {
|
||
_ = f.Close()
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// logPump:pipe → LogBuffer → Wails event(10ms micro-batch)
|
||
// -----------------------------------------------------------------------
|
||
|
||
// logPump 讀取 server 子程序的 stdout 或 stderr pipe,
|
||
// 每行同時:
|
||
// 1. 寫到磁碟 log 檔
|
||
// 2. append 到 ring buffer
|
||
// 3. 累積到 10ms micro-batch,flush 時 emit "log:append" Wails event
|
||
//
|
||
// TDD ground truth:v2/control-panel.md §4.5、v2/server-lifecycle.md §4.2。
|
||
//
|
||
// Pipe EOF(process exit)或 scanner 錯誤時 goroutine 自行退出。
|
||
func (a *App) logPump(pipe io.ReadCloser, stream string, fileWriter io.Writer) {
|
||
defer pipe.Close()
|
||
|
||
scanner := bufio.NewScanner(pipe)
|
||
scanner.Buffer(make([]byte, 64*1024), 1*1024*1024)
|
||
|
||
batch := make([]LogLine, 0, 16)
|
||
ticker := time.NewTicker(10 * time.Millisecond)
|
||
defer ticker.Stop()
|
||
|
||
// 拆出 scanner goroutine,讓 main loop 可以 select 於 ticker + lineCh
|
||
scanDone := make(chan struct{})
|
||
lineCh := make(chan string, 128)
|
||
|
||
go func() {
|
||
defer close(scanDone)
|
||
for scanner.Scan() {
|
||
text := scanner.Text()
|
||
select {
|
||
case lineCh <- text:
|
||
default:
|
||
// channel full:同步 block,避免 scanner 跑太快讓 batch 丟太多行
|
||
// 寧可 scanner 背壓一下,也要保留資料完整性
|
||
lineCh <- text
|
||
}
|
||
}
|
||
}()
|
||
|
||
flush := func() {
|
||
if len(batch) == 0 {
|
||
return
|
||
}
|
||
if a.ctx != nil && a.logBuf != nil {
|
||
// Rate limit:burst 超過就不 emit,但 ring buffer 已 append 過
|
||
if a.logBuf.ShouldEmit() {
|
||
wailsRuntime.EventsEmit(a.ctx, "log:append", batch)
|
||
}
|
||
}
|
||
batch = batch[:0]
|
||
}
|
||
|
||
// processLine 把單行寫檔 + append ring buffer + 加進 batch。
|
||
// MAJ-5 修復後 main loop 與 drain loop 共用此 helper,確保兩條路徑語意一致。
|
||
processLine := func(line string) {
|
||
// 1. 寫檔(持久化)
|
||
if fileWriter != nil {
|
||
_, _ = fileWriter.Write([]byte(line + "\n"))
|
||
}
|
||
// 2. ring buffer
|
||
l := LogLine{
|
||
Ts: time.Now().UnixMilli(),
|
||
Stream: stream,
|
||
Line: line,
|
||
Level: parseLogLevel(line),
|
||
}
|
||
if a.logBuf != nil {
|
||
a.logBuf.Append(l)
|
||
}
|
||
// 3. 加到 batch
|
||
batch = append(batch, l)
|
||
}
|
||
|
||
for {
|
||
select {
|
||
case line, ok := <-lineCh:
|
||
if !ok {
|
||
flush()
|
||
return
|
||
}
|
||
processLine(line)
|
||
case <-ticker.C:
|
||
flush()
|
||
case <-scanDone:
|
||
// MAJ-5 修復:scanner goroutine 已結束(pipe EOF / process exit),
|
||
// 但 lineCh buffer(cap 128)內可能還有未消化的行 — 包括 server crash
|
||
// 時最後幾行 stderr stack trace。Go select 隨機選擇 ready case,
|
||
// 直接 return 會導致這些行被丟棄。
|
||
//
|
||
// 修法:drain lineCh 到底再 flush + return。drain 用 default 避免
|
||
// 永遠阻塞(scanner goroutine 已 close → 沒人會再寫入,drain 必能結束)。
|
||
ticker.Stop()
|
||
drain:
|
||
for {
|
||
select {
|
||
case line := <-lineCh:
|
||
processLine(line)
|
||
default:
|
||
break drain
|
||
}
|
||
}
|
||
flush()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// watchServerV2 — 取代 watchServer,失敗時進 Error state
|
||
// -----------------------------------------------------------------------
|
||
|
||
// watchServerV2 每 10 秒打 /api/system/health,連續 3 次失敗進 Error state。
|
||
// 不再呼叫 reportFatal / os.Exit。
|
||
func (a *App) watchServerV2(ctx context.Context, sp *ServerProcess) {
|
||
ticker := time.NewTicker(10 * time.Second)
|
||
defer ticker.Stop()
|
||
|
||
failures := 0
|
||
healthURL := fmt.Sprintf("http://127.0.0.1:%d/api/system/health", sp.port)
|
||
client := &http.Client{Timeout: 3 * time.Second}
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
resp, err := client.Get(healthURL)
|
||
if err == nil && resp.StatusCode == 200 {
|
||
resp.Body.Close()
|
||
if failures > 0 && a.ctx != nil {
|
||
wailsRuntime.EventsEmit(a.ctx, "server:recovered", nil)
|
||
}
|
||
failures = 0
|
||
continue
|
||
}
|
||
if resp != nil {
|
||
resp.Body.Close()
|
||
}
|
||
failures++
|
||
fmt.Fprintf(os.Stderr, "[visiona-local] server health check failed (%d/3): %v\n", failures, err)
|
||
if failures >= 3 {
|
||
if a.ctrl != nil {
|
||
a.ctrl.handleWatchFailure(sp.port, "health check failed 3 times")
|
||
}
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Bindings — Wails 自動暴露給前端
|
||
// -----------------------------------------------------------------------
|
||
|
||
// StartServer 啟動 server 子程序(允許 port fallback)。
|
||
// 對應 TDD v2/control-panel.md §4.2。
|
||
func (a *App) StartServer() error {
|
||
if a.ctrl == nil {
|
||
return fmt.Errorf("controller not initialized")
|
||
}
|
||
return a.ctrl.Start()
|
||
}
|
||
|
||
// StopServer 優雅停止 server。
|
||
func (a *App) StopServer() error {
|
||
if a.ctrl == nil {
|
||
return fmt.Errorf("controller not initialized")
|
||
}
|
||
return a.ctrl.Stop()
|
||
}
|
||
|
||
// RestartServer 強制保留舊 port 的重啟。
|
||
func (a *App) RestartServer() error {
|
||
if a.ctrl == nil {
|
||
return fmt.Errorf("controller not initialized")
|
||
}
|
||
return a.ctrl.Restart()
|
||
}
|
||
|
||
// ForceKillServer 強制 SIGKILL server(非 graceful,無通知)。
|
||
// 主要給 RestartStartupSequence 用。
|
||
func (a *App) ForceKillServer() error {
|
||
if a.ctrl == nil {
|
||
return fmt.Errorf("controller not initialized")
|
||
}
|
||
return a.ctrl.ForceKill()
|
||
}
|
||
|
||
// RestartStartupSequence 完整重置 6 階段啟動流程(M8-4b / R5-E)。
|
||
// 由 Wails 控制台 Error state 的「Retry」按鈕呼叫。
|
||
//
|
||
// 與 RestartServer 的差別:
|
||
// RestartServer : Stop → Start(保留 port、不重置 startup pipeline)
|
||
// RestartStartupSequence : ForceKill → 清狀態 → 重建 StartupPipeline → 從階段 2 跑
|
||
//
|
||
// TDD 規格見 .autoflow/04-architecture/v2/startup-pipeline.md §8。
|
||
//
|
||
// 五個步驟:
|
||
// 1. 停掉舊 watcher goroutine
|
||
// 2. ForceKill server 子程序
|
||
// 3. 把 state machine 切回 Stopped(避免 Error state 殘留)
|
||
// 4. 清 sentinel file(critical — 否則階段 6 會誤判為瞬間完成)
|
||
// 5. 重建 StartupPipeline、emit 階段 1 completed、開新 watcher、呼叫 ctrl.Start()
|
||
func (a *App) RestartStartupSequence() error {
|
||
if a.ctrl == nil {
|
||
return fmt.Errorf("controller not initialized")
|
||
}
|
||
|
||
// Step 1: 停掉舊 watcher goroutine
|
||
if a.pipelineCancelFn != nil {
|
||
a.pipelineCancelFn()
|
||
a.pipelineCancelFn = nil
|
||
}
|
||
|
||
// Step 2: 強制殺掉 server 子程序(不走 7s grace,我們是在 recover failure)
|
||
_ = a.ctrl.ForceKill()
|
||
|
||
// Step 3: state machine → Stopped(避免 Error state 殘留)
|
||
a.ctrl.setState(ServerStateStopped, "")
|
||
|
||
// Step 4: 清 sentinel file(前次 Run 的殘留會讓階段 6 立刻完成)
|
||
removeSentinelFile(a.dataDir)
|
||
|
||
// Step 5: 重建 StartupPipeline
|
||
a.startupPipeline = NewStartupPipeline(a)
|
||
|
||
// 階段 1「初始化 Wails 控制台」已是 running 狀態(Wails app 本身),
|
||
// 不需要重做,直接 emit completed 然後手動切到階段 2 running
|
||
now := time.Now()
|
||
a.startupPipeline.mu.Lock()
|
||
a.startupPipeline.startedAt = now
|
||
a.startupPipeline.current = 1
|
||
a.startupPipeline.stages[1].status = "completed"
|
||
a.startupPipeline.stages[1].startedAt = now
|
||
a.startupPipeline.stages[1].completedAt = now
|
||
a.startupPipeline.mu.Unlock()
|
||
a.startupPipeline.emitProgress(1)
|
||
|
||
// 啟動 watcher goroutine
|
||
if a.ctx != nil {
|
||
watcherCtx, cancel := context.WithCancel(a.ctx)
|
||
a.pipelineCancelFn = cancel
|
||
a.startupPipeline.watcherCancel = cancel
|
||
a.startupPipeline.watcherDone = make(chan struct{})
|
||
go a.startupPipeline.watcher(watcherCtx)
|
||
}
|
||
|
||
// 切到階段 2 running
|
||
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)
|
||
|
||
// 呼叫 StartServer(內部會依序 CompleteStage(2..4))
|
||
// Retry 情境允許 port fallback(cold start 模式)
|
||
//
|
||
// M8-4b 補丁 M-3:test hook — 單元測試可用 restartStartFn 替換 ctrl.Start,
|
||
// 避免在測試環境 spawn 真的 python server。正式環境走預設 a.ctrl.Start()。
|
||
startFn := a.ctrl.Start
|
||
if a.restartStartFn != nil {
|
||
startFn = a.restartStartFn
|
||
}
|
||
if err := startFn(); err != nil {
|
||
// startServerV2 內已 FailStage,不需重複;err 仍 propagate 給前端讓 Retry 按鈕能 catch
|
||
return err
|
||
}
|
||
|
||
// 階段 5:開瀏覽器(或 skip)
|
||
a.runStartupStage5()
|
||
// 階段 6 由 watcher poll sentinel file 觸發
|
||
return nil
|
||
}
|
||
|
||
// GetServerStatusV2 回傳 v2 版本的完整狀態。
|
||
// 注意:v1 的 GetServerStatus() 仍保留以維持相容性,但內容為舊 struct。
|
||
func (a *App) GetServerStatusV2() ServerStatusV2 {
|
||
return a.snapshotStatus()
|
||
}
|
||
|
||
// snapshotStatus 是組裝 ServerStatusV2 的工具函式(內部使用)。
|
||
func (a *App) snapshotStatus() ServerStatusV2 {
|
||
a.mu.Lock()
|
||
pyBin := a.pythonBin
|
||
pyMode := string(a.pythonModeR)
|
||
a.mu.Unlock()
|
||
|
||
st := ServerStatusV2{
|
||
PythonBin: pyBin,
|
||
PythonMode: pyMode,
|
||
}
|
||
if a.ctrl != nil {
|
||
a.ctrl.mu.Lock()
|
||
st.State = a.ctrl.state
|
||
st.LastError = a.ctrl.lastError
|
||
if !a.ctrl.startedAt.IsZero() {
|
||
st.StartedAt = a.ctrl.startedAt.UnixMilli()
|
||
}
|
||
proc := a.ctrl.proc
|
||
a.ctrl.mu.Unlock()
|
||
if proc != nil && proc.cmd != nil && proc.cmd.Process != nil {
|
||
st.Port = proc.port
|
||
st.URL = fmt.Sprintf("http://127.0.0.1:%d", proc.port)
|
||
st.PID = proc.cmd.Process.Pid
|
||
}
|
||
} else {
|
||
st.State = ServerStateIdle
|
||
}
|
||
return st
|
||
}
|
||
|
||
// GetRecentLogs 回傳 ring buffer 最後 n 行。
|
||
// n <= 0 或 > 2000 → 回傳全部。
|
||
func (a *App) GetRecentLogs(n int) []LogLine {
|
||
if a.logBuf == nil {
|
||
return []LogLine{}
|
||
}
|
||
return a.logBuf.Snapshot(n)
|
||
}
|
||
|
||
// ClearLogs 清空 ring buffer(只清 UI,不清磁碟檔)。
|
||
func (a *App) ClearLogs() {
|
||
if a.logBuf != nil {
|
||
a.logBuf.Reset()
|
||
}
|
||
if a.ctx != nil {
|
||
wailsRuntime.EventsEmit(a.ctx, "log:clear", nil)
|
||
}
|
||
}
|
||
|
||
// GetSystemInfo 回傳靜態系統資訊。
|
||
func (a *App) GetSystemInfo() SystemInfo {
|
||
logsDir := ""
|
||
if a.dataDir != "" {
|
||
logsDir = filepath.Join(a.dataDir, "logs")
|
||
}
|
||
return SystemInfo{
|
||
AppVersion: appVersionString(),
|
||
BuildTime: appBuildTimeString(),
|
||
DataDir: a.dataDir,
|
||
LogsDir: logsDir,
|
||
Platform: runtime.GOOS + "/" + runtime.GOARCH,
|
||
}
|
||
}
|
||
|
||
// OpenInBrowser 用系統瀏覽器開啟 url。空字串 → 用當前 server URL。
|
||
func (a *App) OpenInBrowser(url string) error {
|
||
if url == "" {
|
||
a.mu.Lock()
|
||
if a.ctrl != nil {
|
||
a.ctrl.mu.Lock()
|
||
if a.ctrl.proc != nil {
|
||
url = fmt.Sprintf("http://127.0.0.1:%d", a.ctrl.proc.port)
|
||
}
|
||
a.ctrl.mu.Unlock()
|
||
}
|
||
a.mu.Unlock()
|
||
}
|
||
if url == "" {
|
||
return fmt.Errorf("no server URL available")
|
||
}
|
||
return openBrowser(url)
|
||
}
|
||
|
||
// RevealLogsFolder 在檔案管理器中開啟 <dataDir>/logs/ 目錄。
|
||
func (a *App) RevealLogsFolder() error {
|
||
logsDir := filepath.Join(a.dataDir, "logs")
|
||
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||
return fmt.Errorf("mkdir logs dir: %w", err)
|
||
}
|
||
switch runtime.GOOS {
|
||
case "darwin":
|
||
return exec.Command("open", logsDir).Start()
|
||
case "windows":
|
||
return exec.Command("explorer", logsDir).Start()
|
||
default:
|
||
return exec.Command("xdg-open", logsDir).Start()
|
||
}
|
||
}
|
||
|
||
// ExportLog 把 ring buffer 當前內容寫到一個時間戳檔案並回傳絕對路徑。
|
||
func (a *App) ExportLog() (string, error) {
|
||
if a.logBuf == nil {
|
||
return "", fmt.Errorf("log buffer not initialized")
|
||
}
|
||
exportDir := filepath.Join(a.dataDir, "exports")
|
||
if err := os.MkdirAll(exportDir, 0o755); err != nil {
|
||
return "", fmt.Errorf("mkdir exports dir: %w", err)
|
||
}
|
||
ts := time.Now().Format("20060102-150405")
|
||
path := filepath.Join(exportDir, fmt.Sprintf("log-%s.txt", ts))
|
||
|
||
snap := a.logBuf.Snapshot(0)
|
||
f, err := os.Create(path)
|
||
if err != nil {
|
||
return "", fmt.Errorf("create export file: %w", err)
|
||
}
|
||
defer f.Close()
|
||
|
||
for _, line := range snap {
|
||
// 格式:"[level] 15:04:05.000 [stream] message\n"(level 為空時省略前括號)
|
||
t := time.UnixMilli(line.Ts).Format("15:04:05.000")
|
||
var prefix string
|
||
if line.Level != "" {
|
||
prefix = fmt.Sprintf("[%s] %s [%s] ", line.Level, t, line.Stream)
|
||
} else {
|
||
prefix = fmt.Sprintf("%s [%s] ", t, line.Stream)
|
||
}
|
||
if _, err := f.WriteString(prefix + line.Line + "\n"); err != nil {
|
||
return "", fmt.Errorf("write export file: %w", err)
|
||
}
|
||
}
|
||
abs, err := filepath.Abs(path)
|
||
if err != nil {
|
||
return path, nil
|
||
}
|
||
return abs, nil
|
||
}
|
||
|
||
// GetPreferences 讀取 Preferences(in-memory)。
|
||
func (a *App) GetPreferences() Preferences {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
return a.prefs
|
||
}
|
||
|
||
// SetPreferences 更新並持久化 Preferences。
|
||
func (a *App) SetPreferences(p Preferences) error {
|
||
if a.dataDir == "" {
|
||
return fmt.Errorf("data dir not ready")
|
||
}
|
||
if err := SavePreferences(a.dataDir, p); err != nil {
|
||
return err
|
||
}
|
||
a.mu.Lock()
|
||
a.prefs = p
|
||
a.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 版本字串 placeholder(M8 目前沒有 build 時注入版本,給一個合理預設)
|
||
// -----------------------------------------------------------------------
|
||
|
||
func appVersionString() string {
|
||
if v := os.Getenv("VISIONA_APP_VERSION"); v != "" {
|
||
return v
|
||
}
|
||
return "dev"
|
||
}
|
||
|
||
func appBuildTimeString() string {
|
||
if v := os.Getenv("VISIONA_BUILD_TIME"); v != "" {
|
||
return v
|
||
}
|
||
return "unknown"
|
||
}
|