兩個問題一次修:
1. Stage 順序亂跳 — 「Stage 1 等待中、Stage 2 完成、Stage 3 進行中」
根因:Wails Webview JS load 需 1-3 秒(Windows 乾淨環境更慢),這段
期間 Go 的 Pipeline.Start 已經 emit Stage 1 running event 甚至跑完
Stage 1 / Stage 2,但前端 EventsOn 還沒掛上去,events 全被丟掉。前端
接到的第一個 event 可能是 Stage 2 completed 或 Stage 3 running,
stages[1].status 仍是初始 pending 值,UI 顯示亂序。
修法:
- 新增 Go binding GetStartupSnapshot() 回傳 pipeline 當前所有 stages
狀態(含 current / startedAt / status)。
- 前端 init 流程在 subscribeEvents 後立即拉一次 snapshot,補上漏掉
的 stage 狀態。
- updateStage 加 monotonic 模式:snapshot 補漏時不會用較舊狀態覆蓋
已收到的較新狀態(避免 race condition 倒退)。
- status 優先級 STAGE_STATUS_RANK = pending<running<{skipped,failed}<completed
2. 進度條已等待秒數顯示錯誤 — 「進度 3 / 6 · 已等待 20 秒」
根因:pause 機制讓 elapsed 計算失準(pause 期間 wall clock 仍走但
stages[i].startedAt 沒重設,會顯示明顯比真實還久的數字)。使用者
覺得不需要顯示秒數。
修法:
- paintProgressBar 移除 elapsedText 邏輯,永遠顯示 progressLabel
- i18n 文案移除 {elapsed} placeholder(zh-TW + en):
stage.1.detail.seedSlow / stage.3.detail.waitHealth /
stage.3.detail.waitHealthSlow 都改為固定文案
- Go 端 emit 仍會傳 elapsed(waitProgress callback 不變),但前端
i18n template 不再用該變數,自然就不顯示
驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK
- Wails bindings 自動 regen 含 GetStartupSnapshot
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1803 lines
60 KiB
Go
1803 lines
60 KiB
Go
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 picking(3721 → 3722 → ...)
|
||
// 4. Python runtime 雙策略(auto / bundled / system)— M1 只有空殼
|
||
// 5. spawn visiona-local-server 子行程(含 graceful shutdown)
|
||
// 6. 提供前端 binding:GetServerURL、GetServerStatus、OpenBrowser
|
||
//
|
||
// **不在 M1 範圍**:實際下載/解壓 python-build-standalone(M2)、
|
||
// 內嵌 payload(M1-12 build packaging 才處理)、auto-update、relay、
|
||
// installer wizard、tray。這些原本 installer 的邏輯已被整份刪除。
|
||
// =====================================================================
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 常量 & 型別
|
||
// -----------------------------------------------------------------------
|
||
|
||
const (
|
||
defaultPreferredPort = 3721
|
||
portSearchRange = 20
|
||
// healthCheckTimeout:waitHealthy 等 server /api/system/health 200 的上限。
|
||
//
|
||
// 設 180 秒的理由:Windows 首次啟動時有多層串行延遲疊加:
|
||
// (1) Defender real-time scan 掃 visiona-local-server.exe(未簽章 + 首次
|
||
// 執行)可達 30-60 秒
|
||
// (2) 企業環境 EDR(CrowdStrike / SentinelOne / Carbon Black)再加一層
|
||
// cloud reputation lookup,可再延遲 20-60 秒
|
||
// (3) server 自己 init deps check + kneron bridge + gin router 也花幾秒
|
||
// 乾淨 Windows 環境總和常常超過 60 秒,180 秒涵蓋 99% 情境。日常啟動
|
||
// server 幾百毫秒就能回應,放寬上限不影響正常情境;配合 pipeline pause
|
||
// 機制也不會讓 soft/hard timeout 誤觸。
|
||
healthCheckTimeout = 180 * time.Second
|
||
shutdownGracePeriod = 5 * time.Second
|
||
appName = "visiona-local"
|
||
)
|
||
|
||
// PythonMode 決定 Python runtime 的選擇策略。
|
||
type PythonMode string
|
||
|
||
const (
|
||
PythonModeAuto PythonMode = "auto" // 先試 system,失敗才走 bundled(R4 決策: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 // 實際使用的 mode(auto resolved 之後)
|
||
lastError string
|
||
releaseLock func()
|
||
|
||
// M8-4:啟動進度訊息(v1 splash 殘留,v2 已由 startup-pipeline event 取代)。
|
||
// 目前仍保留供開發 log 使用,最終在 M8-4b 整個流程改寫後會被拿掉。
|
||
bootstrapStatus string
|
||
|
||
// L-1:server 健康偵測 goroutine 控制
|
||
watchCancel context.CancelFunc
|
||
|
||
// L-3:Wails 自己的 IPC server(收 /ipc/raise)
|
||
ipcPort int
|
||
ipcListener net.Listener
|
||
|
||
// M8-4:v2 新增欄位
|
||
ctrl *ServerController // state machine 控制器
|
||
logBuf *LogBuffer // ring buffer(2000 行)
|
||
prefs Preferences // 控制台偏好(in-memory,持久化至 preferences.json)
|
||
|
||
// M8-4b:6 階段啟動進度 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 1(init Wails console)→ seedUserDataDir 完成後 CompleteStage(1)
|
||
// stage 2(Python runtime) ┐
|
||
// stage 3(spawn server) ├─ 由 startServerV2 內部 hook
|
||
// stage 4(device probe) ┘
|
||
// stage 5(open browser) → ctrl.Start() return 後處理
|
||
// stage 6(wait 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: 9c9e005+ (180s hard timeout + all-stage sub-step detail + Stage1 seed pause)")
|
||
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 檔會寫到新路徑)
|
||
a.startupPipeline.EmitStageDetail(1, "startup.stage.1.detail.migrate", 0)
|
||
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
|
||
a.startupPipeline.EmitStageDetail(1, "startup.stage.1.detail.lock", 0)
|
||
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 server(L-3)
|
||
// 供後來的 instance 透過 /ipc/raise 把現有視窗提到前景。
|
||
// 失敗不擋啟動,只是犧牲 single-instance raise 能力。
|
||
a.startupPipeline.EmitStageDetail(1, "startup.stage.1.detail.ipc", 0)
|
||
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 啟動後模型庫會是空的。
|
||
//
|
||
// Pause hard timeout:seedUserDataDir 在 Windows 乾淨環境首次跑會被
|
||
// Defender real-time scan 對 8 個 nef 檔(每個 7-10 MB)逐個掃描,總時
|
||
// 5-30 秒。屬於一次性 bootstrap,不該算進 pipeline 180 秒 budget。
|
||
// 第二次啟動以後 fileExists(userModelsJSON) 為 true,seedUserDataDir 早
|
||
// early return,不會 pause(不影響日常啟動)。
|
||
a.setBootstrapStatus("正在準備應用程式資料...")
|
||
a.startupPipeline.EmitStageDetail(1, "startup.stage.1.detail.seed", 0)
|
||
a.startupPipeline.PauseHardTimeout()
|
||
// 開背景 ticker,每 5 秒 emit slow hint 帶 elapsed 時間,避免使用者
|
||
// 看到 spinner 不動以為 seed 卡住。Goroutine 在 seedUserDataDir return
|
||
// 後透過 close(seedDone) 退出。
|
||
seedStart := time.Now()
|
||
seedDone := make(chan struct{})
|
||
go func() {
|
||
ticker := time.NewTicker(5 * time.Second)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-seedDone:
|
||
return
|
||
case <-ticker.C:
|
||
elapsed := int(time.Since(seedStart).Seconds())
|
||
a.startupPipeline.EmitStageDetail(1, "startup.stage.1.detail.seedSlow", elapsed)
|
||
}
|
||
}
|
||
}()
|
||
if err := a.seedUserDataDir(); err != nil {
|
||
a.appLog("seed user data dir failed: %v", err)
|
||
}
|
||
close(seedDone)
|
||
a.startupPipeline.ResumeHardTimeout()
|
||
|
||
// 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 fallback(StartWithPort(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
|
||
}
|
||
a.startupPipeline.EmitStageDetail(5, "startup.stage.5.detail.open", 0)
|
||
// 取得 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 {
|
||
a.appLog("startup stage 5: open browser failed: %v", err)
|
||
}
|
||
}
|
||
a.startupPipeline.CompleteStage(5)
|
||
// Stage 6 開始等 WebSocket,emit detail
|
||
a.startupPipeline.EmitStageDetail(6, "startup.stage.6.detail.wait", 0)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// GetStartupSnapshot 回傳 pipeline 當前所有 stages 狀態。
|
||
// 前端 init 完成後呼叫一次,補上 Wails Webview JS load 完成前 Go 端已 emit
|
||
// 但被丟掉的 progress events,避免畫面顯示「Stage 1 等待中、Stage 2 完成」
|
||
// 這種亂序。pipeline 還沒建立時回傳空 snapshot(current=0 stages=[])。
|
||
func (a *App) GetStartupSnapshot() StartupSnapshot {
|
||
if a.startupPipeline == nil {
|
||
return StartupSnapshot{
|
||
Current: 0,
|
||
TotalStages: 6,
|
||
Stages: []StartupStageSnapshot{},
|
||
}
|
||
}
|
||
return a.startupPipeline.Snapshot()
|
||
}
|
||
|
||
// 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 build,os.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 driver(Windows only,macOS/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 / ffprobe(M6)
|
||
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 路徑給 server(kneron 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, nil); 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 雙策略(R4:system 優先,bundled 為 fallback,M1 實作 system)
|
||
// -----------------------------------------------------------------------
|
||
|
||
// ensurePythonRuntime 依 mode 決定並回傳 python 可執行路徑與實際使用的 mode。
|
||
//
|
||
// M1 實作狀況:
|
||
// - PythonModeSystem:完整實作(findSystemPython)
|
||
// - PythonModeAuto:先試 system,失敗才走 bundled
|
||
// - PythonModeBundled:placeholder,回錯誤(M2 才實作)
|
||
//
|
||
// R5-5a 之後:python 失敗直接擋啟動(沒有模擬回退)。
|
||
func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) {
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.EmitStageDetail(2, "startup.stage.2.detail.detect", 0)
|
||
}
|
||
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,避免 180 秒 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)
|
||
}
|
||
|
||
// 解壓 tarball(strip-components=1 剝掉 "python/" 前綴)
|
||
a.setBootstrapStatus("正在解壓 Python runtime (~10 秒)...")
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.EmitStageDetail(2, "startup.stage.2.detail.bootstrap", 0)
|
||
}
|
||
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 秒)...")
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.EmitStageDetail(2, "startup.stage.2.detail.venv", 0)
|
||
}
|
||
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)))
|
||
if a.startupPipeline != nil {
|
||
a.startupPipeline.EmitStageDetail(2, "startup.stage.2.detail.pip", 0)
|
||
}
|
||
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 bundle:Contents/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
|
||
}
|
||
}
|
||
// 同目錄 fallback(Windows / 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 bundle:Contents/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/ fallback(Windows / 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 bundle:Contents/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/ fallback(Windows / 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。
|
||
// 若 progress 非 nil,每 5 秒會呼叫一次 progress(elapsedSeconds),讓呼叫者
|
||
// 能對應 emit 「已等待 N 秒」的 UI 文案。
|
||
func waitHealthy(port int, timeout time.Duration, progress func(elapsedSeconds int)) error {
|
||
url := fmt.Sprintf("http://127.0.0.1:%d/api/system/health", port)
|
||
started := time.Now()
|
||
deadline := started.Add(timeout)
|
||
client := &http.Client{Timeout: 1 * time.Second}
|
||
lastProgressTick := started
|
||
for time.Now().Before(deadline) {
|
||
resp, err := client.Get(url)
|
||
if err == nil {
|
||
resp.Body.Close()
|
||
if resp.StatusCode == 200 {
|
||
return nil
|
||
}
|
||
}
|
||
// 每 5 秒推一次進度到 UI
|
||
if progress != nil && time.Since(lastProgressTick) >= 5*time.Second {
|
||
elapsed := int(time.Since(started).Seconds())
|
||
progress(elapsed)
|
||
lastProgressTick = time.Now()
|
||
}
|
||
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()
|
||
}
|
||
}
|