package main import ( "context" "errors" "io" "os" "os/exec" "path/filepath" "runtime" "sort" "strconv" "strings" "sync" "time" gnet "github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/process" "view/platform" ) // ---------------- 启动台:本机监听端口扫描 + 应用启停 ---------------- // launchSample 是进程资源的上一次采样,用于差值计算瞬时 CPU / IO 速率。 type launchSample struct { at time.Time cpuTotal float64 ioBytes uint64 } var ( lpSampleMu sync.Mutex lpSamples = map[int32]launchSample{} ) // ---------- Store ---------- func (s *Store) ListLaunchApps() ([]LaunchApp, error) { rows, e := s.db.Query(`SELECT id,name,kind,port,dir,start_cmd,stop_cmd,last_pid,COALESCE(icon,''),COALESCE(category_id,0),COALESCE(category,''),created_at,updated_at FROM launch_apps ORDER BY id`) if e != nil { return nil, e } defer rows.Close() out := []LaunchApp{} for rows.Next() { var x LaunchApp if e = rows.Scan(&x.ID, &x.Name, &x.Kind, &x.Port, &x.Dir, &x.StartCmd, &x.StopCmd, &x.LastPID, &x.Icon, &x.CategoryID, &x.Category, &x.CreatedAt, &x.UpdatedAt); e != nil { return nil, e } out = append(out, x) } return out, rows.Err() } func (s *Store) GetLaunchApp(id int64) (LaunchApp, error) { var x LaunchApp e := s.db.QueryRow(`SELECT id,name,kind,port,dir,start_cmd,stop_cmd,last_pid,COALESCE(icon,''),COALESCE(category_id,0),COALESCE(category,''),created_at,updated_at FROM launch_apps WHERE id=?`, id). Scan(&x.ID, &x.Name, &x.Kind, &x.Port, &x.Dir, &x.StartCmd, &x.StopCmd, &x.LastPID, &x.Icon, &x.CategoryID, &x.Category, &x.CreatedAt, &x.UpdatedAt) return x, e } func (s *Store) SaveLaunchApp(in LaunchApp) (LaunchApp, error) { in.Name = strings.TrimSpace(in.Name) if in.Name == "" { return in, errors.New("NAME_REQUIRED") } if in.Kind == "" { in.Kind = "other" } in.Category = strings.TrimSpace(in.Category) if in.CategoryID < 0 { in.CategoryID = 0 } now := nowRFC() if in.ID == 0 { if strings.TrimSpace(in.Icon) == "" { in.Icon = resolveLaunchIcon(in.Port, in.Dir) } res, e := s.db.Exec(`INSERT INTO launch_apps(name,kind,port,dir,start_cmd,stop_cmd,last_pid,icon,category_id,category,created_at,updated_at) VALUES(?,?,?,?,?,?,0,?,?,?,?,?)`, in.Name, in.Kind, in.Port, in.Dir, in.StartCmd, in.StopCmd, in.Icon, in.CategoryID, in.Category, now, now) if e != nil { return in, e } in.ID, _ = res.LastInsertId() in.CreatedAt, in.UpdatedAt = now, now return in, nil } _, e := s.db.Exec(`UPDATE launch_apps SET name=?,kind=?,port=?,dir=?,start_cmd=?,stop_cmd=?,icon=?,category_id=?,category=?,updated_at=? WHERE id=?`, in.Name, in.Kind, in.Port, in.Dir, in.StartCmd, in.StopCmd, in.Icon, in.CategoryID, in.Category, now, in.ID) if e != nil { return in, e } return s.GetLaunchApp(in.ID) } func (s *Store) DeleteLaunchApp(id int64) error { _, e := s.db.Exec(`DELETE FROM launch_apps WHERE id=?`, id) return e } func (s *Store) setLaunchPID(id, pid int64) error { _, e := s.db.Exec(`UPDATE launch_apps SET last_pid=?,updated_at=? WHERE id=?`, pid, nowRFC(), id) return e } // ---------- 种类推断与命令推荐 ---------- // inferLaunchKind 根据进程名 / 可执行路径 / 命令行猜项目种类。 func inferLaunchKind(name, exe, cmdline string) string { s := strings.ToLower(name + " " + exe + " " + cmdline) switch { case strings.Contains(s, "mysqld"): return "mysql" case strings.Contains(s, "redis"): return "redis" case strings.Contains(s, "nginx"): return "nginx" case strings.Contains(s, "node") || strings.Contains(s, "vite") || strings.Contains(s, "webpack") || strings.Contains(s, "npm") || strings.Contains(s, "pnpm") || strings.Contains(s, "yarn"): return "node" case strings.Contains(s, "javaw") || strings.Contains(s, "java ") || strings.HasSuffix(strings.TrimSpace(s), "java") || strings.Contains(s, "java.exe"): return "java" case strings.Contains(s, "python") || strings.Contains(s, "uvicorn") || strings.Contains(s, "gunicorn") || strings.Contains(s, "flask") || strings.Contains(s, "django"): return "python" case strings.Contains(s, "php"): return "php" case strings.Contains(s, "dotnet") || strings.Contains(s, "iisexpress") || strings.Contains(s, "w3wp"): return "dotnet" case strings.Contains(s, "go.exe") || strings.Contains(s, "go run") || strings.Contains(s, "__debug_bin"): return "go" case strings.Contains(s, "httpd") || strings.Contains(s, "apache") || strings.Contains(s, "caddy"): return "web" default: return "other" } } // launchSuggestions 按种类推荐启停命令;停止命令留空表示直接结束进程树。 var launchSuggestions = map[string]LaunchSuggest{ "node": {Start: []string{"npm run dev", "npm start", "pnpm dev", "yarn dev"}, Stop: nil}, "go": {Start: []string{"go run .", "go run main.go"}, Stop: nil}, "python": {Start: []string{"python main.py", "uvicorn main:app --reload", "python manage.py runserver"}, Stop: nil}, "java": {Start: []string{"mvn spring-boot:run", "java -jar app.jar", "gradle bootRun"}, Stop: nil}, "php": {Start: []string{"php artisan serve", "php -S 127.0.0.1:8000"}, Stop: nil}, "dotnet": {Start: []string{"dotnet run", "dotnet watch"}, Stop: nil}, "exe": {Start: nil, Stop: nil}, "nginx": {Start: []string{"nginx"}, Stop: []string{"nginx -s stop", "nginx -s quit"}}, "mysql": {Start: []string{"net start mysql", "mysqld --console"}, Stop: []string{"net stop mysql", "mysqladmin -uroot shutdown"}}, "redis": {Start: []string{"redis-server"}, Stop: []string{"redis-cli shutdown"}}, "web": {Start: []string{"caddy run"}, Stop: []string{"caddy stop"}}, "other": {}, } func (a *App) LaunchCmdSuggest(kind string) LaunchSuggest { if s, ok := launchSuggestions[kind]; ok { return s } return LaunchSuggest{} } // DetectLaunchProfile 根据工作目录识别项目类型并推荐启动命令。 func (a *App) DetectLaunchProfile(dir string) (LaunchProfile, error) { if e := a.ready(); e != nil { return LaunchProfile{}, e } p := detectLaunchProfile(dir) if proj, ok := a.matchProjectByDir(dir); ok { p.ProjectID = proj.ID if strings.TrimSpace(p.Name) == "" || p.Name == filepath.Base(dir) { p.Name = proj.Name } } return p, nil } // DraftLaunchFromProject 用「我的项目」生成启动台草稿(种类/命令/端口)。 func (a *App) DraftLaunchFromProject(projectID int64) (LaunchProfile, error) { if e := a.ready(); e != nil { return LaunchProfile{}, e } p, e := a.store.GetProject(projectID) if e != nil { return LaunchProfile{}, errors.New("PROJECT_NOT_FOUND") } prof := detectLaunchProfile(p.Path) prof.ProjectID = p.ID prof.Dir = p.Path if strings.TrimSpace(p.Name) != "" { prof.Name = p.Name } return prof, nil } // ---------- 扫描 ---------- // scanListenPorts 返回 pid -> 去重后的监听端口列表。 func scanListenPorts() (map[int32][]int, error) { conns, e := gnet.Connections("tcp") if e != nil { return nil, e } seen := map[int32]map[int]bool{} for _, c := range conns { if c.Status != "LISTEN" || c.Pid <= 0 { continue } if seen[c.Pid] == nil { seen[c.Pid] = map[int]bool{} } seen[c.Pid][int(c.Laddr.Port)] = true } out := map[int32][]int{} for pid, ports := range seen { lst := make([]int, 0, len(ports)) for p := range ports { lst = append(lst, p) } sort.Ints(lst) out[pid] = lst } return out, nil } // probeProcess 读取进程静态信息与资源占用(CPU/IO 用与上次采样的差值算速率)。 func probeProcess(pid int32) (name, exe, cmdline, cwd string, cpu, memMB, ioKBs float64) { p, e := process.NewProcess(pid) if e != nil { return } name, _ = p.Name() exe, _ = p.Exe() cmdline, _ = p.Cmdline() cwd, _ = p.Cwd() if mi, e := p.MemoryInfo(); e == nil && mi != nil { memMB = float64(mi.RSS) / 1024 / 1024 } now := time.Now() var cpuTotal float64 if ts, e := p.Times(); e == nil && ts != nil { cpuTotal = ts.User + ts.System } var ioBytes uint64 if io, e := p.IOCounters(); e == nil && io != nil { ioBytes = io.ReadBytes + io.WriteBytes } lpSampleMu.Lock() prev, ok := lpSamples[pid] lpSamples[pid] = launchSample{at: now, cpuTotal: cpuTotal, ioBytes: ioBytes} lpSampleMu.Unlock() if ok { wall := now.Sub(prev.at).Seconds() if wall > 0.2 { cpu = (cpuTotal - prev.cpuTotal) / wall * 100 / float64(runtime.NumCPU()) if cpu < 0 { cpu = 0 } if ioBytes >= prev.ioBytes { ioKBs = float64(ioBytes-prev.ioBytes) / wall / 1024 } } } return } // ListLaunchEntries 合并「保存的应用」与「实时扫描到的监听进程」。 func (a *App) ListLaunchEntries() ([]LaunchEntry, error) { if e := a.ready(); e != nil { return nil, e } apps, e := a.store.ListLaunchApps() if e != nil { return nil, e } byPid, e := scanListenPorts() if e != nil { a.store.Log("warning", "启动台", "端口扫描失败", e.Error()) byPid = map[int32][]int{} } self := int32(os.Getpid()) used := map[int32]bool{} findByPort := func(port int) int32 { if port <= 0 { return 0 } for pid, ports := range byPid { if used[pid] || pid == self { continue } for _, p := range ports { if p == port { return pid } } } return 0 } out := []LaunchEntry{} for _, app := range apps { ent := LaunchEntry{ID: app.ID, Name: app.Name, Kind: app.Kind, Port: app.Port, Dir: app.Dir, StartCmd: app.StartCmd, StopCmd: app.StopCmd, Icon: app.Icon, CategoryID: app.CategoryID, Category: app.Category} if proj, ok := a.matchProjectByDir(app.Dir); ok { ent.ProjectID = proj.ID } pid := int32(0) if app.LastPID > 0 { if _, ok := byPid[int32(app.LastPID)]; ok && !used[int32(app.LastPID)] { pid = int32(app.LastPID) } } if pid == 0 { pid = findByPort(app.Port) } if pid > 0 { used[pid] = true ent.Running, ent.PID, ent.Ports = true, pid, byPid[pid] var name, cwd string name, ent.Exe, ent.Cmdline, cwd, ent.CPU, ent.MemMB, ent.IOKBs = probeProcess(pid) if ent.Dir == "" && cwd != "" { ent.Dir = cwd } if ent.Kind == "other" || ent.Kind == "" { ent.Kind = inferLaunchKind(name, ent.Exe, ent.Cmdline) } } st := lpState(app.ID) status, errMsg, lastLog := st.snapshot() if status == "" || status == "stopped" { if ent.Running { status = "running" } else if errMsg != "" { status = "failed" } else { status = "stopped" } } // 壳进程早退可能误标 failed;只要端口已在听就纠回 running。 if ent.Running && (status == "starting" || status == "failed") { st.set("running", "", "") status, errMsg, lastLog = st.snapshot() } if !ent.Running && status == "running" { status = "stopped" st.set("stopped", "", "") } ent.Status, ent.LastError, ent.LastLog = status, errMsg, lastLog if ent.Icon == "" && (ent.Running || ent.Dir != "") { // 后台补抓,避免阻塞列表;下次刷新可见 go a.ensureLaunchIcon(app.ID, app.Port, app.Dir) } out = append(out, ent) } scanned := []LaunchEntry{} for pid, ports := range byPid { if used[pid] || pid == self { continue } name, exe, cmdline, cwd, cpu, memMB, ioKBs := probeProcess(pid) if name == "" { name = "PID " + strconv.Itoa(int(pid)) } ent := LaunchEntry{ Name: name, Kind: inferLaunchKind(name, exe, cmdline), Dir: cwd, Running: true, PID: pid, Exe: exe, Cmdline: cmdline, Ports: ports, CPU: cpu, MemMB: memMB, IOKBs: ioKBs, } if proj, ok := a.matchProjectByDir(cwd); ok { ent.ProjectID = proj.ID ent.Name = proj.Name if ent.Dir == "" { ent.Dir = proj.Path } } else if cwd != "" { // 无项目绑定时,用目录名更易识别 base := filepath.Base(cwd) if base != "" && base != "." && base != string(filepath.Separator) { ent.Name = base + " · " + name } } if len(ports) > 0 { ent.Port = ports[0] } ent.Icon = peekCachedLaunchIcon(ent.Port, ent.Dir) if ent.Icon == "" && (ent.Port > 0 || ent.Dir != "") { p, d := ent.Port, ent.Dir go func() { if resolveLaunchIcon(p, d) != "" { a.emit("launchpad:changed", nil) } }() } scanned = append(scanned, ent) } sort.Slice(scanned, func(i, j int) bool { return scanned[i].Port < scanned[j].Port }) return append(out, scanned...), nil } // ---------- 应用增删与启停 ---------- func (a *App) ListLaunchApps() ([]LaunchApp, error) { if e := a.ready(); e != nil { return nil, e } return a.store.ListLaunchApps() } func (a *App) SaveLaunchApp(in LaunchApp) (LaunchApp, error) { if e := a.ready(); e != nil { return in, e } out, e := a.store.SaveLaunchApp(in) if e == nil { a.emit("launchpad:changed", nil) } return out, e } func (a *App) DeleteLaunchApp(id int64) error { if e := a.ready(); e != nil { return e } e := a.store.DeleteLaunchApp(id) if e == nil { a.emit("launchpad:changed", nil) } return e } // shellCommand 把一行命令交给系统 shell 解释执行。 // Windows 上纯 .exe(可带引号、无额外参数)直接 exec,避免 cmd /C 早退误报。 func shellCommand(line string) *exec.Cmd { line = strings.TrimSpace(line) if runtime.GOOS == "windows" { if exe := windowsDirectExe(line); exe != "" { return exec.Command(exe) } return exec.Command("cmd", "/C", line) } return exec.Command("sh", "-c", line) } // windowsDirectExe 识别可直接启动的单文件 .exe(无 shell 元字符/参数)。 func windowsDirectExe(line string) string { if line == "" { return "" } if strings.HasPrefix(line, `"`) { end := strings.Index(line[1:], `"`) if end < 0 { return "" } path := line[1 : 1+end] rest := strings.TrimSpace(line[2+end:]) if rest == "" && strings.HasSuffix(strings.ToLower(path), ".exe") { return path } return "" } if strings.ContainsAny(line, " \t&|<>^%") { return "" } if strings.HasSuffix(strings.ToLower(line), ".exe") { return line } return "" } // StartLaunchApp 在应用目录以独立进程组执行启动命令,并记录 PID 供停止时杀进程树。 // 输出写入日志文件再尾随到 launch_logs(避免 Windows 管道导致壳进程异常退出)。 func (a *App) StartLaunchApp(id int64) (LaunchApp, error) { if e := a.ready(); e != nil { return LaunchApp{}, e } app, e := a.store.GetLaunchApp(id) if e != nil { return app, errors.New("LAUNCH_APP_NOT_FOUND") } if strings.TrimSpace(app.StartCmd) == "" { return app, errors.New("LAUNCH_CMD_REQUIRED") } st := lpState(app.ID) st.set("starting", "", "") a.writeLaunchLog(app.ID, "info", "▶ 启动 "+app.Name+" · "+app.StartCmd) a.emit("launchpad:changed", nil) cmd := shellCommand(app.StartCmd) if dir := strings.TrimSpace(app.Dir); dir != "" { if info, err := os.Stat(dir); err == nil && info.IsDir() { cmd.Dir = dir } } platform.ConfigureDetached(cmd) logPath, logFile, eLog := a.openLaunchProcLog(app.ID) if eLog != nil { msg := "无法创建启动日志:" + eLog.Error() st.set("failed", msg, "") a.writeLaunchLog(app.ID, "error", msg) a.emit("launchpad:changed", nil) return app, errors.New("LAUNCH_LOG_FAILED") } cmd.Stdout = logFile cmd.Stderr = logFile if e = cmd.Start(); e != nil { _ = logFile.Close() msg := e.Error() st.set("failed", msg, "") a.writeLaunchLog(app.ID, "error", "启动失败:"+msg) a.store.Log("error", "启动台", "启动失败:"+app.Name, msg) a.emit("launchpad:changed", nil) return app, e } pid := int64(cmd.Process.Pid) _ = a.store.setLaunchPID(app.ID, pid) app.LastPID = pid a.writeLaunchLog(app.ID, "info", "进程已创建 pid="+strconv.FormatInt(pid, 10)) a.store.Log("info", "启动台", "已启动 "+app.Name, "pid="+strconv.FormatInt(pid, 10)+" cmd="+app.StartCmd) a.emit("launchpad:changed", nil) go a.tailLaunchProcLog(app.ID, logPath) go a.watchLaunchProcess(app.ID, app.Name, cmd, app.Port, logFile) return app, nil } func (a *App) openLaunchProcLog(appID int64) (string, *os.File, error) { dir, e := os.UserConfigDir() if e != nil { return "", nil, e } dir = filepath.Join(dir, "CodeCount", "launch-logs") if e = os.MkdirAll(dir, 0755); e != nil { return "", nil, e } path := filepath.Join(dir, strconv.FormatInt(appID, 10)+".log") f, e := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) return path, f, e } // tailLaunchProcLog 跟踪进程日志文件新增行,写入本地 launch_logs。 func (a *App) tailLaunchProcLog(appID int64, path string) { f, e := os.Open(path) if e != nil { return } defer f.Close() // 等写入端创建内容;进程退出后最多再读 2s。 deadline := time.Now().Add(30 * time.Minute) idle := 0 buf := make([]byte, 0, 64*1024) tmp := make([]byte, 4096) for time.Now().Before(deadline) { n, err := f.Read(tmp) if n > 0 { idle = 0 buf = append(buf, tmp[:n]...) for { i := -1 for j, b := range buf { if b == '\n' { i = j break } } if i < 0 { break } line := string(buf[:i]) buf = buf[i+1:] level := launchLogLevelFromLine(line) a.writeLaunchLog(appID, level, strings.TrimRight(line, "\r")) } continue } if err != nil && err != io.EOF { return } st, _, _ := lpState(appID).snapshot() if st == "failed" || st == "stopped" { idle++ if idle > 20 { if len(buf) > 0 { a.writeLaunchLog(appID, launchLogLevelFromLine(string(buf)), strings.TrimRight(string(buf), "\r")) } return } } time.Sleep(100 * time.Millisecond) } } // watchLaunchProcess 观察启动后短时是否崩溃;存活则标 running。 // 壳进程(cmd / npm)早退但端口已监听时,接管监听 PID,不标 failed。 func (a *App) watchLaunchProcess(appID int64, name string, cmd *exec.Cmd, port int, logFile *os.File) { st := lpState(appID) done := make(chan error, 1) go func() { err := cmd.Wait() if logFile != nil { _ = logFile.Close() } done <- err }() finishFail := func(e error, prefix string) { if a.adoptLaunchIfPortUp(appID, port, e) { return } msg := "进程已退出" if e != nil { msg = e.Error() } st.set("failed", msg, "") a.writeLaunchLog(appID, "error", prefix+msg) a.store.Log("error", "启动台", "启动失败:"+name, msg) _ = a.store.setLaunchPID(appID, 0) a.emit("launchpad:changed", nil) } timer := time.NewTimer(3 * time.Second) defer timer.Stop() select { case e := <-done: finishFail(e, "启动失败 / 进程退出:") return case <-timer.C: st.set("running", "", "") a.writeLaunchLog(appID, "info", "进程存活,等待服务就绪…") a.emit("launchpad:changed", nil) } if port > 0 { deadline := time.Now().Add(25 * time.Second) for time.Now().Before(deadline) { select { case e := <-done: finishFail(e, "启动后进程退出:") return case <-time.After(800 * time.Millisecond): if pid := findPIDListeningPort(port); pid > 0 { a.writeLaunchLog(appID, "info", "端口 :"+strconv.Itoa(port)+" 已就绪") st.set("running", "", "") a.emit("launchpad:changed", nil) if e := <-done; e != nil { // 监听已建立后壳退出:接管真实 PID,不算失败 if a.adoptLaunchIfPortUp(appID, port, e) { return } st.set("failed", e.Error(), "") a.writeLaunchLog(appID, "error", "运行中退出:"+e.Error()) _ = a.store.setLaunchPID(appID, 0) a.emit("launchpad:changed", nil) } else { if a.adoptLaunchIfPortUp(appID, port, nil) { return } st.set("stopped", "", "") _ = a.store.setLaunchPID(appID, 0) a.writeLaunchLog(appID, "info", "进程已结束") a.emit("launchpad:changed", nil) } return } } } a.writeLaunchLog(appID, "warning", "超时未检测到端口 :"+strconv.Itoa(port)+" 监听(进程仍在运行)") } if e := <-done; e != nil { if a.adoptLaunchIfPortUp(appID, port, e) { return } st.set("failed", e.Error(), "") a.writeLaunchLog(appID, "error", "运行中退出:"+e.Error()) _ = a.store.setLaunchPID(appID, 0) } else { if a.adoptLaunchIfPortUp(appID, port, nil) { return } st.set("stopped", "", "") _ = a.store.setLaunchPID(appID, 0) a.writeLaunchLog(appID, "info", "进程已结束") } a.emit("launchpad:changed", nil) } // adoptLaunchIfPortUp 在启动壳退出后,若配置端口仍在监听则接管该 PID 并保持 running。 func (a *App) adoptLaunchIfPortUp(appID int64, port int, waitErr error) bool { if port <= 0 { return false } var pid int32 for i := 0; i < 4; i++ { if i > 0 { time.Sleep(400 * time.Millisecond) } pid = findPIDListeningPort(port) if pid > 0 { break } } if pid <= 0 { return false } _ = a.store.setLaunchPID(appID, int64(pid)) lpState(appID).set("running", "", "") msg := "启动壳已退出,已接管监听进程 pid=" + strconv.Itoa(int(pid)) if waitErr != nil { msg += "(壳:" + waitErr.Error() + ")" } a.writeLaunchLog(appID, "info", msg) a.emit("launchpad:changed", nil) return true } func findPIDListeningPort(port int) int32 { if port <= 0 { return 0 } byPid, e := scanListenPorts() if e != nil { return 0 } self := int32(os.Getpid()) for pid, ports := range byPid { if pid == self { continue } for _, p := range ports { if p == port { return pid } } } return 0 } func launchPortListening(port int) bool { return findPIDListeningPort(port) > 0 } func (a *App) ensureLaunchIcon(id int64, port int, dir string) { u := resolveLaunchIcon(port, dir) if u == "" { return } _ = a.store.setLaunchIcon(id, u) a.emit("launchpad:changed", nil) } // killProcessTree 结束进程及其全部子进程。 func killProcessTree(ctx context.Context, pid int32) error { if runtime.GOOS == "windows" { _, e := platform.RunHidden(ctx, "taskkill", "/T", "/F", "/PID", strconv.Itoa(int(pid))) return e } _, e := platform.RunHidden(ctx, "kill", "-15", strconv.Itoa(int(pid))) return e } // StopLaunchApp 停止应用:优先执行停止命令,否则结束记录/指定的进程树。 // pid 参数供未保存的扫描条目直接停止。 func (a *App) StopLaunchApp(id int64, pid int32) error { if e := a.ready(); e != nil { return e } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var app LaunchApp if id > 0 { var e error if app, e = a.store.GetLaunchApp(id); e != nil { return errors.New("LAUNCH_APP_NOT_FOUND") } if strings.TrimSpace(app.StopCmd) != "" { cmd := shellCommand(app.StopCmd) if st, err := os.Stat(app.Dir); err == nil && st.IsDir() { cmd.Dir = app.Dir } platform.ConfigureDetached(cmd) if e := cmd.Start(); e != nil { a.store.Log("error", "启动台", "停止命令执行失败:"+app.Name, e.Error()) return e } _ = cmd.Process.Release() _ = a.store.setLaunchPID(app.ID, 0) a.store.Log("info", "启动台", "已执行停止命令:"+app.Name, app.StopCmd) lpState(app.ID).set("stopped", "", "") a.writeLaunchLog(app.ID, "info", "已执行停止命令") a.emit("launchpad:changed", nil) return nil } if pid <= 0 && app.LastPID > 0 { pid = int32(app.LastPID) } } if pid <= 0 { return errors.New("LAUNCH_PID_REQUIRED") } if int(pid) == os.Getpid() { return errors.New("LAUNCH_SELF_FORBIDDEN") } if e := killProcessTree(ctx, pid); e != nil { a.store.Log("error", "启动台", "结束进程失败 pid="+strconv.Itoa(int(pid)), e.Error()) if id > 0 { lpState(id).set("failed", e.Error(), "") a.writeLaunchLog(id, "error", "停止失败:"+e.Error()) a.emit("launchpad:changed", nil) } return e } if id > 0 { _ = a.store.setLaunchPID(id, 0) lpState(id).set("stopped", "", "") a.writeLaunchLog(id, "info", "已停止") } a.store.Log("info", "启动台", "已结束进程", "pid="+strconv.Itoa(int(pid))) a.emit("launchpad:changed", nil) return nil } // RestartLaunchApp 先停止再启动已保存的应用(仅 id>0)。 func (a *App) RestartLaunchApp(id int64) (LaunchApp, error) { if e := a.ready(); e != nil { return LaunchApp{}, e } if id <= 0 { return LaunchApp{}, errors.New("LAUNCH_APP_NOT_FOUND") } app, e := a.store.GetLaunchApp(id) if e != nil { return LaunchApp{}, errors.New("LAUNCH_APP_NOT_FOUND") } if strings.TrimSpace(app.StartCmd) == "" { return app, errors.New("LAUNCH_CMD_REQUIRED") } st := lpState(app.ID) st.set("starting", "", "") a.writeLaunchLog(app.ID, "info", "↻ 重启 "+app.Name) a.emit("launchpad:changed", nil) pid := int32(0) if app.LastPID > 0 { pid = int32(app.LastPID) } // 停止失败不阻断重启(进程可能已退出);短暂等待端口释放。 _ = a.StopLaunchApp(id, pid) time.Sleep(800 * time.Millisecond) return a.StartLaunchApp(id) }