package main import ( "context" "fmt" "io" "net" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "syscall" "time" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // ===================================================================== // visionA-local Wails App // ===================================================================== // M1 範圍:啟動殼層,負責 // 1. single-instance lock(檔案鎖 + PID) // 2. 舊資料目錄遷移 // 3. port picking(3721 → 3722 → ...) // 4. Python runtime 雙策略(auto / bundled / system)— M1 只有空殼 // 5. spawn visiona-local-server 子行程(含 graceful shutdown) // 6. 提供前端 binding:GetServerURL、GetServerStatus、OpenBrowser // // **不在 M1 範圍**:實際下載/解壓 python-build-standalone(M2)、 // 內嵌 payload(M1-12 build packaging 才處理)、auto-update、relay、 // installer wizard、tray。這些原本 installer 的邏輯已被整份刪除。 // ===================================================================== // ----------------------------------------------------------------------- // 常量 & 型別 // ----------------------------------------------------------------------- const ( defaultPreferredPort = 3721 portSearchRange = 20 healthCheckTimeout = 15 * time.Second shutdownGracePeriod = 5 * time.Second appName = "visiona-local" ) // PythonMode 決定 Python runtime 的選擇策略。 type PythonMode string const ( PythonModeAuto PythonMode = "auto" // 先試 system,失敗才走 bundled(R4 決策:M1 先 system) PythonModeBundled PythonMode = "bundled" // 策略 A:內嵌 python-build-standalone PythonModeSystem PythonMode = "system" // 策略 B:系統 python3 ) // ServerStatus 回報給前端。 type ServerStatus struct { Running bool `json:"running"` Port int `json:"port"` URL string `json:"url"` PID int `json:"pid"` PythonBin string `json:"pythonBin"` PythonMode string `json:"pythonMode"` LastError string `json:"lastError,omitempty"` } // ServerProcess 包裝子行程控制。 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 // 實際使用的 mode(auto resolved 之後) lastError string releaseLock func() // L-1:server 健康偵測 goroutine 控制 watchCancel context.CancelFunc // L-3:Wails 自己的 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 } } return &App{ pythonMode: mode, mockMode: true, // M1 預設 mock(沒有真的 python/hardware) } } // ----------------------------------------------------------------------- // 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 server(L-3) // 供後來的 instance 透過 /ipc/raise 把現有視窗提到前景。 // 失敗不擋啟動,只是犧牲 single-instance raise 能力。 if err := a.startIPCServer(); err != nil { fmt.Fprintln(os.Stderr, "[visiona-local] IPC server start 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) } // OpenBrowser 用系統預設瀏覽器開啟 URL。 func (a *App) OpenBrowser(url string) error { return openBrowser(url) } // ----------------------------------------------------------------------- // Server 子行程管理 // ----------------------------------------------------------------------- func (a *App) startServer() error { // 1. 決定 python runtime 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() // 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) // 注入 bundle bin dir 給 server 偵測 ffmpeg / yt-dlp(M6) env := os.Environ() if binDir, err := locateBundleBinDir(); err == nil { env = append(env, "VISIONA_BUNDLE_BIN_DIR="+binDir) fmt.Fprintln(os.Stderr, "[visiona-local] bundle bin dir:", binDir) } 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」寫進檔案誤導) 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.mu.Lock() a.server = proc a.mu.Unlock() // 9. L-1:啟動 server 健康偵測 goroutine watchCtx, cancel := context.WithCancel(context.Background()) a.mu.Lock() // 若先前已有 watch goroutine(例如重啟),先停掉 if a.watchCancel != nil { a.watchCancel() } a.watchCancel = cancel a.mu.Unlock() go a.watchServer(watchCtx, proc) return nil } // watchServer 每 10 秒打一次 /api/system/health,連續 3 次失敗視為 server 崩潰。 // 崩潰後 emit Wails event 給前端(type=server:dead),並呼叫 reportFatal 結束程式。 // 從失敗中恢復時 emit server:recovered。 // // L-1:第一版不做 auto-restart,直接交由 reportFatal 跳對話框 + 結束。 func (a *App) watchServer(ctx context.Context, sp *ServerProcess) { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() failures := 0 healthURL := fmt.Sprintf("http://127.0.0.1:%d/api/system/health", sp.port) client := &http.Client{Timeout: 3 * time.Second} for { select { case <-ctx.Done(): return case <-ticker.C: resp, err := client.Get(healthURL) if err == nil && resp.StatusCode == 200 { resp.Body.Close() if failures > 0 && a.ctx != nil { wailsRuntime.EventsEmit(a.ctx, "server:recovered", nil) } failures = 0 continue } if resp != nil { resp.Body.Close() } failures++ fmt.Fprintf(os.Stderr, "[visiona-local] server health check failed (%d/3): %v\n", failures, err) if failures >= 3 { if a.ctx != nil { wailsRuntime.EventsEmit(a.ctx, "server:dead", map[string]any{ "reason": "health check failed 3 times", "port": sp.port, }) } a.reportFatal("server died", fmt.Errorf("health check failed 3 times in a row on port %d", sp.port)) return } } } } func (a *App) stopServer() { a.mu.Lock() proc := a.server a.server = nil // 先停 watch goroutine,避免它看到 server 不在還誤報 if a.watchCancel != nil { a.watchCancel() a.watchCancel = nil } a.mu.Unlock() if proc == nil { return } proc.stop() } // kill 直接強殺。 func (p *ServerProcess) kill() { if p.cmd != nil && p.cmd.Process != nil { _ = p.cmd.Process.Kill() _, _ = p.cmd.Process.Wait() } if p.stdoutLog != nil { p.stdoutLog.Close() } if p.stderrLog != nil { p.stderrLog.Close() } } // stop 優雅關閉:SIGTERM → 等 5s → SIGKILL。 func (p *ServerProcess) stop() { if p.cmd == nil || p.cmd.Process == nil { return } // Windows 沒有 SIGTERM,直接 Kill if runtime.GOOS == "windows" { _ = p.cmd.Process.Kill() } else { _ = p.cmd.Process.Signal(syscall.SIGTERM) } done := make(chan error, 1) go func() { done <- p.cmd.Wait() }() select { case <-done: // graceful case <-time.After(shutdownGracePeriod): _ = p.cmd.Process.Kill() <-done } if p.stdoutLog != nil { p.stdoutLog.Close() } if p.stderrLog != nil { p.stderrLog.Close() } } // ----------------------------------------------------------------------- // Python Runtime 雙策略(R4:system 優先,bundled 為 fallback,M1 實作 system) // ----------------------------------------------------------------------- // ensurePythonRuntime 依 mode 決定並回傳 python 可執行路徑與實際使用的 mode。 // // M1 實作狀況: // - PythonModeSystem:完整實作(findSystemPython) // - PythonModeAuto:先試 system,失敗才走 bundled // - PythonModeBundled:placeholder,回錯誤(M2 才實作) // // 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/Contents/Resources/python/python.tar.gz 與 Resources/wheels/*.whl // - 開發模式:/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) } // 解壓 tarball(strip-components=1 剝掉 "python/" 前綴) fmt.Fprintln(os.Stderr, "[visiona-local] extracting bundled python runtime...") extract := exec.Command("tar", "-xzf", pyTarball, "-C", pyHome, "--strip-components=1") 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) } fmt.Fprintln(os.Stderr, "[visiona-local] creating venv at", venvPath) if out, err := exec.Command(embeddedPython, "-m", "venv", venvPath).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 } fmt.Fprintf(os.Stderr, "[visiona-local] offline pip install %d wheels...\n", len(wheels)) args := []string{"-m", "pip", "install", "--no-index", "--find-links", wheelsDir, "--prefer-binary"} args = append(args, wheels...) pipCmd := exec.Command(pythonBin, args...) if out, err := pipCmd.CombinedOutput(); err != nil { return "", fmt.Errorf("pip install wheels: %w\n%s", err, string(out)) } fmt.Fprintln(os.Stderr, "[visiona-local] bundled python runtime ready:", pythonBin) return pythonBin, nil } // locateBundledPythonAssets 找 python tarball 與 wheels 目錄。 // 順序:macOS bundle → 開發模式 payload/darwin → 上一層。 func locateBundledPythonAssets() (tarball, wheelsDir string, err error) { // 1. macOS .app bundle:Contents/Resources/python + Resources/wheels if exe, e := os.Executable(); e == nil { exeDir := filepath.Dir(exe) if runtime.GOOS == "darwin" { t := filepath.Join(exeDir, "..", "Resources", "python", "python.tar.gz") w := filepath.Join(exeDir, "..", "Resources", "wheels") if fileExists(t) && dirExists(w) { return t, w, nil } } // 同目錄 fallback(Windows / Linux 打包後) t := filepath.Join(exeDir, "python", "python.tar.gz") w := filepath.Join(exeDir, "wheels") if fileExists(t) && dirExists(w) { return t, w, nil } } // 2. 開發模式 fallback(依 GOOS 挑對應 payload 子目錄) if cwd, e := os.Getwd(); e == nil { osName := runtime.GOOS // darwin / windows / linux candidates := []struct{ t, w string }{ {filepath.Join(cwd, "payload", osName, "python", "python.tar.gz"), filepath.Join(cwd, "payload", osName, "wheels")}, {filepath.Join(cwd, "..", "payload", osName, "python", "python.tar.gz"), filepath.Join(cwd, "..", "payload", osName, "wheels")}, {filepath.Join(cwd, "..", "..", "payload", osName, "python", "python.tar.gz"), filepath.Join(cwd, "..", "..", "payload", osName, "wheels")}, } for _, c := range candidates { if fileExists(c.t) && dirExists(c.w) { return c.t, c.w, nil } } } return "", "", fmt.Errorf("bundled python assets not found (tried .app Resources + same-dir + payload/%s)", runtime.GOOS) } // locateBundleBinDir 找 bundle 內的 bin 目錄(含 ffmpeg / yt-dlp / visiona-local-server)。 // // 順序: // 1. macOS .app bundle:Contents/Resources/bin // 2. Windows / Linux 打包後:與 Wails 執行檔同目錄下的 bin/ // 3. 開發模式:payload/darwin/bin(相對 cwd) func locateBundleBinDir() (string, error) { if exe, err := os.Executable(); err == nil { exeDir := filepath.Dir(exe) if runtime.GOOS == "darwin" { d := filepath.Join(exeDir, "..", "Resources", "bin") if dirExists(d) { abs, _ := filepath.Abs(d) return abs, nil } } // 同目錄 bin/ fallback(Windows / Linux 打包後) d := filepath.Join(exeDir, "bin") if dirExists(d) { abs, _ := filepath.Abs(d) return abs, nil } // 或與執行檔同目錄(server binary 本身就在這裡時) if fileExists(filepath.Join(exeDir, "ffmpeg")) || fileExists(filepath.Join(exeDir, "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") } 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 " /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. 開發模式:/payload//bin/visiona-local-server // 5. 開發模式:/dist/visiona-local-server // 6. 開發模式:/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() } }