You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
pi-launcher/app.go

921 lines
25 KiB

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 <pkg> 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())
}
}