Files
code-utils/launchpad.go
2026-08-15 17:18:00 +08:00

849 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}