package main import ( "bufio" "bytes" "context" "fmt" "io" "log" "os" "os/exec" "path/filepath" "regexp" "runtime" "runtime/debug" "strconv" "strings" "sync" "time" wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // App is the Wails application backend. type App struct { ctx context.Context mu sync.Mutex dshCmd *exec.Cmd dshURL 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/dsh-launcher.log. // macOS GUI 进程 stderr 没终端,panic 信息看不到,落盘最稳。 var crashLogOnce sync.Once func initCrashLog() { crashLogOnce.Do(func() { home, _ := os.UserHomeDir() logPath := filepath.Join(home, "Library", "Logs", "dsh-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 } // shutdown is called when the app is closing. Ensure dsh is killed // so we never leak a child process. func (a *App) shutdown(ctx context.Context) { a.stopDSH() } // ---- 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"` DshPath string `json:"dshPath"` DshVersion string `json:"dshVersion"` CredFile string `json:"credFile"` CredExists bool `json:"credExists"` HomeDshDir string `json:"homeDshDir"` 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, dsh, ~/.dsh credentials and port 3080. // 所有外部命令都套超时 + 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{ HomeDshDir: filepath.Join(home, ".dsh"), CredFile: filepath.Join(home, ".dsh", ".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 未安装或不可用") } // dsh res.DshPath = lookPath("dsh") if res.DshPath == "" { // fallback to npm prefix bin if npm, err := runQuiet("npm", "prefix", "-g"); err == nil { prefix := strings.TrimSpace(npm) candidate := filepath.Join(prefix, "bin", "dsh") if _, statErr := os.Stat(candidate); statErr == nil { res.DshPath = candidate } } else { log.Printf("CheckEnv npm prefix err: %v", err) } } if res.DshPath != "" { if v, err := runQuiet(res.DshPath, "--version"); err == nil { res.DshVersion = strings.TrimSpace(v) } else { log.Printf("CheckEnv dsh --version err: %v", err) } } else { res.Issues = append(res.Issues, "dsh 命令未找到(请 npm i -g @deepseek-ai/dsh)") } // credentials if _, err := os.Stat(res.CredFile); err == nil { res.CredExists = true } else { res.Issues = append(res.Issues, "缺少 ~/.dsh/.credentials.yaml,请先 dsh login") } // port 3080 — lsof 在受限进程下可能挂死,超时保护 if pids, err := lsofPort(3080); err == nil && len(pids) > 0 { res.PortBusy = true res.PortBusyBy = strings.Join(pids, ", ") } return res } // Start launches dsh web, captures the token URL and opens the browser. func (a *App) Start() StartResult { a.mu.Lock() if a.running { a.mu.Unlock() return StartResult{OK: false, URL: a.dshURL, Error: "已在运行中"} } a.mu.Unlock() dshBin := lookPath("dsh") if dshBin == "" { if npm, err := runQuiet("npm", "prefix", "-g"); err == nil { candidate := filepath.Join(strings.TrimSpace(npm), "bin", "dsh") if _, statErr := os.Stat(candidate); statErr == nil { dshBin = candidate } } } if dshBin == "" { return StartResult{OK: false, Error: "找不到 dsh 可执行文件"} } // --no-open 让 dsh 只打印 URL,我们自己用系统 open 打开 cmd := exec.Command(dshBin, "--profile", "web", "--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: "启动 dsh 失败: " + err.Error()} } a.mu.Lock() a.dshCmd = cmd a.running = true a.startedAt = time.Now() a.dshURL = "" a.mu.Unlock() // 异步读 stdout/stderr,抓 URL urlCh := make(chan string, 1) errCh := make(chan string, 1) safeGo("scanForURL", func() { scanForURL(stdout, urlCh) }) safeGo("drainStderr", func() { drainStderr(stderr, errCh) }) // 监听子进程退出 safeGo("dshWait", func() { _ = cmd.Wait() a.mu.Lock() a.running = false a.mu.Unlock() }) // 等 URL(最多 30 秒) select { case url := <-urlCh: a.mu.Lock() a.dshURL = url a.mu.Unlock() // 用系统 open 唤起浏览器 if openErr := openBrowser(url); openErr != nil { return StartResult{OK: true, URL: url, Error: "已启动,但自动开浏览器失败: " + openErr.Error()} } // 启动成功 + 已打开浏览器 → 最小化到 Dock(用 WindowMinimise 不用 WindowHide, // 这样点 Dock 图标系统会自动 unminimise 恢复窗口,而不是再也打不开)。 if a.ctx != nil { wailsruntime.WindowMinimise(a.ctx) } return StartResult{OK: true, URL: url, Hidden: true} case errMsg := <-errCh: return StartResult{OK: false, Error: "dsh 输出错误: " + errMsg} case <-time.After(30 * time.Second): return StartResult{OK: false, Error: "等待 dsh URL 超时(30s)"} } } // Stop kills the dsh process and verifies the port is released. func (a *App) Stop() StartResult { a.mu.Lock() cmd := a.dshCmd a.mu.Unlock() if cmd != nil && cmd.Process != nil { _ = cmd.Process.Kill() _, _ = cmd.Process.Wait() } // 兜底:把 dsh 整个进程树都杀(dsh 自己 + dsh-pet Electron helper + GPU/renderer/utility 等) // 不杀全,dsh-pet 会在 EPIPE 时弹 JavaScript 错误弹窗。 killDSHTree() time.Sleep(500 * time.Millisecond) a.mu.Lock() a.dshCmd = nil a.dshURL = "" a.running = false a.mu.Unlock() if pids, err := lsofPort(3080); err == nil && len(pids) > 0 { return StartResult{OK: false, Error: "端口 3080 仍被占用: " + 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 dsh state. func (a *App) GetStatus() StatusResult { a.mu.Lock() defer a.mu.Unlock() res := StatusResult{Running: a.running, URL: a.dshURL} if a.dshCmd != nil && a.dshCmd.Process != nil { res.Pid = a.dshCmd.Process.Pid } if !a.startedAt.IsZero() { res.StartedAt = a.startedAt.Format(time.RFC3339) } return res } // ---- internal helpers ---- func (a *App) stopDSH() { a.mu.Lock() cmd := a.dshCmd a.running = false a.mu.Unlock() if cmd != nil && cmd.Process != nil { _ = cmd.Process.Kill() } killDSHTree() } // 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 scanForURL(r io.Reader, urlCh chan<- string) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 64*1024), 1024*1024) // dsh web: http://127.0.0.1:3080/?token=xxx // 兼容多种格式 re := regexp.MustCompile(`https?://[^\s)]+token=[A-Za-z0-9._\-]+`) for scanner.Scan() { line := scanner.Text() if m := re.FindString(line); m != "" { urlCh <- m return } // 兜底:任何 http URL 也接受 if strings.Contains(line, "http://") || strings.Contains(line, "https://") { if m := regexp.MustCompile(`https?://[^\s]+`).FindString(line); m != "" { urlCh <- strings.TrimRight(m, ".,;)") return } } } } func drainStderr(r io.Reader, errCh chan<- string) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 64*1024), 1024*1024) for scanner.Scan() { // 把 stderr 行发给 errCh,Start 收到后会返回 select { case errCh <- scanner.Text(): default: } } } 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 dshPkg = "@deepseek-ai/dsh" // UpdateInfo describes the available update for dsh. type UpdateInfo struct { Installed string `json:"installed"` Latest string `json:"latest"` HasUpdate bool `json:"hasUpdate"` Raw string `json:"raw"` } // InstallResult is returned by InstallDsh / UpdateDsh. type InstallResult struct { OK bool `json:"ok"` Error string `json:"error"` Output string `json:"output"` } // killDSHTree kills all processes related to the dsh service: // - the dsh CLI itself (npm/node dsh/lib/bin.js) // - dsh-pet Electron helper (main / renderer / gpu / utility / network) // - any Electron app launched from ~/.dsh/electron // // 不只 kill 主进程: dsh-pet 还在跑时,EPIPE 会在 pipe 关闭后让它抛 // "Uncaught Exception" 弹窗,污染退出 UX。 func killDSHTree() { pids := listDSHPids() if len(pids) == 0 { return } log.Printf("killDSHTree: %d pids to kill", len(pids)) for _, pid := range pids { // kill -9 强杀,避免 dsh-pet 还在做 graceful shutdown 又触发 EPIPE if err := exec.Command("kill", "-9", pid).Run(); err != nil { log.Printf("killDSHTree: kill %s: %v", pid, err) } } } // listDSHPids 列出所有跟 dsh 相关的进程 PID(去重 + 排除自身)。 // 用 ps + grep 而不是 pgrep,以兼容 macOS 默认 PATH 不带 /usr/bin 的情况。 func listDSHPids() []string { out, err := runQuiet("ps", "-axo", "pid=,command=") if err != nil { log.Printf("listDSHPids: 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 } // 匹配规则: dsh 启动的所有相关进程 if isDSHProcess(cmd) { if !seen[pidStr] { seen[pidStr] = true pids = append(pids, pidStr) } } } return pids } // isDSHProcess 判断一条 ps command 是不是 dsh 相关的进程。 // 规则: func isDSHProcess(cmd string) bool { matches := []string{ "dsh/lib/bin.js", // dsh CLI 主进程 "dsh-pet", // dsh-pet Electron helper ".dsh/electron", // dsh 内置的 Electron runtime "dsh-pet-electron-helper", // dsh-pet user-data-dir } for _, m := range matches { if strings.Contains(cmd, m) { return true } } return false } func (a *App) CheckUpdate() UpdateInfo { installed := "" if p := lookPath("dsh"); p != "" { if v, err := runQuietTimeout(p, 5*time.Second, "--version"); err == nil { installed = strings.TrimSpace(v) } else { log.Printf("CheckUpdate dsh --version err: %v", err) } } latest, raw := latestVersionFromNPM(dshPkg) 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),请检查网络" } } // InstallDsh runs `npm install -g @deepseek-ai/dsh` and streams output via Wails events. // Returns InstallResult once the command exits. func (a *App) InstallDsh() InstallResult { return a.runNpmEvent("install", []string{"install", "-g", dshPkg}, "dsh:install:done") } // UpdateDsh runs `npm install -g @deepseek-ai/dsh@latest` and streams output. func (a *App) UpdateDsh() InstallResult { return a.runNpmEvent("update", []string{"install", "-g", dshPkg + "@latest"}, "dsh: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, "dsh:"+phase+":log", "✗ "+errMsg+"\n") wailsruntime.EventsEmit(a.ctx, doneEvent, InstallResult{Error: errMsg}) } return InstallResult{Error: errMsg} } if a.ctx != nil { wailsruntime.EventsEmit(a.ctx, "dsh:"+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, "dsh:"+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()) } }