jim800121chen 9b0d946acd feat(local-tool): splash 顯示實時啟動進度 + 拉長 timeout + 修正執行模式顯示
Splash 進度:
- app.go 新增 bootstrapStatus field + GetBootstrapStatus() binding
- 各 startup step 呼叫 setBootstrapStatus 更新文字:
  "正在初始化 Python 環境..."
  "正在解壓 Python runtime (~10 秒)..."
  "正在建立 Python 虛擬環境 (~5 秒)..."
  "正在安裝 N 個 Python 套件 (numpy / opencv / KneronPLUS ...) (~30-60 秒)..."
  "正在安裝 Kneron USB 驅動程式 (請在 UAC 視窗點「是」)..."
  "正在準備應用程式資料..."
  "正在啟動伺服器..."
  "等待伺服器就緒..."
  "載入主介面..."
- visiona-local/frontend/app.js 每 400ms 呼叫 GetBootstrapStatus 更新畫面
- wailsjs/go/main/App.js 手動補上新 binding export(避免等 wails generate)

Timeout:
- splash MAX_WAIT_MS 60s → 240s(涵蓋 UAC 被拖延 + 慢速硬碟)
- healthCheckTimeout 15s → 30s(server 首次啟動內部解析 + embed fs 載入)

設定 > 硬體 > 執行模式:
- 顯示預設值從 mock 改為 real(跟 app.go 實際預設對齊 - Q8 決策)
- 下拉選單寬度 240 → 420px 避免文字被截斷
- i18n 說明文字改為「預設為真實硬體模式,強制 Mock 請設 VISIONA_MOCK=1」
- 仍標 disabled — 未來 M8+ 會連 backend GET /api/system/config

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

1585 lines
50 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

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