package main import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "log" "os" "os/exec" "path/filepath" "runtime" "runtime/debug" "strconv" "strings" "sync" "time" wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // pi 默认 Web UI 端口 const piPort = 30141 // App is the Wails application backend. type App struct { ctx context.Context mu sync.Mutex piCmd *exec.Cmd piURL string startedAt time.Time running bool } // NewApp creates a new App application struct. func NewApp() *App { initCrashLog() // 在进程启动时预先初始化 npm prefix 缓存,后续 buildChildEnv 直接读缓存。 // 这步可能 exec npm 子命令(限 2s 超时),但不会和 buildChildEnv 互相递归。 initNpmPrefix() return &App{} } // initNpmPrefix triggers the cached npm prefix lookup exactly once. // 必须在启动早期(且不依赖 buildChildEnv)调用,避免 buildChildEnv → cachedNpmPrefix // 第一次执行时栈还未充分展开导致后续路径无法解析。 func initNpmPrefix() { _, _ = cachedNpmPrefix() } // initCrashLog wires a panic logger that writes to ~/Library/Logs/pi-launcher.log. // macOS GUI 进程 stderr 没终端,panic 信息看不到,落盘最稳。 var crashLogOnce sync.Once func initCrashLog() { crashLogOnce.Do(func() { home, _ := os.UserHomeDir() logPath := filepath.Join(home, "Library", "Logs", "pi-launcher.log") _ = os.MkdirAll(filepath.Dir(logPath), 0o755) f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return } log.SetOutput(f) log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Lshortfile) }) } // safeGo wraps a goroutine with recover so a panic in any background // task can't take down the whole Wails app. func safeGo(name string, fn func()) { go func() { defer func() { if r := recover(); r != nil { log.Printf("[panic:%s] %v\n%s", name, r, debugStack()) } }() fn() }() } func debugStack() string { return string(debug.Stack()) } // startup is called when the app starts. func (a *App) startup(ctx context.Context) { a.ctx = ctx // 接管旧进程: 用户可能之前手动启动过 pi-web / 残留 node 占着 30141。 // launcher 作为唯一入口,启动时强制清场,确保 UI 状态与实际一致。 safeGo("takeOver", func() { pids := listPIPids() freePort(piPort) killed := len(pids) if killed > 0 && a.ctx != nil { wailsruntime.EventsEmit(a.ctx, "pi:takeover:done", killed) } }) } // shutdown is called when the app is closing. Ensure pi is killed // so we never leak a child process. func (a *App) shutdown(ctx context.Context) { a.stopPI() } // ---- Types exposed to the frontend ---- // CheckResult is returned by CheckEnv. type CheckResult struct { NodePath string `json:"nodePath"` NodeVersion string `json:"nodeVersion"` NodeOK bool `json:"nodeOK"` PiPath string `json:"piPath"` PiVersion string `json:"piVersion"` CredFile string `json:"credFile"` CredExists bool `json:"credExists"` HomePiDir string `json:"homePiDir"` PortBusy bool `json:"portBusy"` PortBusyBy string `json:"portBusyBy"` Issues []string `json:"issues"` // 始终非 nil,前端可放心访问 } // StartResult is returned by Start. type StartResult struct { OK bool `json:"ok"` URL string `json:"url"` Error string `json:"error"` Output string `json:"output"` Hidden bool `json:"hidden"` // 是否已自动隐藏窗口 } // StatusResult is returned by GetStatus. type StatusResult struct { Running bool `json:"running"` URL string `json:"url"` StartedAt string `json:"startedAt"` Pid int `json:"pid"` } // ---- 3 core methods ---- // CheckEnv inspects Node, pi-web, ~/.pi credentials and port 30141. // 所有外部命令都套超时 + panic recover,任何一个失败不能让整个 app 闪退。 func (a *App) CheckEnv() (res CheckResult) { defer func() { if r := recover(); r != nil { log.Printf("[panic:CheckEnv] %v\n%s", r, debugStack()) res.Issues = append(res.Issues, fmt.Sprintf("CheckEnv 内部错误: %v", r)) } }() home := os.Getenv("HOME") if home == "" { home = "/tmp" } res = CheckResult{ HomePiDir: filepath.Join(home, ".pi"), CredFile: filepath.Join(home, ".pi", ".credentials.yaml"), PortBusy: false, Issues: []string{}, // 确保 JSON 序列化为 [] 而非 null } // node res.NodePath = lookPath("node") if res.NodePath != "" { if v, err := runQuiet(res.NodePath, "-v"); err == nil { res.NodeVersion = strings.TrimSpace(v) if nodeMajor(res.NodeVersion) >= 18 { res.NodeOK = true } } else { log.Printf("CheckEnv node -v err: %v", err) } } if !res.NodeOK { res.Issues = append(res.Issues, "Node >= 18 未安装或不可用") } // pi-web res.PiPath = lookPath("pi-web") piPkgDir := "" // 用来读 package.json 拿版本 if res.PiPath == "" { // fallback to npm prefix bin if npm, err := runQuiet("npm", "prefix", "-g"); err == nil { prefix := strings.TrimSpace(npm) candidate := filepath.Join(prefix, "bin", "pi-web") if _, statErr := os.Stat(candidate); statErr == nil { res.PiPath = candidate } piPkgDir = filepath.Join(prefix, "lib", "node_modules", "@agegr", "pi-web") } else { log.Printf("CheckEnv npm prefix err: %v", err) } } else { // 从 symlink 反推 package.json 路径: .../bin/pi-web -> ../lib/node_modules/@agegr/pi-web/bin/pi-web.js // 走 npm prefix -g 是稳的 if npm, err := runQuiet("npm", "prefix", "-g"); err == nil { prefix := strings.TrimSpace(npm) piPkgDir = filepath.Join(prefix, "lib", "node_modules", "@agegr", "pi-web") } } if res.PiPath != "" { // 注意: pi-web --version 实际上会启动 server 然后失败,不安全。 // 改用读 npm 全局 package.json 拿版本号。 if data, err := os.ReadFile(filepath.Join(piPkgDir, "package.json")); err == nil { var pkg struct { Version string `json:"version"` } if jerr := json.Unmarshal(data, &pkg); jerr == nil && pkg.Version != "" { res.PiVersion = pkg.Version } } if res.PiVersion == "" { log.Printf("CheckEnv: 找不到 pi-web package.json (尝试: %s)", piPkgDir) } } else { res.Issues = append(res.Issues, "pi-web 命令未找到(请 npm i -g @agegr/pi-web)") } // port 30141 — lsof 在受限进程下可能挂死,超时保护 if pids, err := lsofPort(piPort); err == nil && len(pids) > 0 { res.PortBusy = true res.PortBusyBy = strings.Join(pids, ", ") } return res } // Start launches pi-web, waits for the service port, and opens the browser. func (a *App) Start() StartResult { a.mu.Lock() if a.running { a.mu.Unlock() return StartResult{OK: false, URL: a.piURL, Error: "已在运行中"} } a.mu.Unlock() // 启动前先清理:杀残留 pi 进程,再强杀占 30141 端口的残留 PID(孤儿 node 等) killPITree() freePort(piPort) piBin := lookPath("pi-web") if piBin == "" { if npm, err := runQuiet("npm", "prefix", "-g"); err == nil { candidate := filepath.Join(strings.TrimSpace(npm), "bin", "pi-web") if _, statErr := os.Stat(candidate); statErr == nil { piBin = candidate } } } if piBin == "" { return StartResult{OK: false, Error: "找不到 pi-web 可执行文件"} } // --no-open 让 pi-web 只起服务不自动开浏览器,我们自己开 cmd := exec.Command(piBin, "--no-open") cmd.Env = buildChildEnv() stdout, err := cmd.StdoutPipe() if err != nil { return StartResult{OK: false, Error: "无法创建 stdout 管道: " + err.Error()} } stderr, err := cmd.StderrPipe() if err != nil { return StartResult{OK: false, Error: "无法创建 stderr 管道: " + err.Error()} } if err := cmd.Start(); err != nil { return StartResult{OK: false, Error: "启动 pi-web 失败: " + err.Error()} } a.mu.Lock() a.piCmd = cmd a.running = true a.startedAt = time.Now() a.piURL = "" a.mu.Unlock() // drain 掉 stdout/stderr 避免阻塞 safeGo("drainStdout", func() { drainLog(stdout, "pi") }) safeGo("drainStderr", func() { drainLog(stderr, "pi") }) // 监听子进程退出 safeGo("piWait", func() { _ = cmd.Wait() a.mu.Lock() a.running = false a.mu.Unlock() }) // polling 等端口 30141 LISTEN(替代等 URL 字符串) const timeout = 30 * time.Second ready := waitForPort(piPort, timeout) if !ready { _ = cmd.Process.Kill() return StartResult{OK: false, Error: fmt.Sprintf("等待端口 %d 监听超时(30s),pi-web 可能没正常启动", piPort)} } url := fmt.Sprintf("http://localhost:%d/", piPort) a.mu.Lock() a.piURL = url a.mu.Unlock() // 用系统 open 唤起浏览器(cookie session 自动登录) if openErr := openBrowser(url); openErr != nil { return StartResult{OK: true, URL: url, Error: "已启动,但自动开浏览器失败: " + openErr.Error()} } // 启动成功 + 已打开浏览器 → 最小化到 Dock if a.ctx != nil { wailsruntime.WindowMinimise(a.ctx) } return StartResult{OK: true, URL: url, Hidden: true} } // waitForPort 轮询检查端口是否处于 LISTEN,timeout 内返回 true。 func waitForPort(port int, timeout time.Duration) bool { deadline := time.Now().Add(timeout) for { if pids, _ := lsofPort(port); len(pids) > 0 { return true } if time.Now().After(deadline) { return false } time.Sleep(300 * time.Millisecond) } } // freePort 强制释放端口: lsof 拿到 LISTEN 的所有 PID,逐一 kill -9。 // 返回被杀的 PID 数量。 // 适用于: // - Start 前:如果旧 pi-web 进程杀完但端口还被占(孤儿 node 进程),再补刀 // - Stop 后:如果 pi-web 进程杀完但端口还在 TIME_WAIT,精准 kill 残留 PID // - OnShutdown:app 退出前保证不残留任何 pi-web 进程 + 端口 func freePort(port int) int { pids, _ := lsofPort(port) if len(pids) == 0 { return 0 } log.Printf("freePort: %d pid(s) holding :%d", len(pids), port) for _, pid := range pids { if err := exec.Command("kill", "-9", pid).Run(); err != nil { log.Printf("freePort: kill -9 %s: %v", pid, err) } } // 等系统释放 socket time.Sleep(300 * time.Millisecond) return len(pids) } // drainLog 读子进程输出并丢弃(不阻塞子进程 stdout 缓冲区)。 func drainLog(r io.Reader, tag string) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 64*1024), 1024*1024) for scanner.Scan() { _ = scanner.Text() } } // Stop kills the pi-web process and verifies the port is released. func (a *App) Stop() StartResult { a.mu.Lock() cmd := a.piCmd a.mu.Unlock() if cmd != nil && cmd.Process != nil { _ = cmd.Process.Kill() _, _ = cmd.Process.Wait() } // 兜底:把 pi-web 整个进程树都杀(pi-web 主进程 + Electron helper 等) killPITree() // 再精准 kill 占 30141 的残留 PID(孤儿 node、TIME_WAIT 等) freePort(piPort) time.Sleep(500 * time.Millisecond) a.mu.Lock() a.piCmd = nil a.piURL = "" a.running = false a.mu.Unlock() if pids, err := lsofPort(piPort); err == nil && len(pids) > 0 { return StartResult{OK: false, Error: fmt.Sprintf("端口 %d 仍被占用: %s", piPort, strings.Join(pids, ", "))} } return StartResult{OK: true} } // ShowWindow brings the application window to the front. func (a *App) ShowWindow() { if a.ctx != nil { wailsruntime.WindowShow(a.ctx) } } // HideWindow minimises the application window to Dock. // 用 WindowMinimise 而不是 WindowHide,这样 macOS 上点 Dock 图标系统会 // 自动 unminimise 恢复窗口(WindowHide 之后点 Dock 不会自动恢复)。 func (a *App) HideWindow() { if a.ctx != nil { wailsruntime.WindowMinimise(a.ctx) } } // GetStatus returns the current pi state. func (a *App) GetStatus() StatusResult { a.mu.Lock() defer a.mu.Unlock() res := StatusResult{Running: a.running, URL: a.piURL} if a.piCmd != nil && a.piCmd.Process != nil { res.Pid = a.piCmd.Process.Pid } if !a.startedAt.IsZero() { res.StartedAt = a.startedAt.Format(time.RFC3339) } return res } // ---- internal helpers ---- func (a *App) stopPI() { a.mu.Lock() cmd := a.piCmd a.running = false a.mu.Unlock() if cmd != nil && cmd.Process != nil { _ = cmd.Process.Kill() } // 按 X 关 app / Stop 按钮 / OnShutdown 统一都走这个流程: // 1) 杀整个 pi-web 进程树 // 2) 再精准 kill 占 30141 端口的残留 PID killPITree() freePort(piPort) } // buildChildEnv 手动把 homebrew 路径塞进子进程 PATH。 // macOS GUI 启动的进程不读 ~/.zshrc,直接拿环境变量 PATH 经常缺 /opt/homebrew/bin。 // 注意:本函数不能调任何 exec 子命令(包括 npm),否则会触发栈溢出。 // npm 全局 bin 路径由 cachedNpmPrefix() 在 initNpmPrefix() 中预先算好。 func buildChildEnv() []string { env := os.Environ() home := os.Getenv("HOME") extra := []string{ "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", } // 加上缓存好的 npm 全局 bin(如果有) if prefix, ok := cachedNpmPrefix(); ok { extra = append(extra, filepath.Join(prefix, "bin")) } hasPath := false for i, e := range env { if strings.HasPrefix(e, "PATH=") { hasPath = true cur := strings.TrimPrefix(e, "PATH=") merged := mergePath(cur, extra) env[i] = "PATH=" + merged } } if !hasPath { env = append(env, "PATH="+strings.Join(extra, ":")) } if home == "" { env = append(env, "HOME=/tmp") } return env } // mergePath 拼接 PATH。绝对不能调 runQuiet / 任何 exec 子命令, // 否则会触发 buildChildEnv → mergePath 的无限递归 → 栈溢出。 // npm 全局 bin 用 sync.Once 懒加载 + 超时兜底,在 initEnvOnce 里完成。 func mergePath(cur string, extra []string) string { seen := map[string]bool{} out := []string{} add := func(p string) { if p == "" || seen[p] { return } seen[p] = true out = append(out, p) } for _, p := range strings.Split(cur, ":") { add(p) } for _, p := range extra { add(p) } return strings.Join(out, ":") } // npmPrefixCache 用 sync.Once 缓存 `npm prefix -g` 的输出, // 避免每次 buildChildEnv 都 exec npm。 var ( npmPrefixOnce sync.Once npmPrefixValue string npmPrefixReady bool ) func cachedNpmPrefix() (string, bool) { npmPrefixOnce.Do(func() { // 只在确实有 npm 时才跑 bin := lookPath("npm") if bin == "" { return } // 硬超时 2s,失败就算了,不致命。 // 注意:这里直接 exec 用 os.Environ(),不走 buildChildEnv, // 否则初始化期间可能再次触发 buildChildEnv 形成循环。 cmd := exec.Command(bin, "prefix", "-g") cmd.Env = os.Environ() var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf if err := cmd.Start(); err != nil { return } done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case <-done: npmPrefixValue = strings.TrimSpace(buf.String()) npmPrefixReady = npmPrefixValue != "" case <-time.After(2 * time.Second): _ = cmd.Process.Kill() <-done } }) return npmPrefixValue, npmPrefixReady } func openBrowser(url string) error { switch runtime.GOOS { case "darwin": bin := lookPath("open") if bin == "" { bin = "/usr/bin/open" } return exec.Command(bin, url).Start() case "windows": return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() default: bin := lookPath("xdg-open") if bin == "" { return fmt.Errorf("xdg-open not found") } return exec.Command(bin, url).Start() } } func lookPath(name string) string { // 先用 PATH if p, err := exec.LookPath(name); err == nil { return p } // macOS GUI 进程常缺 PATH,兜底几个常见位置 for _, dir := range []string{"/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"} { candidate := filepath.Join(dir, name) if _, err := os.Stat(candidate); err == nil { return candidate } } return "" } func runQuiet(name string, args ...string) (string, error) { return runQuietTimeout(name, 3*time.Second, args...) } // runQuietTimeout runs a command with a hard timeout. If it doesn't finish in time, // the child is killed and an error is yielded. macOS 上某些 fork-exec 失败场景, // 没有超时保护会一直 hang,进而拖垮调用方。 func runQuietTimeout(name string, timeout time.Duration, args ...string) (string, error) { if name == "" { return "", fmt.Errorf("empty command") } // 用我们自己的 lookPath(GUI 进程 PATH 缺失时也能找到 /opt/homebrew/bin 等), // 不能用 exec.LookPath —— 它只读系统 PATH,看不到我们手动塞的路径。 bin := lookPath(name) if bin == "" { return "", fmt.Errorf("not found: %s (也没在 /opt/homebrew/bin 找到)", name) } cmd := exec.Command(bin, args...) cmd.Env = buildChildEnv() // 输出大小限制,防止 npm 输出爆 buffer 卡死 var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf if err := cmd.Start(); err != nil { return "", fmt.Errorf("start %s: %w", bin, err) } done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case err := <-done: return buf.String(), err case <-time.After(timeout): _ = cmd.Process.Kill() <-done return "", fmt.Errorf("timeout after %s: %s %s", timeout, bin, strings.Join(args, " ")) } } func nodeMajor(v string) int { v = strings.TrimPrefix(v, "v") parts := strings.SplitN(v, ".", 2) if len(parts) == 0 { return 0 } n := 0 for _, ch := range parts[0] { if ch < '0' || ch > '9' { break } n = n*10 + int(ch-'0') } return n } // lsofPort 返回占用该端口的 PID 列表(macOS)。带超时 + panic recover。 func lsofPort(port int) (res []string, err error) { defer func() { if r := recover(); r != nil { log.Printf("[panic:lsofPort] %v\n%s", r, debugStack()) err = fmt.Errorf("lsof panic: %v", r) } }() lsofBin := lookPath("lsof") if lsofBin == "" { return nil, nil // 没 lsof,跳过 } cmd := exec.Command(lsofBin, "-nP", fmt.Sprintf("-iTCP:%d", port), "-sTCP:LISTEN", "-t") cmd.Env = buildChildEnv() var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf if err := cmd.Start(); err != nil { return nil, fmt.Errorf("lsof start: %w", err) } done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case err := <-done: out := buf.Bytes() if err != nil && len(out) == 0 { // lsof 无命中时退出码非 0 + 空输出 = 端口空闲 return nil, nil } for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") { l = strings.TrimSpace(l) if l != "" { res = append(res, l) } } return res, err case <-time.After(3 * time.Second): _ = cmd.Process.Kill() <-done return nil, fmt.Errorf("lsof timeout") } } // ---- Install / Update ---- const piPkg = "@agegr/pi-web" // UpdateInfo describes the available update for pi-web. type UpdateInfo struct { Installed string `json:"installed"` Latest string `json:"latest"` HasUpdate bool `json:"hasUpdate"` Raw string `json:"raw"` } // InstallResult is returned by InstallPi / UpdatePi. type InstallResult struct { OK bool `json:"ok"` Error string `json:"error"` Output string `json:"output"` } // killPITree kills all processes related to the pi-web service: // - the pi-web CLI itself (npm/node pi-web/lib/bin.js) // - any Electron helper (main / renderer / gpu / utility / network) // // 不只 kill 主进程: Electron helper 还在跑时,EPIPE 会在 pipe 关闭后让它抛 // "Uncaught Exception" 弹窗,污染退出 UX。 func killPITree() { pids := listPIPids() if len(pids) == 0 { return } log.Printf("killPITree: %d pids to kill", len(pids)) for _, pid := range pids { // kill -9 强杀,避免 Electron helper 还在做 graceful shutdown 又触发 EPIPE if err := exec.Command("kill", "-9", pid).Run(); err != nil { log.Printf("killPITree: kill %s: %v", pid, err) } } } // listPIPids 列出所有跟 pi-web 相关的进程 PID(去重 + 排除自身)。 // 用 ps + grep 而不是 pgrep,以兼容 macOS 默认 PATH 不带 /usr/bin 的情况。 func listPIPids() []string { out, err := runQuiet("ps", "-axo", "pid=,command=") if err != nil { log.Printf("listPIPids: ps err: %v", err) return nil } seen := map[string]bool{} var pids []string self := os.Getpid() for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } // 格式: "PID COMMAND..." sp := strings.IndexByte(line, ' ') if sp <= 0 { continue } pidStr := line[:sp] cmd := line[sp+1:] // 跳过自身进程,免得自杀 if pid, _ := strconv.Atoi(pidStr); pid == self { continue } // 匹配规则: pi-web 启动的所有相关进程 if isPIProcess(cmd) { if !seen[pidStr] { seen[pidStr] = true pids = append(pids, pidStr) } } } return pids } // isPIProcess 判断一条 ps command 是不是 pi-web 相关的进程。 // pi-web 基于 Next.js,真正 listen 30141 的是 next-server 进程; // 主进程 / Electron helper 用包名路径也能匹到。 func isPIProcess(cmd string) bool { matches := []string{ "pi-web", // pi-web 任意引用(主进程、cli、helper) "@agegr/pi-web", // npm 包路径 ".pi/electron", // pi-web 内置 Electron runtime "next-server", // Next.js server(实际 listen 端口的进程) } for _, m := range matches { if strings.Contains(cmd, m) { return true } } return false } func (a *App) CheckUpdate() UpdateInfo { installed := "" if p := lookPath("pi-web"); p != "" { if v, err := runQuietTimeout(p, 5*time.Second, "--version"); err == nil { installed = strings.TrimSpace(v) } else { log.Printf("CheckUpdate pi-web --version err: %v", err) } } latest, raw := latestVersionFromNPM(piPkg) info := UpdateInfo{ Installed: installed, Latest: latest, Raw: raw, } if installed != "" && latest != "" && installed != latest { info.HasUpdate = true } return info } // latestVersionFromNPM 用 `npm view version` 取最新版。 // 带 10 秒超时,带 stderr 捕获以便诊断。 func latestVersionFromNPM(pkg string) (string, string) { npmBin := lookPath("npm") if npmBin == "" { return "", "npm not found in PATH" } cmd := exec.Command(npmBin, "view", pkg, "version", "--registry=https://registry.npmjs.org/") cmd.Env = buildChildEnv() var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Start(); err != nil { return "", "start npm: " + err.Error() } done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case err := <-done: out := strings.TrimSpace(stdout.String()) errMsg := strings.TrimSpace(stderr.String()) if err != nil { raw := errMsg if raw == "" { raw = err.Error() } return "", raw } // npm 输出可能带换行 / 警告,只取第一行 token first := out if i := strings.IndexAny(out, "\n\r"); i >= 0 { first = strings.TrimSpace(out[:i]) } // 清理 npm 有时会附加的 @ 标签 first = strings.TrimPrefix(first, "@") return first, out case <-time.After(15 * time.Second): _ = cmd.Process.Kill() <-done return "", "npm view 超时(15s),请检查网络" } } // InstallPi runs `npm install -g @agegr/pi-web` and streams output via Wails events. // Returns InstallResult once the command exits. func (a *App) InstallPi() InstallResult { return a.runNpmEvent("install", []string{"install", "-g", piPkg}, "pi:install:done") } // UpdatePi runs `npm install -g @agegr/pi-web@latest` and streams output. func (a *App) UpdatePi() InstallResult { return a.runNpmEvent("update", []string{"install", "-g", piPkg + "@latest"}, "pi:update:done") } func (a *App) runNpmEvent(phase string, args []string, doneEvent string) InstallResult { npmBin := lookPath("npm") if npmBin == "" { errMsg := "找不到 npm(系统 PATH 和 /opt/homebrew/bin 都没找到)" if a.ctx != nil { wailsruntime.EventsEmit(a.ctx, "pi:"+phase+":log", "✗ "+errMsg+"\n") wailsruntime.EventsEmit(a.ctx, doneEvent, InstallResult{Error: errMsg}) } return InstallResult{Error: errMsg} } if a.ctx != nil { wailsruntime.EventsEmit(a.ctx, "pi:"+phase+":log", "▶ "+npmBin+" "+strings.Join(args, " ")+"\n") } cmd := exec.Command(npmBin, args...) cmd.Env = buildChildEnv() stdout, err := cmd.StdoutPipe() if err != nil { return InstallResult{Error: err.Error()} } stderr, err := cmd.StderrPipe() if err != nil { return InstallResult{Error: err.Error()} } if err := cmd.Start(); err != nil { return InstallResult{Error: "启动 npm 失败: " + err.Error()} } var buf strings.Builder var mu sync.Mutex collect := func(line string) { mu.Lock() buf.WriteString(line) buf.WriteString("\n") mu.Unlock() if a.ctx != nil { wailsruntime.EventsEmit(a.ctx, "pi:"+phase+":log", line+"\n") } } safeGo("scanStdout", func() { scanLines(stdout, collect) }) safeGo("scanStderr", func() { scanLines(stderr, collect) }) waitErr := cmd.Wait() mu.Lock() out := buf.String() mu.Unlock() res := InstallResult{Output: out} if waitErr != nil { res.Error = waitErr.Error() } else { res.OK = true } if a.ctx != nil { wailsruntime.EventsEmit(a.ctx, doneEvent, res) } return res } func scanLines(r io.Reader, fn func(string)) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 64*1024), 1024*1024) for scanner.Scan() { fn(scanner.Text()) } }