更新若干功能
This commit is contained in:
456
launchpad.go
456
launchpad.go
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -35,7 +37,7 @@ var (
|
||||
// ---------- Store ----------
|
||||
|
||||
func (s *Store) ListLaunchApps() ([]LaunchApp, error) {
|
||||
rows, e := s.db.Query(`SELECT id,name,kind,port,dir,start_cmd,stop_cmd,last_pid,created_at,updated_at FROM launch_apps ORDER BY id`)
|
||||
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
|
||||
}
|
||||
@@ -43,7 +45,7 @@ func (s *Store) ListLaunchApps() ([]LaunchApp, error) {
|
||||
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.CreatedAt, &x.UpdatedAt); e != nil {
|
||||
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)
|
||||
@@ -53,8 +55,8 @@ func (s *Store) ListLaunchApps() ([]LaunchApp, error) {
|
||||
|
||||
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,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.CreatedAt, &x.UpdatedAt)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -66,10 +68,17 @@ func (s *Store) SaveLaunchApp(in LaunchApp) (LaunchApp, error) {
|
||||
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 {
|
||||
res, e := s.db.Exec(`INSERT INTO launch_apps(name,kind,port,dir,start_cmd,stop_cmd,last_pid,created_at,updated_at) VALUES(?,?,?,?,?,?,0,?,?)`,
|
||||
in.Name, in.Kind, in.Port, in.Dir, in.StartCmd, in.StopCmd, now, now)
|
||||
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
|
||||
}
|
||||
@@ -77,8 +86,8 @@ func (s *Store) SaveLaunchApp(in LaunchApp) (LaunchApp, error) {
|
||||
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=?,updated_at=? WHERE id=?`,
|
||||
in.Name, in.Kind, in.Port, in.Dir, in.StartCmd, in.StopCmd, now, in.ID)
|
||||
_, 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
|
||||
}
|
||||
@@ -134,6 +143,7 @@ var launchSuggestions = map[string]LaunchSuggest{
|
||||
"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"}},
|
||||
@@ -148,6 +158,39 @@ func (a *App) LaunchCmdSuggest(kind string) LaunchSuggest {
|
||||
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 -> 去重后的监听端口列表。
|
||||
@@ -179,7 +222,7 @@ func scanListenPorts() (map[int32][]int, error) {
|
||||
}
|
||||
|
||||
// probeProcess 读取进程静态信息与资源占用(CPU/IO 用与上次采样的差值算速率)。
|
||||
func probeProcess(pid int32) (name, exe, cmdline string, cpu, memMB, ioKBs float64) {
|
||||
func probeProcess(pid int32) (name, exe, cmdline, cwd string, cpu, memMB, ioKBs float64) {
|
||||
p, e := process.NewProcess(pid)
|
||||
if e != nil {
|
||||
return
|
||||
@@ -187,6 +230,7 @@ func probeProcess(pid int32) (name, exe, cmdline string, cpu, memMB, ioKBs float
|
||||
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
|
||||
}
|
||||
@@ -252,7 +296,10 @@ func (a *App) ListLaunchEntries() ([]LaunchEntry, error) {
|
||||
}
|
||||
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}
|
||||
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)] {
|
||||
@@ -265,12 +312,40 @@ func (a *App) ListLaunchEntries() ([]LaunchEntry, error) {
|
||||
if pid > 0 {
|
||||
used[pid] = true
|
||||
ent.Running, ent.PID, ent.Ports = true, pid, byPid[pid]
|
||||
var name string
|
||||
name, ent.Exe, ent.Cmdline, ent.CPU, ent.MemMB, ent.IOKBs = probeProcess(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{}
|
||||
@@ -278,18 +353,40 @@ func (a *App) ListLaunchEntries() ([]LaunchEntry, error) {
|
||||
if used[pid] || pid == self {
|
||||
continue
|
||||
}
|
||||
name, exe, cmdline, cpu, memMB, ioKBs := probeProcess(pid)
|
||||
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),
|
||||
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 })
|
||||
@@ -328,14 +425,46 @@ func (a *App) DeleteLaunchApp(id int64) error {
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -347,24 +476,276 @@ func (a *App) StartLaunchApp(id int64) (LaunchApp, error) {
|
||||
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 st, err := os.Stat(app.Dir); err == nil && st.IsDir() {
|
||||
cmd.Dir = app.Dir
|
||||
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 {
|
||||
a.store.Log("error", "启动台", "启动失败:"+app.Name, e.Error())
|
||||
_ = 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)
|
||||
_ = cmd.Process.Release()
|
||||
_ = 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" {
|
||||
@@ -402,6 +783,8 @@ func (a *App) StopLaunchApp(id int64, pid int32) error {
|
||||
_ = 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
|
||||
}
|
||||
@@ -417,12 +800,49 @@ func (a *App) StopLaunchApp(id int64, pid int32) error {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user