jim800121chen 35db6c8167 fix(local-tool): Windows popup 卡死 safety net + appLog 覆蓋啟動流程
使用者回報 c649a81 之後仍看到「正在停止伺服器…」popup 一打開就卡住。
無法在不看 log 的情況下推斷根因,先加三層 safety net 確保 popup 不是
blocker,並把關鍵啟動訊息寫到 wails.log 供 Windows 除錯。

Safety net 三層:

1. 前端 watchdog:shutdown-modal 最多顯示 15 秒,超時自動 hide 並 toast
   提示使用者 server 可能還沒停掉。
2. 前端 escape hatch:點 backdrop 空白處 / 按 Esc 可手動關閉 popup。
3. Go 端 hardBailout timer:stopGraceful 最多跑 shutdownGraceV2 + 2 秒
   (目前 = 9 秒),到上限直接 return leak process,避免 Process.Wait
   永遠阻塞(Windows 偶有情境)。graceTimer 分支的 `<-done` 也改成
   非阻塞 `select-with-1s-timeout`。

Windows 除錯 log 強化:

4. startup 頭加版本識別標記到 wails.log:
     ==================================================
     visionA-local startup build=dev buildTime=unknown
     platform=windows arch=amd64 dataDir=...
     fix marker: c649a81+ (Stage3 waitHealthy pause / shutdown modal safety net)
     ==================================================
   使用者拉新版後啟動可從此確認 build 是否是最新版。
5. app.go 把 startup 路徑上的 fmt.Fprintln(os.Stderr, ...) 改 appLog:
   IPC server start / seed failure / Stage 1 complete / ctrl.Start 結果。
   Windows 上 stderr 是 null device,appLog 會同時寫檔到 wails.log。
6. server_control.go stopGraceful 加 appLog 記錄 entry / modal-show /
   grace timer / hard bailout / return,整條 Stop 路徑完全透明。
7. driver auto-install failed 訊息也改 appLog。

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK

需要使用者協助:拉新版後在 Windows 乾淨環境試,啟動後貼以下三個檔案
內容給我:
  %APPDATA%\visiona-local\logs\wails.log        — appLog 記錄整個啟動流程
  %APPDATA%\visiona-local\logs\server.stdout.log — server subprocess stdout
  %APPDATA%\visiona-local\logs\server.stderr.log — server subprocess stderr

log 裡有「fix marker: c649a81+」即為本 commit 或更新;若沒有 marker
或 marker 指向別的 commit 則代表 build 不是最新版。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:31:15 +08:00

1727 lines
57 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// =====================================================================
// visionA-local Wails App
// =====================================================================
// M1 範圍:啟動殼層,負責
// 1. single-instance lock檔案鎖 + PID
// 2. 舊資料目錄遷移
// 3. port picking3721 → 3722 → ...
// 4. Python runtime 雙策略auto / bundled / system— M1 只有空殼
// 5. spawn visiona-local-server 子行程(含 graceful shutdown
// 6. 提供前端 bindingGetServerURL、GetServerStatus、OpenBrowser
//
// **不在 M1 範圍**:實際下載/解壓 python-build-standaloneM2
// 內嵌 payloadM1-12 build packaging 才處理、auto-update、relay、
// installer wizard、tray。這些原本 installer 的邏輯已被整份刪除。
// =====================================================================
// -----------------------------------------------------------------------
// 常量 & 型別
// -----------------------------------------------------------------------
const (
defaultPreferredPort = 3721
portSearchRange = 20
// healthCheckTimeoutwaitHealthy 等 server /api/system/health 200 的上限。
//
// 從 30s → 60s 的理由Windows 首次啟動時 Defender real-time scan 會對
// visiona-local-server.exe 做完整掃描(未簽章 + 首次執行)可延遲 30-60 秒才
// 允許 process 真正執行。30 秒不夠60 秒涵蓋絕大多數企業環境(含 EDR
// 日常啟動 server 幾百毫秒就能回應,放寬上限不會影響正常情境。
healthCheckTimeout = 60 * time.Second
shutdownGracePeriod = 5 * time.Second
appName = "visiona-local"
)
// PythonMode 決定 Python runtime 的選擇策略。
type PythonMode string
const (
PythonModeAuto PythonMode = "auto" // 先試 system失敗才走 bundledR4 決策M1 先 system
PythonModeBundled PythonMode = "bundled" // 策略 A內嵌 python-build-standalone
PythonModeSystem PythonMode = "system" // 策略 B系統 python3
)
// ServerStatus 回報給前端。
type ServerStatus struct {
Running bool `json:"running"`
Port int `json:"port"`
URL string `json:"url"`
PID int `json:"pid"`
PythonBin string `json:"pythonBin"`
PythonMode string `json:"pythonMode"`
LastError string `json:"lastError,omitempty"`
}
// ServerProcess 包裝子行程控制。
// v2 新增 app 反向指標,讓 ServerProcess.stopGraceful() 能 emit Wails event。
type ServerProcess struct {
cmd *exec.Cmd
port int
stdoutLog *os.File
stderrLog *os.File
app *App
}
// App 是 Wails 綁定的主結構。
type App struct {
ctx context.Context
dataDir string
pythonMode PythonMode
mu sync.Mutex
server *ServerProcess // v1 相容保留v2 已改用 ctrl.proc
pythonBin string
pythonModeR PythonMode // 實際使用的 modeauto resolved 之後)
lastError string
releaseLock func()
// M8-4啟動進度訊息v1 splash 殘留v2 已由 startup-pipeline event 取代)。
// 目前仍保留供開發 log 使用,最終在 M8-4b 整個流程改寫後會被拿掉。
bootstrapStatus string
// L-1server 健康偵測 goroutine 控制
watchCancel context.CancelFunc
// L-3Wails 自己的 IPC server收 /ipc/raise
ipcPort int
ipcListener net.Listener
// M8-4v2 新增欄位
ctrl *ServerController // state machine 控制器
logBuf *LogBuffer // ring buffer2000 行)
prefs Preferences // 控制台偏好in-memory持久化至 preferences.json
// M8-4b6 階段啟動進度 pipeline
// startupPipeline 由 startup() 建立shutdown() 與 RestartStartupSequence() 重置。
// pipelineCancelFn 是 watcher goroutine 的 cancel func存在 App 上方便 RestartStartupSequence 操作。
startupPipeline *StartupPipeline
pipelineCancelFn context.CancelFunc
// M8-4b 補丁M-3 修復RestartStartupSequence 的 Step 6呼叫 ctrl.Start
// 在單元測試裡會真的 spawn python server無法測。提供 test hook 讓測試能替換
// 成 no-op 或 spy。生產環境 restartStartFn == nil → 走預設的 a.ctrl.Start()。
// test-only正式環境不應設定。
restartStartFn func() error
}
// NewApp 建立 App 實例。
func NewApp() *App {
mode := PythonModeAuto
// M3 smoke test hook設定 VISIONA_PYTHON_MODE=bundled 可強制走內嵌策略,
// 不需要改程式就能測試 ensureBundledPython。
if m := strings.ToLower(os.Getenv("VISIONA_PYTHON_MODE")); m != "" {
switch m {
case "bundled":
mode = PythonModeBundled
case "system":
mode = PythonModeSystem
case "auto":
mode = PythonModeAuto
}
}
// R5-5a沒插硬體就顯示空白狀態由 UI 處理),一律真實硬體路徑
return &App{
pythonMode: mode,
}
}
// -----------------------------------------------------------------------
// Wails lifecycle
// -----------------------------------------------------------------------
// startup 由 Wails 在 app 啟動時呼叫。
//
// M8-4b整合 6 階段啟動 pipeline。流程
// stage 1init Wails console→ seedUserDataDir 完成後 CompleteStage(1)
// stage 2Python runtime
// stage 3spawn server ├─ 由 startServerV2 內部 hook
// stage 4device probe
// stage 5open browser → ctrl.Start() return 後處理
// stage 6wait WebSocket → watcher goroutine poll sentinel file
//
// pipeline 失敗時不再呼叫 reportFatal會直接結束程式而是進 Error state
// 讓使用者看到 Wails 控制台的 Retry 按鈕。
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
ensureGUIPath()
// M8-4初始化 ring buffer 與 state machine controller
a.logBuf = NewLogBuffer()
a.ctrl = NewServerController(a)
dataDir := platformDataDir()
a.dataDir = dataDir
if err := os.MkdirAll(dataDir, 0o755); err != nil {
a.reportFatal("cannot create data dir", err)
return
}
// 版本識別標記 — 使用者拉新版後看 wails.log 能確認 build 是否正確。
// 請在每次影響啟動流程的修復時更新此訊息。
a.appLog("==================================================")
a.appLog("visionA-local startup build=%s buildTime=%s", appVersionString(), appBuildTimeString())
a.appLog("platform=%s arch=%s dataDir=%s", runtime.GOOS, runtime.GOARCH, dataDir)
a.appLog("fix marker: c649a81+ (Stage3 waitHealthy pause / shutdown modal safety net)")
a.appLog("==================================================")
// M8-4載入 preferences.json讀取失敗 → 用 DefaultPreferences 預設)
a.prefs = LoadPreferences(dataDir)
// M8-4b建立 startup pipeline 並啟動emit stage=1 running
// 必須在 prefs 載入後,因為 watcher 會讀 prefs.AutoOpenBrowser 判斷階段 5/6 是否 skip。
// 同時清掉殘留的 sentinel file前次 crash 留下的舊檔會讓階段 6 瞬間完成造成假象)
removeSentinelFile(dataDir)
a.startupPipeline = NewStartupPipeline(a)
pipelineCtx, cancel := context.WithCancel(ctx)
a.pipelineCancelFn = cancel
a.startupPipeline.Start(pipelineCtx)
// 1. 舊資料目錄遷移(必須在 lock 之前,因為 lock 檔會寫到新路徑)
migrateOldDataDirs(dataDir)
// 遷移後再次確認 dataDir 存在(遷移過程若發生異常狀況的保險)
if err := os.MkdirAll(dataDir, 0o755); err != nil {
a.reportFatal("cannot ensure data dir after migration", err)
return
}
// 2. single-instance lock
release, err := acquireSingleInstance(dataDir)
if err != nil {
// 區分錯誤類型:只有真的偵測到另一個 instance 才 exit(0) quietly
if isAnotherInstanceError(err) {
if tryRaiseExistingInstance(dataDir) {
fmt.Fprintln(os.Stderr, "visiona-local already running, raised existing window")
} else {
fmt.Fprintln(os.Stderr, "visiona-local already running")
}
os.Exit(0)
}
// 其他錯誤IO / 權限 / 資料目錄不見):視為 fatal 並顯示訊息
a.reportFatal("cannot acquire single-instance lock", err)
return
}
a.releaseLock = release
// 3. 啟動 Wails 自己的 IPC serverL-3
// 供後來的 instance 透過 /ipc/raise 把現有視窗提到前景。
// 失敗不擋啟動,只是犧牲 single-instance raise 能力。
if err := a.startIPCServer(); err != nil {
a.appLog("IPC server start failed: %v", err)
}
// 3.5. 首次啟動 seed把 installer 內建的 models.json / nef 預置模型 / scripts
// 複製到 user data-dir讓 server 能在 --data-dir=<user> 情境下讀到內建模型。
// 失敗不擋啟動,只是 server 啟動後模型庫會是空的。
a.setBootstrapStatus("正在準備應用程式資料...")
if err := a.seedUserDataDir(); err != nil {
a.appLog("seed user data dir failed: %v", err)
}
// M8-4b階段 1初始化 Wails 控制台)完成 → 自動進入階段 2 running
a.appLog("startup: Stage 1 complete, entering Stage 2 (Python runtime)")
a.startupPipeline.CompleteStage(1)
// 4. M8-4走 ServerController 啟動v2 路徑)。
// 冷啟動允許 port fallbackStartWithPort(0) 即為冷啟動)。
// startServerV2 內部會 hook 階段 2/3/4 的 pipeline.CompleteStage()。
if err := a.ctrl.Start(); err != nil {
// pipeline.FailStage 已經由 startServerV2 → ctrl.startInternal 觸發失敗時 emit error +
// 切到 Error state這裡不需要呼叫 reportFatal讓使用者看到 Retry 按鈕)
a.appLog("startup: ctrl.Start failed: %v", err)
return
}
a.appLog("startup: ctrl.Start returned successfully")
// 階段 5開瀏覽器或 skip
a.runStartupStage5()
// 階段 6 由 watcher goroutine poll sentinel file 觸發 → CompleteStage(6) → markReady
}
// runStartupStage5 處理 R5-E 階段 5開瀏覽器。
// AutoOpenBrowser=false → SkipStage 進入階段 6也會被 skip-timeout 規則處理)
// AutoOpenBrowser=true → 呼叫 openBrowser 並 CompleteStage(5)
func (a *App) runStartupStage5() {
if a.startupPipeline == nil {
return
}
if !a.prefs.AutoOpenBrowser {
a.startupPipeline.SkipStage(5)
return
}
// 取得 server URL
url := ""
if a.ctrl != nil {
a.ctrl.mu.Lock()
if a.ctrl.proc != nil && a.ctrl.proc.port > 0 {
url = fmt.Sprintf("http://127.0.0.1:%d", a.ctrl.proc.port)
}
a.ctrl.mu.Unlock()
}
if url != "" {
// 不等瀏覽器真的開(只等命令 return失敗記 log 不擋流程
if err := openBrowser(url); err != nil {
fmt.Fprintf(os.Stderr, "[visiona-local] startup stage 5: open browser failed: %v\n", err)
}
}
a.startupPipeline.CompleteStage(5)
}
// shutdown 由 Wails 在 app 結束時呼叫。
//
// M8-4改為呼叫 ctrl.Stop() 走 7 秒 grace + 1 秒 modal 流程。
// M8-4b停 startup pipeline watcher + 清 sentinel file。
// 細節對應 TDD v2/server-lifecycle.md §8 + v2/startup-pipeline.md §3。
func (a *App) shutdown(ctx context.Context) {
// M8-4b停 startup pipeline watcher避免 Stop 期間 watcher 還在 poll sentinel
if a.pipelineCancelFn != nil {
a.pipelineCancelFn()
a.pipelineCancelFn = nil
}
// 停 watch goroutine
if a.watchCancel != nil {
a.watchCancel()
}
// 關 IPC listener
if a.ipcListener != nil {
_ = a.ipcListener.Close()
}
removeWailsIPCPort(a.dataDir)
// MAJ-4 補丁:先通知瀏覽器 tab「server 要關了」→ 立即顯示 Offline Overlay
// 這一步必須在 ctrl.Stop() 之前,因為 Stop 會馬上 SIGTERM server之後瀏覽器
// 只會看到 ECONNREFUSED要等 15 s polling 失敗才顯示 overlay。
//
// best-effort失敗server 沒起來、endpoint 掛了、1 s timeout全部忽略
// 不阻塞 shutdown 流程。
if a.ctrl != nil {
port := a.snapshotStatus().Port
notifyShutdownImminent(ctx, port, "quit")
}
// M8-4由 ServerController 執行 Stop含 7 秒 grace + 1 秒 modal
if a.ctrl != nil {
_ = a.ctrl.Stop()
} else {
// v1 fallback理論上 M8-4 後不會走到)
a.stopServer()
}
// M8-4b清 sentinel file正常關機的清理下次啟動才不會誤判階段 6 已完成)
removeSentinelFile(a.dataDir)
if a.releaseLock != nil {
a.releaseLock()
}
}
// reportFatal 記錄錯誤、顯示原生錯誤視窗並結束程式。
//
// L-2從原本單純記錄錯誤 → 升級為「顯示原生對話框 + 結束程式」。
// 呼叫者會在呼叫後 return因此我們這邊可以安全地 os.Exit(1)。
func (a *App) reportFatal(msg string, err error) {
full := fmt.Sprintf("%s: %v", msg, err)
a.mu.Lock()
a.lastError = full
a.mu.Unlock()
fmt.Fprintln(os.Stderr, "[visiona-local] FATAL:", full)
// 1. emit Wails event 給前端(若 ctx 還在)
if a.ctx != nil {
wailsRuntime.EventsEmit(a.ctx, "fatal", map[string]string{"error": full})
wailsRuntime.EventsEmit(a.ctx, "app:error", full) // 保留既有事件名稱
}
// 2. 原生對話框
body := fmt.Sprintf("visionA-local 遇到嚴重錯誤:\n\n%s\n\n程式即將結束。", full)
showNativeError("visionA-local 嚴重錯誤", body)
// 3. 結束程式
if a.ctx != nil {
wailsRuntime.Quit(a.ctx)
}
os.Exit(1)
}
// showNativeError 在各平台跳原生錯誤視窗。失敗就退回 stderr。
func showNativeError(title, body string) {
switch runtime.GOOS {
case "darwin":
// macOS: osascript
// 用 "" 包字串並對內容做基本 escape雙引號與反斜線
esc := func(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}
script := fmt.Sprintf(
`display dialog "%s" with title "%s" buttons {"OK"} default button "OK" with icon stop`,
esc(body), esc(title),
)
_ = exec.Command("osascript", "-e", script).Run()
case "windows":
// Windows: PowerShell MessageBox
esc := func(s string) string {
return strings.ReplaceAll(s, `"`, `""`)
}
ps := fmt.Sprintf(
`Add-Type -AssemblyName PresentationFramework; [System.Windows.MessageBox]::Show("%s","%s",'OK','Error') | Out-Null`,
esc(body), esc(title),
)
_ = exec.Command("powershell", "-NoProfile", "-Command", ps).Run()
case "linux":
// Linux: 試 zenity → 失敗試 kdialog → 失敗才 stderr
if _, err := exec.LookPath("zenity"); err == nil {
_ = exec.Command("zenity", "--error", "--title="+title, "--text="+body).Run()
return
}
if _, err := exec.LookPath("kdialog"); err == nil {
_ = exec.Command("kdialog", "--title", title, "--error", body).Run()
return
}
fmt.Fprintln(os.Stderr, title+": "+body)
}
}
// -----------------------------------------------------------------------
// Bindings前端可呼叫
// -----------------------------------------------------------------------
// GetServerStatus 回傳目前 server 狀態。
func (a *App) GetServerStatus() ServerStatus {
a.mu.Lock()
defer a.mu.Unlock()
st := ServerStatus{
PythonBin: a.pythonBin,
PythonMode: string(a.pythonModeR),
LastError: a.lastError,
}
if a.server != nil && a.server.cmd != nil && a.server.cmd.Process != nil {
st.Running = true
st.Port = a.server.port
st.URL = fmt.Sprintf("http://127.0.0.1:%d", a.server.port)
st.PID = a.server.cmd.Process.Pid
}
return st
}
// GetServerURL 回傳 server base URL給前端 WebView 載入用)。
func (a *App) GetServerURL() string {
a.mu.Lock()
defer a.mu.Unlock()
if a.server == nil {
return ""
}
return fmt.Sprintf("http://127.0.0.1:%d", a.server.port)
}
// GetBootstrapStatus 回傳目前啟動階段的人類可讀文字(給 splash page 顯示進度)。
// 空字串代表還沒設定或已完成。
func (a *App) GetBootstrapStatus() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.bootstrapStatus
}
// setBootstrapStatus 更新啟動階段文字。各 startup step 呼叫此函式。
func (a *App) setBootstrapStatus(msg string) {
a.mu.Lock()
a.bootstrapStatus = msg
a.mu.Unlock()
a.appLog("bootstrap: %s", msg)
}
// OpenBrowser 用系統預設瀏覽器開啟 URL。
func (a *App) OpenBrowser(url string) error {
return openBrowser(url)
}
// InstallKneronDriver 呼叫 KneronPLUS SDK 的 libwdi wrapper 安裝 WinUSB driver。
//
// Windows 下需要 UAC 提權macOS / Linux 下會直接回錯誤訊息(用不到)。
// 前端在使用者點「安裝 Kneron USB driver」按鈕、或偵測到 connect 失敗 + error 28 時呼叫。
//
// 呼叫前 Python venv 必須已就緒ensureBundledPython 完成 + kp wheel 已裝)。
// 此 binding 會自動嘗試重新解析 python 路徑。
func (a *App) InstallKneronDriver() error {
a.mu.Lock()
pyBin := a.pythonBin
a.mu.Unlock()
if pyBin == "" {
// 可能使用者手動觸發、server 還沒啟動或 ensurePythonRuntime 失敗
// 嘗試重新 resolve
resolved, _, err := a.ensurePythonRuntime(a.pythonMode)
if err != nil {
return fmt.Errorf("無法取得 Python interpreter%w", err)
}
pyBin = resolved
}
if err := installKneronWinUSBDriver(pyBin); err != nil {
return err
}
// 手動安裝成功後也寫記號檔(之後就不會再自動彈 UAC
a.markDriverInstalled()
return nil
}
// appLog 把一行訊息寫到 <dataDir>/logs/wails.log 以及 os.Stderr。
// Wails Windows app 以 windowsgui subsystem buildos.Stderr 指向 null device
// 沒有這個檔使用者就看不到 startup 期間的 debug 訊息。
func (a *App) appLog(format string, args ...interface{}) {
msg := fmt.Sprintf("["+time.Now().Format("15:04:05")+"] "+format, args...)
fmt.Fprintln(os.Stderr, msg) // dev 模式 / macOS / Linux 會看到
if a.dataDir == "" {
return
}
logsDir := filepath.Join(a.dataDir, "logs")
_ = os.MkdirAll(logsDir, 0o755)
f, err := os.OpenFile(filepath.Join(logsDir, "wails.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return
}
defer f.Close()
fmt.Fprintln(f, msg)
}
// ensureDriverInstalled 在首次 startup 時自動安裝 Kneron WinUSB driver。
//
// 用 `<dataDir>/.driver-installed` 記號檔避免每次啟動都彈 UAC
// - 檔案存在 → 跳過(之前已安裝過,假設仍有效)
// - 檔案不存在 → 呼叫 installKneronWinUSBDriver → 成功後寫記號檔
//
// 失敗時寫 stderr 但不回 error讓 startup 流程繼續走,使用者之後可手動重試)。
//
// 使用者移除 `.driver-installed` 檔就能強制重裝(例如 Windows 更新把 driver 弄壞時)。
func (a *App) ensureDriverInstalled(pyBin string) error {
if runtime.GOOS != "windows" {
return nil // macOS / Linux 不需要
}
marker := filepath.Join(a.dataDir, ".driver-installed")
if fileExists(marker) {
a.appLog("driver 記號檔存在,跳過自動安裝:%s", marker)
return nil
}
a.setBootstrapStatus("正在安裝 Kneron USB 驅動程式 (請在 UAC 視窗點「是」)...")
a.appLog("首次啟動:自動安裝 Kneron WinUSB driver會彈出 UAC 提權視窗,請點「是」)")
if err := installKneronWinUSBDriver(pyBin); err != nil {
a.appLog("driver 自動安裝失敗(非致命):%v", err)
return err
}
a.markDriverInstalled()
a.appLog("driver 自動安裝完成,記號檔已建立:%s", marker)
a.setBootstrapStatus("Kneron USB 驅動程式安裝完成")
return nil
}
// markDriverInstalled 寫 `.driver-installed` 記號檔,內容為時間戳供 debug。
func (a *App) markDriverInstalled() {
marker := filepath.Join(a.dataDir, ".driver-installed")
content := fmt.Sprintf("installed at %s\n", time.Now().Format(time.RFC3339))
_ = os.WriteFile(marker, []byte(content), 0o644)
}
// -----------------------------------------------------------------------
// Server 子行程管理
// -----------------------------------------------------------------------
func (a *App) startServer() error {
// 1. 決定 python runtime
a.setBootstrapStatus("正在初始化 Python 環境...")
pyBin, pyMode, err := a.ensurePythonRuntime(a.pythonMode)
if err != nil {
return fmt.Errorf("python runtime unavailable: %w", err)
}
a.mu.Lock()
a.pythonBin = pyBin
a.pythonModeR = pyMode
a.mu.Unlock()
// 1.5. 首次啟動自動安裝 Kneron WinUSB driverWindows onlymacOS/Linux no-op
// 失敗不擋 server 啟動 —— 使用者之後可手動點「安裝 USB Driver」按鈕重試。
// 用 .driver-installed 記號檔避免每次都跑。
if pyBin != "" {
if err := a.ensureDriverInstalled(pyBin); err != nil {
fmt.Fprintln(os.Stderr, "[visiona-local] driver auto-install failed (非致命,可於 UI 手動重試):", err)
}
}
a.setBootstrapStatus("正在啟動伺服器...")
// 2. 找 port
port, err := pickPort(defaultPreferredPort)
if err != nil {
return fmt.Errorf("no free port: %w", err)
}
// 3. 定位 server binary
binPath, err := locateServerBinary()
if err != nil {
return fmt.Errorf("server binary not found: %w", err)
}
// 4. 組參數
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)
}
// 5. 開 log 檔
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)
// 6. spawn
cmd := exec.Command(binPath, args...)
cmd.Dir = filepath.Dir(binPath)
configureSysProcAttr(cmd) // Windows: CREATE_NO_WINDOW 藏掉 server 小黑窗
// 注入 bundle bin dir 給 server 偵測 ffmpeg / ffprobeM6
env := os.Environ()
if binDir, err := locateBundleBinDir(); err == nil {
env = append(env, "VISIONA_BUNDLE_BIN_DIR="+binDir)
fmt.Fprintln(os.Stderr, "[visiona-local] bundle bin dir:", binDir)
}
// 注入 python interpreter 路徑給 serverkneron detector 會讀 VISIONA_PYTHON
// 避免 detector 自己去 resolve 又走不同的路徑邏輯造成不一致。
if pyBin != "" {
env = append(env, "VISIONA_PYTHON="+pyBin)
fmt.Fprintln(os.Stderr, "[visiona-local] python interpreter:", pyBin)
}
cmd.Env = env
if stdoutLog != nil {
cmd.Stdout = stdoutLog
} else {
cmd.Stdout = io.Discard
}
if stderrLog != nil {
cmd.Stderr = stderrLog
} else {
cmd.Stderr = io.Discard
}
if err := cmd.Start(); err != nil {
if stdoutLog != nil {
stdoutLog.Close()
}
if stderrLog != nil {
stderrLog.Close()
}
return fmt.Errorf("exec.Start: %w", err)
}
proc := &ServerProcess{
cmd: cmd,
port: port,
stdoutLog: stdoutLog,
stderrLog: stderrLog,
}
// 7. 等 health check成功後才寫 ipc-port避免把「預期 port」寫進檔案誤導
a.setBootstrapStatus("等待伺服器就緒...")
if err := waitHealthy(port, healthCheckTimeout); err != nil {
proc.kill()
removeIPCPort(a.dataDir)
return fmt.Errorf("server did not become healthy: %w", err)
}
// 8. 寫 ipc-port 檔(給 single-instance raise 用)—
// 此時 server 已確認在 listen寫下去的就是「實際可連線」的 port。
writeIPCPort(a.dataDir, port)
a.setBootstrapStatus("就緒,載入主介面...")
a.mu.Lock()
a.server = proc
a.mu.Unlock()
// 9. L-1啟動 server 健康偵測 goroutine
watchCtx, cancel := context.WithCancel(context.Background())
a.mu.Lock()
// 若先前已有 watch goroutine例如重啟先停掉
if a.watchCancel != nil {
a.watchCancel()
}
a.watchCancel = cancel
a.mu.Unlock()
go a.watchServer(watchCtx, proc)
return nil
}
// watchServer 每 10 秒打一次 /api/system/health連續 3 次失敗視為 server 崩潰。
// 崩潰後 emit Wails event 給前端type=server:dead並呼叫 reportFatal 結束程式。
// 從失敗中恢復時 emit server:recovered。
//
// L-1第一版不做 auto-restart直接交由 reportFatal 跳對話框 + 結束。
func (a *App) watchServer(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.ctx != nil {
wailsRuntime.EventsEmit(a.ctx, "server:dead", map[string]any{
"reason": "health check failed 3 times",
"port": sp.port,
})
}
a.reportFatal("server died", fmt.Errorf("health check failed 3 times in a row on port %d", sp.port))
return
}
}
}
}
func (a *App) stopServer() {
a.mu.Lock()
proc := a.server
a.server = nil
// 先停 watch goroutine避免它看到 server 不在還誤報
if a.watchCancel != nil {
a.watchCancel()
a.watchCancel = nil
}
a.mu.Unlock()
if proc == nil {
return
}
proc.stop()
}
// kill 直接強殺。
func (p *ServerProcess) kill() {
if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
_, _ = p.cmd.Process.Wait()
}
if p.stdoutLog != nil {
p.stdoutLog.Close()
}
if p.stderrLog != nil {
p.stderrLog.Close()
}
}
// stop 優雅關閉SIGTERM → 等 5s → SIGKILL。
func (p *ServerProcess) stop() {
if 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 error, 1)
go func() { done <- p.cmd.Wait() }()
select {
case <-done:
// graceful
case <-time.After(shutdownGracePeriod):
_ = p.cmd.Process.Kill()
<-done
}
if p.stdoutLog != nil {
p.stdoutLog.Close()
}
if p.stderrLog != nil {
p.stderrLog.Close()
}
}
// -----------------------------------------------------------------------
// Python Runtime 雙策略R4system 優先bundled 為 fallbackM1 實作 system
// -----------------------------------------------------------------------
// ensurePythonRuntime 依 mode 決定並回傳 python 可執行路徑與實際使用的 mode。
//
// M1 實作狀況:
// - PythonModeSystem完整實作findSystemPython
// - PythonModeAuto先試 system失敗才走 bundled
// - PythonModeBundledplaceholder回錯誤M2 才實作)
//
// R5-5a 之後python 失敗直接擋啟動(沒有模擬回退)。
func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) {
switch mode {
case PythonModeAuto:
if bin, err := a.findSystemPython(); err == nil {
return bin, PythonModeSystem, nil
}
if bin, err := a.ensureBundledPython(); err == nil {
return bin, PythonModeBundled, nil
}
return "", PythonModeAuto, fmt.Errorf("no python runtime available (tried system + bundled)")
case PythonModeSystem:
bin, err := a.findSystemPython()
return bin, PythonModeSystem, err
case PythonModeBundled:
bin, err := a.ensureBundledPython()
return bin, PythonModeBundled, err
}
return "", mode, fmt.Errorf("unknown python mode: %s", mode)
}
// findSystemPython 在 PATH 上尋找 python3>= 3.10)。
func (a *App) findSystemPython() (string, error) {
candidates := []string{"python3.12", "python3.11", "python3.10", "python3", "python"}
for _, name := range candidates {
p, err := exec.LookPath(name)
if err != nil {
continue
}
// Windows Store stub會彈 Store跳過
if runtime.GOOS == "windows" && strings.Contains(strings.ToLower(p), "windowsapps") {
continue
}
out, err := exec.Command(p, "--version").Output()
if err != nil {
continue
}
ver := strings.TrimSpace(string(out))
if !strings.Contains(ver, "Python 3") {
continue
}
// 粗略檢查 >= 3.10
if isPython310OrNewer(ver) {
return p, nil
}
}
return "", fmt.Errorf("no suitable python3 (>= 3.10) found on PATH")
}
// ensureBundledPython 解壓內嵌 python-build-standalone建 venv離線安裝 wheels。
// 第一次執行會花 30-60 秒,之後只要 venv 存在就直接重用。
//
// 內嵌資產位置locateBundledPythonAssets 會找):
// - macOS bundle<app>.app/Contents/Resources/python/python.tar.gz 與 Resources/wheels/*.whl
// - 開發模式:<cwd>/payload/darwin/python/python.tar.gz 與 payload/darwin/wheels
//
// 產出(使用者資料目錄 runtime/
// - runtime/python/ ← 解壓後的 python-build-standalone
// - runtime/venv/ ← 建立的 venv並已離線安裝 wheels
// - runtime/venv/bin/python3 ← 最終回傳的 interpreter path
func (a *App) ensureBundledPython() (string, error) {
pyTarball, wheelsDir, err := locateBundledPythonAssets()
if err != nil {
return "", err
}
runtimeDir := filepath.Join(a.dataDir, "runtime")
venvPath := filepath.Join(runtimeDir, "venv")
pyHome := filepath.Join(runtimeDir, "python")
pythonBin := filepath.Join(venvPath, "bin", "python3")
if runtime.GOOS == "windows" {
pythonBin = filepath.Join(venvPath, "Scripts", "python.exe")
}
// 已建立好就直接回傳(幂等)— 日常啟動走這條,不會暫停 hard timeout。
if _, err := os.Stat(pythonBin); err == nil {
return pythonBin, nil
}
// 首次 bootstrap 路徑:解壓 tarball + 建 venv + pip install 9 個 wheel
// (含 numpy / opencv / KneronPLUS 合計 ~150 MB乾淨環境可能 2-5 分鐘。
// 暫停 pipeline hard timeout避免 60 秒 R5-E1 budget 把使用者擋在 Error state。
// Soft timeout每階段 20 秒提示)繼續照常。
if a.startupPipeline != nil {
a.startupPipeline.PauseHardTimeout()
defer a.startupPipeline.ResumeHardTimeout()
}
if err := os.MkdirAll(pyHome, 0o755); err != nil {
return "", fmt.Errorf("mkdir python home: %w", err)
}
// 解壓 tarballstrip-components=1 剝掉 "python/" 前綴)
a.setBootstrapStatus("正在解壓 Python runtime (~10 秒)...")
extract := exec.Command("tar", "-xzf", pyTarball, "-C", pyHome, "--strip-components=1")
configureSysProcAttr(extract)
if out, err := extract.CombinedOutput(); err != nil {
return "", fmt.Errorf("extract python tarball: %w (%s)", err, string(out))
}
embeddedPython := filepath.Join(pyHome, "bin", "python3")
if runtime.GOOS == "windows" {
embeddedPython = filepath.Join(pyHome, "python.exe")
}
if _, err := os.Stat(embeddedPython); err != nil {
return "", fmt.Errorf("embedded python not found after extract: %w", err)
}
a.setBootstrapStatus("正在建立 Python 虛擬環境 (~5 秒)...")
venvCmd := exec.Command(embeddedPython, "-m", "venv", venvPath)
configureSysProcAttr(venvCmd)
if out, err := venvCmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("create venv: %w (%s)", err, string(out))
}
// 列舉 wheelsDir 下所有 .whl
var wheels []string
if entries, err := os.ReadDir(wheelsDir); err == nil {
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".whl") {
wheels = append(wheels, filepath.Join(wheelsDir, e.Name()))
}
}
}
if len(wheels) == 0 {
fmt.Fprintln(os.Stderr, "[visiona-local] WARN: no wheels found in", wheelsDir, "— venv 已建立但未安裝任何相依")
return pythonBin, nil
}
a.setBootstrapStatus(fmt.Sprintf("正在安裝 %d 個 Python 套件 (numpy / opencv / KneronPLUS ...) (~30-60 秒)...", len(wheels)))
args := []string{"-m", "pip", "install", "--no-index", "--find-links", wheelsDir, "--prefer-binary"}
args = append(args, wheels...)
pipCmd := exec.Command(pythonBin, args...)
configureSysProcAttr(pipCmd)
if out, err := pipCmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("pip install wheels: %w\n%s", err, string(out))
}
a.setBootstrapStatus("Python 環境就緒")
return pythonBin, nil
}
// locateBundledPythonAssets 找 python tarball 與 wheels 目錄。
// 順序macOS bundle → 開發模式 payload/darwin → 上一層。
func locateBundledPythonAssets() (tarball, wheelsDir string, err error) {
// 1. macOS .app bundleContents/Resources/python + Resources/wheels
if exe, e := os.Executable(); e == nil {
exeDir := filepath.Dir(exe)
if runtime.GOOS == "darwin" {
t := filepath.Join(exeDir, "..", "Resources", "python", "python.tar.gz")
w := filepath.Join(exeDir, "..", "Resources", "wheels")
if fileExists(t) && dirExists(w) {
return t, w, nil
}
}
// 同目錄 fallbackWindows / Linux 打包後)
t := filepath.Join(exeDir, "python", "python.tar.gz")
w := filepath.Join(exeDir, "wheels")
if fileExists(t) && dirExists(w) {
return t, w, nil
}
}
// 2. 開發模式 fallback依 GOOS 挑對應 payload 子目錄)
if cwd, e := os.Getwd(); e == nil {
osName := runtime.GOOS // darwin / windows / linux
candidates := []struct{ t, w string }{
{filepath.Join(cwd, "payload", osName, "python", "python.tar.gz"), filepath.Join(cwd, "payload", osName, "wheels")},
{filepath.Join(cwd, "..", "payload", osName, "python", "python.tar.gz"), filepath.Join(cwd, "..", "payload", osName, "wheels")},
{filepath.Join(cwd, "..", "..", "payload", osName, "python", "python.tar.gz"), filepath.Join(cwd, "..", "..", "payload", osName, "wheels")},
}
for _, c := range candidates {
if fileExists(c.t) && dirExists(c.w) {
return c.t, c.w, nil
}
}
}
return "", "", fmt.Errorf("bundled python assets not found (tried .app Resources + same-dir + payload/%s)", runtime.GOOS)
}
// locateBundleBinDir 找 bundle 內的 bin 目錄(含 ffmpeg / ffprobe / visiona-local-server
//
// 順序:
// 1. macOS .app bundleContents/Resources/bin
// 2. Windows / Linux 打包後:與 Wails 執行檔同目錄下的 bin/
// 3. 開發模式payload/darwin/bin相對 cwd
func locateBundleBinDir() (string, error) {
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
if runtime.GOOS == "darwin" {
d := filepath.Join(exeDir, "..", "Resources", "bin")
if dirExists(d) {
abs, _ := filepath.Abs(d)
return abs, nil
}
}
// 同目錄 bin/ fallbackWindows / Linux 打包後)
d := filepath.Join(exeDir, "bin")
if dirExists(d) {
abs, _ := filepath.Abs(d)
return abs, nil
}
// 或與執行檔同目錄server binary 本身就在這裡時)
if fileExists(filepath.Join(exeDir, "ffmpeg")) || fileExists(filepath.Join(exeDir, "ffprobe")) {
return exeDir, nil
}
}
// 開發模式 fallback依 GOOS 挑對應 payload 子目錄)
if cwd, err := os.Getwd(); err == nil {
osName := runtime.GOOS // darwin / windows / linux
candidates := []string{
filepath.Join(cwd, "payload", osName, "bin"),
filepath.Join(cwd, "..", "payload", osName, "bin"),
filepath.Join(cwd, "..", "..", "payload", osName, "bin"),
}
for _, c := range candidates {
if dirExists(c) {
abs, _ := filepath.Abs(c)
return abs, nil
}
}
}
return "", fmt.Errorf("bundle bin dir not found")
}
// locateBundleDataDir 找 installer 打包的 data 目錄(含 models.json + nef/ 預置模型)。
//
// 順序:
// 1. macOS .app bundleContents/Resources/data
// 2. Windows / Linux 打包後:與 Wails 執行檔同目錄下的 data/
// 3. 開發模式:<repo>/server/data相對 cwd或往上一層 / 兩層)
func locateBundleDataDir() (string, error) {
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
if runtime.GOOS == "darwin" {
d := filepath.Join(exeDir, "..", "Resources", "data")
if dirExists(d) {
abs, _ := filepath.Abs(d)
return abs, nil
}
}
// 同目錄 data/ fallbackWindows / Linux Inno Setup / AppImage 佈局)
d := filepath.Join(exeDir, "data")
if dirExists(d) {
abs, _ := filepath.Abs(d)
return abs, nil
}
}
// 開發模式 fallback<repo>/server/data
if cwd, err := os.Getwd(); err == nil {
candidates := []string{
filepath.Join(cwd, "server", "data"),
filepath.Join(cwd, "..", "server", "data"),
filepath.Join(cwd, "..", "..", "server", "data"),
}
for _, c := range candidates {
if dirExists(c) {
abs, _ := filepath.Abs(c)
return abs, nil
}
}
}
return "", fmt.Errorf("bundle data dir not found")
}
// seedUserDataDir 首次啟動時把 installer 內建的 data/ 內容models.json / nef/ / scripts/
// 複製到 user data-dir讓 server 能在 --data-dir=<user> 情境下讀到內建模型。
//
// 只複製缺失的檔案,不覆蓋使用者已有的資料。
//
// 複製清單(相對於 bundle data dir
// - models.json → 預置模型 metadata
// - nef/ → Kneron 預置 .nef 模型檔
func (a *App) seedUserDataDir() error {
bundleDataDir, err := locateBundleDataDir()
if err != nil {
return fmt.Errorf("locate bundle data dir: %w", err)
}
// 檢查 user data-dir 是否已經有 models.json表示已 seed 過)
userModelsJSON := filepath.Join(a.dataDir, "models.json")
if fileExists(userModelsJSON) {
return nil // 已 seed跳過
}
fmt.Fprintln(os.Stderr, "[visiona-local] first-run: seeding user data dir from", bundleDataDir)
// 1. 複製 models.json
srcJSON := filepath.Join(bundleDataDir, "models.json")
if fileExists(srcJSON) {
if err := copyFile(srcJSON, userModelsJSON); err != nil {
return fmt.Errorf("copy models.json: %w", err)
}
fmt.Fprintln(os.Stderr, "[visiona-local] + models.json")
} else {
fmt.Fprintln(os.Stderr, "[visiona-local] - bundle models.json not found at", srcJSON)
}
// 2. 遞迴複製 nef/ 預置模型目錄
srcNefDir := filepath.Join(bundleDataDir, "nef")
dstNefDir := filepath.Join(a.dataDir, "nef")
if dirExists(srcNefDir) {
if err := copyDirRecursive(srcNefDir, dstNefDir); err != nil {
return fmt.Errorf("copy nef dir: %w", err)
}
fmt.Fprintln(os.Stderr, "[visiona-local] + nef/")
}
return nil
}
// copyFile 複製單一檔案,若目標檔已存在則覆蓋。
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
// copyDirRecursive 遞迴複製目錄保留結構。已存在的檔案不覆蓋skip
func copyDirRecursive(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
if fileExists(target) {
return nil // 不覆蓋已存在的檔案
}
return copyFile(path, target)
})
}
func fileExists(p string) bool {
info, err := os.Stat(p)
return err == nil && !info.IsDir()
}
func dirExists(p string) bool {
info, err := os.Stat(p)
return err == nil && info.IsDir()
}
// isPython310OrNewer 粗略判斷 "Python 3.X.Y" 是否 >= 3.10。
func isPython310OrNewer(verStr string) bool {
// verStr 形如 "Python 3.12.4"
parts := strings.Fields(verStr)
if len(parts) < 2 {
return false
}
v := parts[1]
segs := strings.Split(v, ".")
if len(segs) < 2 {
return false
}
major, err1 := strconv.Atoi(segs[0])
minor, err2 := strconv.Atoi(segs[1])
if err1 != nil || err2 != nil {
return false
}
if major > 3 {
return true
}
return major == 3 && minor >= 10
}
// -----------------------------------------------------------------------
// Port picking
// -----------------------------------------------------------------------
// pickPort 從 preferred 開始往後找可用 port。
//
// L-4若 preferred port 被佔用,先嘗試清掉 stale 的 visiona-local-server
// 然後重試。清理失敗或佔用者不是我們就 fallback 到下一個 port。
func pickPort(preferred int) (int, error) {
if portAvailable(preferred) {
return preferred, nil
}
// preferred 被佔 → 先試著清掉 stale visiona-local-server
if killStaleServerOnPort(preferred) {
time.Sleep(1 * time.Second)
if portAvailable(preferred) {
return preferred, nil
}
}
// 仍不可用 → 往後找
for p := preferred + 1; p < preferred+portSearchRange; p++ {
if portAvailable(p) {
return p, nil
}
}
return 0, fmt.Errorf("no free port in range %d..%d", preferred, preferred+portSearchRange-1)
}
// killStaleServerOnPort 檢查 port 上 listen 的 process 是不是我們自己的
// visiona-local-server。若是送 SIGTERM / taskkill 清掉。
//
// macOS / Linux用 lsof + ps
// Windows用 netstat -ano + tasklist
func killStaleServerOnPort(port int) bool {
if runtime.GOOS == "windows" {
// netstat -ano | 找 :PORT LISTENING 取 PID
nsOut, err := exec.Command("netstat", "-ano").CombinedOutput()
if err != nil {
return false
}
target := fmt.Sprintf(":%d", port)
var pid int
for _, line := range strings.Split(string(nsOut), "\n") {
if !strings.Contains(line, target) || !strings.Contains(strings.ToUpper(line), "LISTENING") {
continue
}
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
if p, err := strconv.Atoi(fields[len(fields)-1]); err == nil {
pid = p
break
}
}
if pid == 0 {
return false
}
// tasklist /FI "PID eq <pid>" /FO CSV /NH → 確認 image name 是我們的 server
psOut, err := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/FO", "CSV", "/NH").CombinedOutput()
if err != nil {
return false
}
if !strings.Contains(strings.ToLower(string(psOut)), "visiona-local-server") {
return false
}
fmt.Fprintf(os.Stderr, "[visiona-local] killing stale visiona-local-server pid %d on port %d\n", pid, port)
_ = exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F").Run()
time.Sleep(500 * time.Millisecond)
return true
}
out, err := exec.Command("lsof", "-nPi", fmt.Sprintf(":%d", port), "-sTCP:LISTEN", "-Fp").CombinedOutput()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
if !strings.HasPrefix(line, "p") {
continue
}
pid, err := strconv.Atoi(strings.TrimPrefix(line, "p"))
if err != nil || pid <= 0 {
continue
}
// 取 process 的完整 command line-o args=)而非 comm後者只有 basename
// `go run` 編出來的是 `server` 或 `exe`,無法與 packaged binary 區分)。
// 我們接受兩種匹配:
// 1. 含 "visiona-local-server" 字串 — packaged binary 或 dev 直接 go build
// 2. 含 "/go-build" 路徑且 args 含 "visiona-local/server" — go run 編的 server
psOut, _ := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "args=").CombinedOutput()
psStr := string(psOut)
isVisionaServer := strings.Contains(psStr, "visiona-local-server")
isGoRunServer := strings.Contains(psStr, "/go-build") &&
(strings.Contains(psStr, "visiona-local/server") || strings.Contains(psStr, "/exe/server"))
if !isVisionaServer && !isGoRunServer {
continue
}
fmt.Fprintf(os.Stderr, "[visiona-local] killing stale visiona-local-server pid %d on port %d\n", pid, port)
_ = exec.Command("kill", "-TERM", strconv.Itoa(pid)).Run()
time.Sleep(500 * time.Millisecond)
return true
}
return false
}
func portAvailable(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return false
}
_ = ln.Close()
return true
}
// waitHealthy 輪詢 /api/system/health 直到 200 或 timeout。
func waitHealthy(port int, timeout time.Duration) error {
url := fmt.Sprintf("http://127.0.0.1:%d/api/system/health", port)
deadline := time.Now().Add(timeout)
client := &http.Client{Timeout: 1 * time.Second}
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
return nil
}
}
time.Sleep(300 * time.Millisecond)
}
return fmt.Errorf("health check timeout after %v", timeout)
}
// writeIPCPort 把目前 server port 寫到 dataDir 供 single-instance raise 使用。
func writeIPCPort(dataDir string, port int) {
path := filepath.Join(dataDir, "visiona-local.ipc-port")
_ = os.WriteFile(path, []byte(strconv.Itoa(port)), 0o644)
}
// removeIPCPort 在 server 啟動失敗時清除 ipc-port 檔,避免殘留錯誤資訊。
func removeIPCPort(dataDir string) {
_ = os.Remove(filepath.Join(dataDir, "visiona-local.ipc-port"))
}
// -----------------------------------------------------------------------
// Single-instance lock
// -----------------------------------------------------------------------
// errAnotherInstance 代表另一個 instance 正在執行(真的有活著的 PID 持有 lock
// 外層以 errors.Is 判斷,用以決定是否 exit(0) quietly。
var errAnotherInstance = fmt.Errorf("another instance is running")
// isAnotherInstanceError 判斷是否為「另一個 instance 在跑」的錯誤。
func isAnotherInstanceError(err error) bool {
if err == nil {
return false
}
// 用字串比對搭配 wrap 也可以,這裡採簡單的 Is 檢查
for e := err; e != nil; {
if e == errAnotherInstance {
return true
}
type unwrapper interface{ Unwrap() error }
u, ok := e.(unwrapper)
if !ok {
break
}
e = u.Unwrap()
}
return false
}
// acquireSingleInstance 嘗試取得檔案鎖,回傳 release 函式。
// 如果已有其他 instance 在跑PID 存活)→ 回錯errAnotherInstance
// 其他失敗IO/權限/目錄不見)→ 回原始錯誤。
func acquireSingleInstance(dataDir string) (func(), error) {
lockPath := filepath.Join(dataDir, "visiona-local.lock")
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err == nil {
fmt.Fprintf(f, "%d", os.Getpid())
f.Close()
return func() { os.Remove(lockPath) }, nil
}
if !os.IsExist(err) {
return nil, err
}
// 鎖存在 → 檢查 PID 是否活著
data, _ := os.ReadFile(lockPath)
pid, _ := strconv.Atoi(strings.TrimSpace(string(data)))
if pid > 0 && processAlive(pid) {
return nil, fmt.Errorf("%w (pid=%d)", errAnotherInstance, pid)
}
// stale lock → 清掉重取
_ = os.Remove(lockPath)
f, err = os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return nil, err
}
fmt.Fprintf(f, "%d", os.Getpid())
f.Close()
return func() { os.Remove(lockPath) }, nil
}
// processAlive 檢查 PID 是否仍活著。
func processAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
if runtime.GOOS == "windows" {
// Windows 的 FindProcess 幾乎一定成功,用 tasklist 判斷
out, _ := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid)).Output()
return strings.Contains(string(out), strconv.Itoa(pid))
}
// Unix: signal 0 測試
err = proc.Signal(syscall.Signal(0))
return err == nil
}
// tryRaiseExistingInstance 讀 Wails IPC port 並呼叫 /ipc/raise叫既有 instance
// 把主視窗提到前景。L-3從原本只打 server health 升級為真正的 raise IPC。
//
// fallback若 wails-ipc-port 檔不存在(舊版 instance退回打 server health
// 確認它活著,避免誤砍舊版鎖。
func tryRaiseExistingInstance(dataDir string) bool {
// 1. 優先讀 Wails IPC port
portFile := filepath.Join(dataDir, "visiona-local.wails-ipc-port")
if data, err := os.ReadFile(portFile); err == nil {
port, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err == nil && port > 0 {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/ipc/raise", port))
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == 200 {
return true
}
}
}
}
// 2. Fallback打 server health 確認舊 instance 還活著
serverPortFile := filepath.Join(dataDir, "visiona-local.ipc-port")
data, err := os.ReadFile(serverPortFile)
if err != nil {
return false
}
port := strings.TrimSpace(string(data))
client := &http.Client{Timeout: 1 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%s/api/system/health", port))
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == 200
}
// startIPCServer 啟動一個極小的 HTTP server 在 127.0.0.1 隨機 port
// 提供 /ipc/raise endpoint讓後來的 instance 可以把這個 instance 的主視窗
// 提到前景。L-3。
func (a *App) startIPCServer() error {
mux := http.NewServeMux()
mux.HandleFunc("/ipc/raise", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(os.Stderr, "[visiona-local] IPC raise received from another instance")
if a.ctx != nil {
wailsRuntime.WindowShow(a.ctx)
wailsRuntime.WindowUnminimise(a.ctx)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("listen: %w", err)
}
actualPort := listener.Addr().(*net.TCPAddr).Port
a.mu.Lock()
a.ipcPort = actualPort
a.ipcListener = listener
a.mu.Unlock()
// 寫 port 檔供後來的 instance 查找
portFile := filepath.Join(a.dataDir, "visiona-local.wails-ipc-port")
if err := os.WriteFile(portFile, []byte(strconv.Itoa(actualPort)), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "[visiona-local] WARN: write wails-ipc-port failed:", err)
}
go func() {
srv := &http.Server{Handler: mux}
_ = srv.Serve(listener) // Close() 時會回傳 ErrServerClosed忽略
}()
fmt.Fprintf(os.Stderr, "[visiona-local] IPC server listening on 127.0.0.1:%d\n", actualPort)
return nil
}
// removeWailsIPCPort 在 shutdown 時清除檔案。
func removeWailsIPCPort(dataDir string) {
if dataDir == "" {
return
}
_ = os.Remove(filepath.Join(dataDir, "visiona-local.wails-ipc-port"))
}
// -----------------------------------------------------------------------
// 舊資料目錄遷移
// -----------------------------------------------------------------------
// migrateOldDataDirs 把舊路徑 rename 到新路徑。失敗不擋啟動。
//
// 注意macOS 預設 APFS 為 case-insensitive`visionA-local` 與 `visiona-local`
// 在 FS 層會指向同一個 inode。若不做 inode 比對就貿然 `os.Remove(newDir)` +
// `os.Rename(old, newDir)`,會把唯一的實體目錄誤刪,導致後續所有操作 ENOENT。
// 因此在任何操作前都必須先確認 old 與 new 不是同一個 inode或相同的解析路徑
func migrateOldDataDirs(newDir string) {
newInfo, newErr := os.Stat(newDir)
newResolved, _ := resolvePath(newDir)
for _, old := range oldDataDirCandidates() {
oldInfo, err := os.Stat(old)
if err != nil {
continue
}
// 防呆 1若 old 與 new 是同一個實體路徑case-insensitive FS 上的大小寫差異
// 或 symlink完全跳過不做任何刪除 / rename。
if newErr == nil && os.SameFile(newInfo, oldInfo) {
fmt.Fprintf(os.Stderr,
"[visiona-local] 略過遷移 %s與 %s 為同一個實體目錄case-insensitive FS\n",
old, newDir)
continue
}
if oldResolved, err := resolvePath(old); err == nil && newResolved != "" && oldResolved == newResolved {
fmt.Fprintf(os.Stderr,
"[visiona-local] 略過遷移 %s解析後與新路徑指向同一個位置\n", old)
continue
}
// 新路徑已存在 → 不覆寫
if newErr == nil {
// 檢查是否為空資料夾(剛被 MkdirAll 建出來)
entries, _ := os.ReadDir(newDir)
if len(entries) > 0 {
fmt.Fprintf(os.Stderr,
"[visiona-local] 舊資料目錄 %s 仍存在,但新路徑 %s 已有內容,請手動清理\n",
old, newDir)
continue
}
// 空的 → 刪掉再 rename
if err := os.Remove(newDir); err != nil {
fmt.Fprintf(os.Stderr,
"[visiona-local] 無法清空新資料目錄 %s%v跳過遷移\n", newDir, err)
continue
}
}
if err := os.Rename(old, newDir); err != nil {
fmt.Fprintf(os.Stderr, "[visiona-local] 遷移 %s → %s 失敗:%v\n", old, newDir, err)
// 確保 newDir 仍存在(避免後續 lock 寫入失敗)
_ = os.MkdirAll(newDir, 0o755)
continue
}
breadcrumb := filepath.Join(newDir, ".migrated-from")
_ = os.WriteFile(breadcrumb,
[]byte(old+"\n"+time.Now().Format(time.RFC3339)+"\n"), 0o644)
fmt.Fprintf(os.Stderr, "[visiona-local] 已將 %s 遷移到 %s\n", old, newDir)
// rename 之後 newDir 已變成原先 old 的內容,重新 stat
newInfo, newErr = os.Stat(newDir)
newResolved, _ = resolvePath(newDir)
}
// 保險:不論發生什麼,最後都確保 newDir 存在
_ = os.MkdirAll(newDir, 0o755)
}
// resolvePath 回傳清理 + EvalSymlinks 後的絕對路徑,用於比對兩個路徑是否指向同一個位置。
// 若 EvalSymlinks 失敗(檔案不存在等),退回 Clean+Abs 結果。
func resolvePath(p string) (string, error) {
abs, err := filepath.Abs(p)
if err != nil {
return "", err
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return filepath.Clean(resolved), nil
}
return filepath.Clean(abs), nil
}
func oldDataDirCandidates() []string {
home, _ := os.UserHomeDir()
switch runtime.GOOS {
case "darwin":
return []string{
filepath.Join(home, ".visiona-local"),
filepath.Join(home, ".edge-ai-platform"),
filepath.Join(home, "Library", "Application Support", "visionA-local"),
}
case "windows":
appdata := os.Getenv("APPDATA")
local := os.Getenv("LOCALAPPDATA")
return []string{
filepath.Join(home, ".visiona-local"),
filepath.Join(appdata, "visionA-local"),
filepath.Join(local, "EdgeAIPlatform"),
}
case "linux":
return []string{
filepath.Join(home, ".visiona-local"),
filepath.Join(home, ".edge-ai-platform"),
filepath.Join(home, ".local", "share", "visionA-local"),
}
}
return nil
}
// -----------------------------------------------------------------------
// Server binary 定位
// -----------------------------------------------------------------------
// locateServerBinary 找 visiona-local-server 執行檔。
//
// 搜尋順序:
// 1. 與 Wails app 可執行檔同目錄下的 bin/Windows/Linux installer 佈局Inno Setup .iss 裝到 {app}\bin
// 2. 與 Wails app 可執行檔同目錄(開發/舊佈局)
// 3. macOS app bundle 內Contents/Resources/bin 或 Contents/Resources
// 4. 開發模式:<repo>/payload/<os>/bin/visiona-local-server
// 5. 開發模式:<repo>/dist/visiona-local-server
// 6. 開發模式:<repo>/server/visiona-local-server
func locateServerBinary() (string, error) {
binName := "visiona-local-server"
if runtime.GOOS == "windows" {
binName += ".exe"
}
candidates := []string{}
// 1-2. 打包後佈局installer 生成的目錄結構)
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
// {app}\bin\visiona-local-server.exe — Windows / Linux installer 標準佈局
candidates = append(candidates, filepath.Join(exeDir, "bin", binName))
// {app}\visiona-local-server.exe — exe 跟 server 同目錄的扁平佈局
candidates = append(candidates, filepath.Join(exeDir, binName))
// 3. macOS app bundle 佈局
if runtime.GOOS == "darwin" {
candidates = append(candidates,
filepath.Join(exeDir, "..", "Resources", "bin", binName),
filepath.Join(exeDir, "..", "Resources", binName),
filepath.Join(exeDir, "..", "..", "..", binName),
)
}
}
// 4-6. 開發模式
if cwd, err := os.Getwd(); err == nil {
osName := runtime.GOOS // darwin / windows / linux
candidates = append(candidates,
filepath.Join(cwd, "payload", osName, "bin", binName),
filepath.Join(cwd, "..", "payload", osName, "bin", binName),
filepath.Join(cwd, "dist", binName),
filepath.Join(cwd, "..", "dist", binName),
filepath.Join(cwd, "server", binName),
filepath.Join(cwd, "..", "server", binName),
)
}
for _, p := range candidates {
abs, err := filepath.Abs(p)
if err != nil {
continue
}
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
return abs, nil
}
}
return "", fmt.Errorf("visiona-local-server not found; searched: %v", candidates)
}
// -----------------------------------------------------------------------
// 工具PATH 補強GUI app 繼承的 PATH 常常很窮)
// -----------------------------------------------------------------------
func ensureGUIPath() {
extraDirs := []string{
"/usr/local/bin",
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/sbin",
}
if home, err := os.UserHomeDir(); err == nil {
extraDirs = append(extraDirs,
filepath.Join(home, ".local", "bin"),
filepath.Join(home, "bin"),
)
}
sep := ":"
if runtime.GOOS == "windows" {
sep = ";"
}
current := os.Getenv("PATH")
for _, d := range extraDirs {
if _, err := os.Stat(d); err == nil && !strings.Contains(current, d) {
current = current + sep + d
}
}
os.Setenv("PATH", current)
}
// openBrowser 用系統預設瀏覽器開啟 URL。
//
// 設計為 package-level var 是為了方便單元測試攔截呼叫次數
// M8-4b 補丁 M-2 的 regression test 需要驗證冷啟動時 openBrowser 只被呼叫一次)。
// 正式執行環境由 init預設值 openBrowserExec提供實作。
var openBrowser = openBrowserExec
// openBrowserExec 是 openBrowser 的實際系統呼叫實作。
func openBrowserExec(url string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url).Start()
case "windows":
return exec.Command("cmd", "/c", "start", url).Start()
default:
return exec.Command("xdg-open", url).Start()
}
}