jim800121chen 4f316bb3b8 fix: wheels 目錄累積多版本導致 pip 解析失敗,app 啟動失敗
Windows 啟動卡在階段 2,pip 在 2 秒內失敗:

    ERROR: Cannot install certifi 2026.2.25 and certifi 2026.6.17
           because these package versions have conflicting dependencies.
    ERROR: ResolutionImpossible

根因:Makefile 的 vendor-wheels* 用 pip download --dest 但從不清空目錄,
payload-* 複製時也只疊上去不清。upstream 每發一次新版就多留一顆,於是
`pip install *.whl` 同時收到同一套件的多個版本,相依解析器立刻放棄。

vendor/wheels/darwin 目前 16 顆 = 9 個套件 + 7 顆重複舊版,與 log 的
「正在安裝 16 個 Python 套件」完全吻合。

三層修正:

1. ensurePythonRuntime 的 Auto 分支不再丟棄 bundled/system 的失敗原因。
   這是使用者連續三輪拿不到線索的原因 —— 它不是根因,是放大器。
   pip 的完整輸出本來就在 error 裡,只是被這裡擋掉。

2. selectLatestWheelPerPackage:送 pip 前每個套件只留最新版。這層才救得了
   已經出貨的安裝包,只修 Makefile 對使用者手上那包無效。套件名依 PEP 503
   正規化,版本用整數逐段比(避免 2.9 > 2.10 的字典序錯誤)。

3. Makefile 新增 clean_wheels_dir(沿用 copy_bundled_data 的路徑防護),
   vendor-wheels* 先清再下載、payload-* 複製前先清,從源頭杜絕。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:55:42 +08:00

2241 lines
79 KiB
Go
Raw Permalink 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"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"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 的上限。
//
// 設 180 秒的理由Windows 首次啟動時有多層串行延遲疊加:
// (1) Defender real-time scan 掃 visiona-local-server.exe未簽章 + 首次
// 執行)可達 30-60 秒
// (2) 企業環境 EDRCrowdStrike / 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" // 先試 bundledkp wheel 已預裝),失敗才 fallback system
PythonModeBundled PythonMode = "bundled" // 策略 A內嵌 python-build-standalone
PythonModeSystem PythonMode = "system" // 策略 B系統 python3需使用者自行 pip install
)
// 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
// pythonDepsWarning 記錄「Python 相依健康檢查沒過、自動修復也沒成功」。
//
// 刻意做成 warning 而非 error這個檢查會誤判見 ensureBundledPython 註解),
// 擋下啟動的代價遠大於放行。所以照常啟動、把訊息留在這裡讓使用者在 app 內
// 看得到,而不是把人擋在啟動畫面外。
pythonDepsWarning 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
// M9-4.5firmware-aware close guardTDD §8.6.2)。
// 韌體升降版進行中時擋住 OnBeforeClose、emit event 給前端攔截 modal。
// 由 NewApp 初始化、shutdown 後保留thread-safe via 自身 mu
firmwareCloseGuard *FirmwareCloseGuard
}
// 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,
firmwareCloseGuard: NewFirmwareCloseGuard(),
}
}
// -----------------------------------------------------------------------
// 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: d946561+ (5min hard timeout + 5-stage UI + fullscreen splash)")
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 serverL-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 timeoutseedUserDataDir 在 Windows 乾淨環境首次跑會被
// Defender real-time scan 對 8 個 nef 檔(每個 7-10 MB逐個掃描總時
// 5-30 秒。屬於一次性 bootstrap不該算進 pipeline 180 秒 budget。
// 第二次啟動以後 fileExists(userModelsJSON) 為 trueseedUserDataDir 早
// 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 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
}
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 開始等 WebSocketemit 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 還沒建立時回傳空 snapshotcurrent=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)
}
// GetPythonDepsWarning 回傳 Python 相依健康檢查的警告訊息(空字串 = 沒問題)。
//
// 前端可在控制台顯示這則訊息。它**不代表啟動失敗** —— 有這則訊息時 app 仍
// 正常啟動,只是提醒使用者若推論 / 裝置掃描異常時該怎麼自救。
func (a *App) GetPythonDepsWarning() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.pythonDepsWarning
}
// setPythonDepsWarning 記錄相依修復失敗的訊息(不阻斷啟動)。
func (a *App) setPythonDepsWarning(msg string) {
a.mu.Lock()
a.pythonDepsWarning = msg
a.mu.Unlock()
a.appLog("WARN python-deps: %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, 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 雙策略R4system 優先bundled 為 fallbackM1 實作 system
// -----------------------------------------------------------------------
// ensurePythonRuntime 依 mode 決定並回傳 python 可執行路徑與實際使用的 mode。
//
// M1 實作狀況:
// - PythonModeSystem完整實作findSystemPython
// - PythonModeAuto先試 bundled含預裝 kp wheel失敗才 fallback system
// - PythonModeBundled完整實作ensureBundledPython
//
// R5-5a 之後python 失敗直接擋啟動(沒有模擬回退)。
//
// 為什麼不先 systemLocal-tool 的架構是整包內嵌 Python + KneronPLUS wheel
// 使用者系統 python 絕大多數沒裝 kp先 system 會拿到可執行但「import kp 失敗」
// 的解譯器,導致 detector scan 空、表面卻看似啟動成功。
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:
// ⚠️ 兩條失敗原因都必須保留到最終 error。
//
// 事故背景:舊版把 bundled / system 的 err 直接丟棄,只回一句通用的
// "no python runtime available"。實機上 pip 因為 wheels 目錄有同一套件
// 多版本而 2 秒內 ResolutionImpossible但使用者與我們在 log 裡永遠
// 只看得到那句通用訊息,連續三輪都無法定位真因。
//
// 失敗原因是這條路徑上唯一能讓使用者自救的資訊,不可以吞。
bundledBin, bundledErr := a.ensureBundledPython()
if bundledErr == nil {
return bundledBin, PythonModeBundled, nil
}
fmt.Fprintf(os.Stderr, "[visiona-local] bundled python 失敗,改試 system python%v\n", bundledErr)
systemBin, systemErr := a.findSystemPython()
if systemErr == nil {
return systemBin, PythonModeSystem, nil
}
fmt.Fprintf(os.Stderr, "[visiona-local] system python 也失敗:%v\n", systemErr)
// %w 包 bundled主要路徑、也是幾乎所有實機失敗的來源system 以文字附上。
// errors.Is/As 對 bundled 鏈可用,使用者則兩個原因都看得到。
return "", PythonModeAuto, fmt.Errorf(
"no python runtime available (tried bundled + system)\n bundled 失敗:%w\n system 失敗:%v",
bundledErr, systemErr)
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。
//
// ⚠️ 「python 執行檔存在」不等於「相依裝好了」pip install 中途失敗
// (斷網 / 磁碟滿 / 防毒攔截)會留下 venv 與 python.exe 都在、但 `import kp`
// 失敗的半套環境。所以這裡會順手做一次健康檢查並嘗試修復。
//
// ⚠️⚠️ 但健康檢查**絕不可以成為啟動的阻斷點**。理由:
//
// 1. 這個檢查會誤判。probe 跑的是裸的 `python -c "import kp"`,而 kp 在
// Windows 要先把 site-packages\kp\lib 加進 PATH / add_dll_directory
// 才載得到 native DLL見 platform_windows.go 的 driver 安裝腳本,以及
// kl720_driver.go startPython() 為子行程注入的 PATH。真正跑推論的
// bridge 有這些設定、我們的 probe 沒有 → 環境健康也會被判成壞掉。
//
// 2. 誤判的代價不對稱。放行的最壞情況是使用者進到 app 後看到「detector
// 掃不到裝置」這類具體錯誤kneron_bridge.py 本來就把 import kp 當
// optional失敗只設 HAS_KP=False擋下的最壞情況是 app 完全打不開、
// 使用者連錯誤細節都看不到,比原本的 bug 更糟。
//
// 所以:盡力修,修不好也放行,讓後續流程去報真正的錯。
if _, err := os.Stat(pythonBin); err == nil {
return a.reuseExistingVenv(runtimeDir, pythonBin, wheelsDir), 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)
}
// 解壓 tarballstrip-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))
}
// 首次安裝pip 真的失敗才算致命(沒有 venv 可用、後面一定跑不動)。
// 但「pip 成功、只是 import 驗證沒過」不擋啟動 —— 那個 probe 會誤判。
if err := a.installBundledWheels(runtimeDir, pythonBin, wheelsDir); err != nil {
if errors.Is(err, errPythonDepsProbeFailed) {
a.setPythonDepsWarning(fmt.Sprintf(
"Python 套件已安裝,但載入驗證未通過:%v\n"+
"應用程式仍會啟動。若推論或裝置掃描異常,請關閉應用程式後刪除 %s 再重新啟動。",
err, runtimeDir))
fmt.Fprintf(os.Stderr,
"[visiona-local] WARN: wheels 已裝但 import 驗證未過,仍繼續啟動:%v\n", err)
return pythonBin, nil
}
return "", err
}
a.setBootstrapStatus("Python 環境就緒")
return pythonBin, nil
}
// reuseExistingVenv 處理「venv 已存在」的日常啟動路徑:驗健康、必要時嘗試修復,
// 然後**無論如何都回傳 pythonBin**。
//
// 這個函式沒有 error 回傳值,是刻意的設計 —— 型別本身就保證了「健康檢查不會
// 變成啟動的阻斷點」。要退化成阻斷版本必須改簽章,不會不小心發生。
//
// 修不好時把原因寫進 pythonDepsWarning讓使用者在 app 內看得到自救步驟,
// 而不是被擋在啟動畫面外面(那比原本的 bug 更糟)。
func (a *App) reuseExistingVenv(runtimeDir, pythonBin, wheelsDir string) string {
if a.bundledPythonDepsHealthy(runtimeDir, pythonBin, wheelsDir) {
return pythonBin
}
// venv 在但相依看起來壞了 → 只補裝 wheels不整個重建 venv。
// 重建 venv 要重新解壓 ~100MB tarball而失敗幾乎都出在 pip 階段。
fmt.Fprintln(os.Stderr, "[visiona-local] venv 存在但 Python 相依驗證未通過,嘗試重新安裝 wheels")
if err := a.installBundledWheels(runtimeDir, pythonBin, wheelsDir); err != nil {
a.setPythonDepsWarning(fmt.Sprintf(
"Python 相依自動修復未成功:%v\n"+
"應用程式仍會啟動。若推論或裝置掃描異常,請關閉應用程式後刪除 %s 再重新啟動。",
err, runtimeDir))
fmt.Fprintf(os.Stderr,
"[visiona-local] WARN: wheels 重裝失敗,改以現有 venv 繼續啟動:%v\n", err)
}
return pythonBin
}
// venvReadyMarkerName 是「wheels 已成功裝完」的標記檔名(放在 runtimeDir 下)。
//
// 存在此標記 = 上一次 installBundledWheels 完整跑完且 pip 回 0。內容是當時
// 安裝的 wheel 檔名清單wheels 換版(升級 KneronPLUS 等)時內容不符會觸發重裝。
const venvReadyMarkerName = "venv-ready.txt"
// venvHealthProbeModules 是啟動時要驗證的關鍵模組。
//
// 只放「少了就完全跑不動」的三個kpKneron SDK使用者實際踩到的就是這個
// numpy、cv2。不放次要相依 —— 這是日常啟動的 hot path每多一個 import 都是成本。
var venvHealthProbeModules = []string{"kp", "numpy", "cv2"}
// bundledPythonDepsHealthy 檢查 venv 裡的關鍵相依是否真的可用。
//
// 成本設計(這條路徑每次啟動都會走):
// - 快路徑:只讀一個標記檔(~µs。標記存在且與當前 wheels 清單相符 → 直接放行,
// 不啟動任何 process。絕大多數啟動走這條。
// - 慢路徑:標記不存在 / 內容不符(首次升級、或舊版留下的 venv才真的跑一次
// python -c "import kp, numpy, cv2"~200-400ms僅此一次成功後補寫標記
// 下次啟動即回到快路徑。
//
// 為什麼不是每次都跑 importimport cv2 + numpy 要數百毫秒,乘上每次啟動並不划算,
// 而「裝好之後又壞掉」需要外力介入(使用者手動刪檔案),不是常態。
func (a *App) bundledPythonDepsHealthy(runtimeDir, pythonBin, wheelsDir string) bool {
want := bundledWheelsFingerprint(wheelsDir)
markerPath := filepath.Join(runtimeDir, venvReadyMarkerName)
// 快路徑:標記與當前 wheels 相符 → 視為健康,不啟動 process。
if got, err := os.ReadFile(markerPath); err == nil {
if strings.TrimSpace(string(got)) == want {
return true
}
fmt.Fprintln(os.Stderr, "[visiona-local] venv 標記與內建 wheels 不符,重新驗證 Python 相依")
}
// 慢路徑:實際 import 一次確認。
if err := probePythonModules(pythonBin, venvHealthProbeModules); err != nil {
fmt.Fprintf(os.Stderr, "[visiona-local] Python 相依驗證失敗:%v\n", err)
return false
}
// 驗證通過(例如舊版留下的健康 venv→ 補標記,下次走快路徑。
if err := os.WriteFile(markerPath, []byte(want), 0o644); err != nil {
// 寫不了標記不影響正確性,只是下次還要再驗一次。
fmt.Fprintf(os.Stderr, "[visiona-local] WARN: 寫入 venv 標記失敗(不影響運作):%v\n", err)
}
return true
}
// probePythonModules 跑一次 `python -c "import <mods>"`,確認相依真的可用。
//
// ⚠️ 這個 probe 的環境必須盡量貼近「真正跑 bridge 的環境」,否則會誤判。
// kp 在 import 時就會 ctypes.CDLL 載入 kp/lib 下的 native DLLlibkplus /
// libusb-1.0 / libwdi + MinGW runtime。Windows 上這些 DLL 不在預設搜尋路徑,
// 必須先 add_dll_directory —— kl720_driver.go 的 startPython() 與
// platform_windows.go 的 driver 安裝腳本都有做這件事。probe 若不做,
// 健康的環境也會 import 失敗。所以這裡用一小段 preamble 補上。
func probePythonModules(pythonBin string, modules []string) error {
if len(modules) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, pythonBin, "-c", pythonProbeScript(modules))
configureSysProcAttr(cmd)
out, err := cmd.CombinedOutput()
if err != nil {
if ctx.Err() != nil {
return fmt.Errorf("import %s 逾時:%w", strings.Join(modules, ", "), ctx.Err())
}
return fmt.Errorf("import %s 失敗:%w\n%s",
strings.Join(modules, ", "), err, strings.TrimSpace(string(out)))
}
return nil
}
// pythonProbeScript 產生 probe 用的 python 程式碼。
//
// 先把 site-packages/*/libkp 的 native DLL 目錄)掛進 DLL 搜尋路徑再 import
// 對齊 bridge 實際執行時的環境。找不到那些目錄就單純 import行為與舊版一致。
func pythonProbeScript(modules []string) string {
return `
import os, sys, sysconfig
try:
sp = sysconfig.get_paths().get('purelib') or ''
if sp and os.path.isdir(sp):
for pkg in os.listdir(sp):
libdir = os.path.join(sp, pkg, 'lib')
if not os.path.isdir(libdir):
continue
os.environ['PATH'] = libdir + os.pathsep + os.environ.get('PATH', '')
if hasattr(os, 'add_dll_directory'):
try:
os.add_dll_directory(libdir)
except OSError:
pass
except Exception:
pass
import ` + strings.Join(modules, ", ") + "\n"
}
// bundledWheelsFingerprint 產生 wheels 目錄的指紋(排序後的檔名清單)。
//
// 用檔名而非內容雜湊wheel 檔名本來就帶版本號KneronPLUS-3.1.2-...whl
// 升級一定換檔名;而讀 ~150MB 內容算雜湊在啟動路徑上太貴。
func bundledWheelsFingerprint(wheelsDir string) string {
names := listBundledWheelNames(wheelsDir)
sort.Strings(names)
return strings.Join(names, "\n")
}
// listBundledWheelNames 列出 wheelsDir 下所有 .whl 的檔名(不含路徑)。
func listBundledWheelNames(wheelsDir string) []string {
var names []string
entries, err := os.ReadDir(wheelsDir)
if err != nil {
return names
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".whl") {
names = append(names, e.Name())
}
}
return names
}
// droppedWheel 記錄一顆因為同套件有更新版而被略過的 wheel僅供 log
type droppedWheel struct {
skipped string // 被略過的檔名
chosen string // 同套件實際採用的檔名
}
// parseWheelName 從 wheel 檔名取出 distribution 名稱與版本字串。
//
// PEP 427 檔名格式:{distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl
// 前兩段固定是名稱與版本,所以只需切前兩個 "-" 即可,不必完整解析 tag。
//
// distribution 依 PEP 503 正規化(底線/點/連字號 → 連字號、轉小寫),讓
// `opencv_python_headless` 與 `opencv-python-headless` 視為同一套件。
func parseWheelName(filename string) (dist, version string, ok bool) {
base := strings.TrimSuffix(filename, ".whl")
parts := strings.Split(base, "-")
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
dist = normalizeDistName(parts[0])
return dist, parts[1], true
}
// normalizeDistName 依 PEP 503 正規化套件名稱(連續的 -_. 收斂成單一 -、小寫)。
func normalizeDistName(name string) string {
var b strings.Builder
prevSep := false
for _, r := range strings.ToLower(name) {
if r == '-' || r == '_' || r == '.' {
if !prevSep {
b.WriteRune('-')
prevSep = true
}
continue
}
b.WriteRune(r)
prevSep = false
}
return strings.Trim(b.String(), "-")
}
// compareWheelVersions 比較兩個版本字串,回傳 -1 / 0 / 1。
//
// 這裡刻意只做「數字段落逐段比大小」的簡化版 PEP 440不支援 pre-release
// 1.0rc1 < 1.0)等完整語意。理由:我們只需要在「同一套件的多個 vendored
// wheel」之間挑最新而那些都是 pip download 抓下來的正式版2026.2.25 /
// 2026.6.17 這種),不會有 rc。完整實作 PEP 440 需要引入相依,不值得。
//
// 數字段落用整數比較而非字串比較 —— 字串比較會把 "10" 排在 "9" 前面。
// 非數字段落(如 "post1"、"dev")退回字串比較,保證結果穩定可預期。
func compareWheelVersions(a, b string) int {
as := strings.Split(a, ".")
bs := strings.Split(b, ".")
for i := 0; i < len(as) || i < len(bs); i++ {
var av, bv string
if i < len(as) {
av = as[i]
}
if i < len(bs) {
bv = bs[i]
}
an, aErr := strconv.Atoi(av)
bn, bErr := strconv.Atoi(bv)
if aErr == nil && bErr == nil {
if an != bn {
if an < bn {
return -1
}
return 1
}
continue
}
if av != bv {
if av < bv {
return -1
}
return 1
}
}
return 0
}
// selectLatestWheelPerPackage 對每個套件只保留版本最高的一顆 wheel回傳完整路徑。
//
// 為什麼需要:見 installBundledWheels 內的說明 —— vendor/wheels/<os> 會累積同一
// 套件的多個版本,一起丟給 pip 會 ResolutionImpossible 而秒退。
//
// 無法解析檔名的 wheel 一律保留(寧可交給 pip 判斷,也不要靜默丟掉使用者的相依)。
// 回傳順序依檔名排序,確保 pip 參數穩定、可測。
func selectLatestWheelPerPackage(wheelsDir string, names []string) (paths []string, dropped []droppedWheel) {
best := make(map[string]string, len(names)) // dist → 目前最新的檔名
var keep []string // 無法解析、直接保留的檔名
sorted := append([]string(nil), names...)
sort.Strings(sorted)
for _, n := range sorted {
dist, ver, ok := parseWheelName(n)
if !ok {
keep = append(keep, n)
continue
}
cur, seen := best[dist]
if !seen {
best[dist] = n
continue
}
_, curVer, _ := parseWheelName(cur)
if compareWheelVersions(ver, curVer) > 0 {
best[dist] = n
dropped = append(dropped, droppedWheel{skipped: cur, chosen: n})
} else {
dropped = append(dropped, droppedWheel{skipped: n, chosen: cur})
}
}
chosen := make([]string, 0, len(best)+len(keep))
for _, n := range best {
chosen = append(chosen, n)
}
chosen = append(chosen, keep...)
sort.Strings(chosen)
paths = make([]string, 0, len(chosen))
for _, n := range chosen {
paths = append(paths, filepath.Join(wheelsDir, n))
}
sort.Slice(dropped, func(i, j int) bool { return dropped[i].skipped < dropped[j].skipped })
return paths, dropped
}
// errPythonDepsProbeFailed 標示「pip 安裝成功、但事後 import 驗證沒過」。
//
// 與「pip 本身失敗」分開的原因pip 失敗代表相依真的沒裝上致命probe 失敗
// 則可能只是 probe 環境不足Windows 缺 kp/lib 的 PATH / add_dll_directory
// 呼叫端要能分辨並選擇放行。用 errors.Is 判斷。
var errPythonDepsProbeFailed = errors.New("python deps probe failed")
// installBundledWheels 離線安裝 wheelsDir 下的所有 wheel成功後寫入就緒標記。
//
// 標記只在 pip 回 0 **且** import 驗證通過後才寫 —— 半套安裝不可留下「已就緒」
// 的假象,那正是舊版讓使用者永久卡住的成因。驗證沒過時回 errPythonDepsProbeFailed
// 由呼叫端決定要不要放行(目前一律放行,只留 warning
func (a *App) installBundledWheels(runtimeDir, pythonBin, wheelsDir string) error {
names := listBundledWheelNames(wheelsDir)
if len(names) == 0 {
fmt.Fprintln(os.Stderr, "[visiona-local] WARN: no wheels found in", wheelsDir, "— venv 已建立但未安裝任何相依")
return nil
}
// ⚠️ 同一套件只能挑一個版本送給 pip。
//
// 事故背景:`make vendor-wheels*` 用 `pip download --dest vendor/wheels/<os>`
// 而該目錄不會被清空 —— 每次 upstream 出新版就多留一顆 whl舊的仍在。實機上
// vendor/wheels/darwin 已累積成 certifi ×3、numpy ×2 …共 16 顆。
//
// 把重複版本一起丟給 `pip install a.whl b.whl` 會直接 ResolutionImpossible
// "Cannot install certifi 2026.2.25 and certifi 2026.6.17"~0.5 秒就退出,
// 而不是慢慢跑完才失敗。這正是使用者看到的「2 秒失敗」。
//
// 在這裡擋掉而不是只修 Makefile使用者手上的安裝包已經帶著多版本 wheels
// 修 build 腳本救不了已出貨的版本;而這層防護對乾淨的 wheels 目錄是 no-op。
wheels, dropped := selectLatestWheelPerPackage(wheelsDir, names)
for _, d := range dropped {
fmt.Fprintf(os.Stderr,
"[visiona-local] wheels 目錄有同套件多版本,忽略舊版:%s採用 %s\n", d.skipped, d.chosen)
}
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))
}
// 安裝完立刻驗一次pip 回 0 不保證 import 得起來(架構不符的 wheel、
// 缺系統層 DLL 等)。驗過才寫標記,否則下次啟動又會被快路徑放行。
//
// 包成 errPythonDepsProbeFailed讓呼叫端能分辨「pip 失敗」(致命)與
// 「只是 probe 沒過」(可能誤判,不該擋啟動)。
if err := probePythonModules(pythonBin, venvHealthProbeModules); err != nil {
return fmt.Errorf("%w: wheels 安裝完成但相依無法載入:%w", errPythonDepsProbeFailed, err)
}
markerPath := filepath.Join(runtimeDir, venvReadyMarkerName)
if err := os.WriteFile(markerPath, []byte(bundledWheelsFingerprint(wheelsDir)), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "[visiona-local] WARN: 寫入 venv 標記失敗(不影響運作):%v\n", err)
}
return nil
}
// locateBundledPythonAssets 找 python tarball 與 wheels 目錄。
// 順序macOS bundle → 開發模式 payload/darwin → 上一層。
func locateBundledPythonAssets() (tarball, wheelsDir string, err error) {
// 0. Linux AppImageAppRun 已 export VISIONA_BUNDLE_LIB_DIR
if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" {
t := filepath.Join(libDir, "python", "python.tar.gz")
w := filepath.Join(libDir, "wheels")
if fileExists(t) && dirExists(w) {
return t, w, nil
}
}
// 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) {
// Linux AppImageAppRun 已 export VISIONA_BUNDLE_BIN_DIR=<mount>/usr/bin
if binDir := os.Getenv("VISIONA_BUNDLE_BIN_DIR"); binDir != "" {
if dirExists(binDir) {
abs, _ := filepath.Abs(binDir)
return abs, nil
}
}
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. Linux AppImageVISIONA_BUNDLE_LIB_DIR/dataAppRun 注入)
// 2. macOS .app bundleContents/Resources/data
// 3. Windows / Linux非 AppImage與 Wails 執行檔同目錄下的 data/
// 4. 開發模式:<repo>/server/data相對 cwd或往上一層 / 兩層)
func locateBundleDataDir() (string, error) {
// Linux AppImageAppRun 已 export VISIONA_BUNDLE_LIB_DIR=<mount>/usr/lib/visiona-local
if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" {
d := filepath.Join(libDir, "data")
if dirExists(d) {
abs, _ := filepath.Abs(d)
return abs, nil
}
}
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。
// 若 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()
}
}