2026-08-14 07:52:01 +08:00
package main
import (
"context"
"errors"
2026-08-15 17:18:00 +08:00
"io"
2026-08-14 07:52:01 +08:00
"os"
"os/exec"
2026-08-15 17:18:00 +08:00
"path/filepath"
2026-08-14 07:52:01 +08:00
"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 ) {
2026-08-15 17:18:00 +08:00
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 ` )
2026-08-14 07:52:01 +08:00
if e != nil {
return nil , e
}
defer rows . Close ( )
out := [ ] LaunchApp { }
for rows . Next ( ) {
var x LaunchApp
2026-08-15 17:18:00 +08:00
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 {
2026-08-14 07:52:01 +08:00
return nil , e
}
out = append ( out , x )
}
return out , rows . Err ( )
}
func ( s * Store ) GetLaunchApp ( id int64 ) ( LaunchApp , error ) {
var x LaunchApp
2026-08-15 17:18:00 +08:00
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 )
2026-08-14 07:52:01 +08:00
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"
}
2026-08-15 17:18:00 +08:00
in . Category = strings . TrimSpace ( in . Category )
if in . CategoryID < 0 {
in . CategoryID = 0
}
2026-08-14 07:52:01 +08:00
now := nowRFC ( )
if in . ID == 0 {
2026-08-15 17:18:00 +08:00
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 )
2026-08-14 07:52:01 +08:00
if e != nil {
return in , e
}
in . ID , _ = res . LastInsertId ( )
in . CreatedAt , in . UpdatedAt = now , now
return in , nil
}
2026-08-15 17:18:00 +08:00
_ , 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 )
2026-08-14 07:52:01 +08:00
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 } ,
2026-08-15 17:18:00 +08:00
"exe" : { Start : nil , Stop : nil } ,
2026-08-14 07:52:01 +08:00
"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 { }
}
2026-08-15 17:18:00 +08:00
// 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
}
2026-08-14 07:52:01 +08:00
// ---------- 扫描 ----------
// 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 用与上次采样的差值算速率)。
2026-08-15 17:18:00 +08:00
func probeProcess ( pid int32 ) ( name , exe , cmdline , cwd string , cpu , memMB , ioKBs float64 ) {
2026-08-14 07:52:01 +08:00
p , e := process . NewProcess ( pid )
if e != nil {
return
}
name , _ = p . Name ( )
exe , _ = p . Exe ( )
cmdline , _ = p . Cmdline ( )
2026-08-15 17:18:00 +08:00
cwd , _ = p . Cwd ( )
2026-08-14 07:52:01 +08:00
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 {
2026-08-15 17:18:00 +08:00
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
}
2026-08-14 07:52:01 +08:00
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 ]
2026-08-15 17:18:00 +08:00
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
}
2026-08-14 07:52:01 +08:00
if ent . Kind == "other" || ent . Kind == "" {
ent . Kind = inferLaunchKind ( name , ent . Exe , ent . Cmdline )
}
}
2026-08-15 17:18:00 +08:00
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 )
}
2026-08-14 07:52:01 +08:00
out = append ( out , ent )
}
scanned := [ ] LaunchEntry { }
for pid , ports := range byPid {
if used [ pid ] || pid == self {
continue
}
2026-08-15 17:18:00 +08:00
name , exe , cmdline , cwd , cpu , memMB , ioKBs := probeProcess ( pid )
2026-08-14 07:52:01 +08:00
if name == "" {
name = "PID " + strconv . Itoa ( int ( pid ) )
}
ent := LaunchEntry {
2026-08-15 17:18:00 +08:00
Name : name , Kind : inferLaunchKind ( name , exe , cmdline ) , Dir : cwd ,
2026-08-14 07:52:01 +08:00
Running : true , PID : pid , Exe : exe , Cmdline : cmdline , Ports : ports ,
CPU : cpu , MemMB : memMB , IOKBs : ioKBs ,
}
2026-08-15 17:18:00 +08:00
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
}
}
2026-08-14 07:52:01 +08:00
if len ( ports ) > 0 {
ent . Port = ports [ 0 ]
}
2026-08-15 17:18:00 +08:00
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 )
}
} ( )
}
2026-08-14 07:52:01 +08:00
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 解释执行。
2026-08-15 17:18:00 +08:00
// Windows 上纯 .exe( 可带引号、无额外参数) 直接 exec, 避免 cmd /C 早退误报。
2026-08-14 07:52:01 +08:00
func shellCommand ( line string ) * exec . Cmd {
2026-08-15 17:18:00 +08:00
line = strings . TrimSpace ( line )
2026-08-14 07:52:01 +08:00
if runtime . GOOS == "windows" {
2026-08-15 17:18:00 +08:00
if exe := windowsDirectExe ( line ) ; exe != "" {
return exec . Command ( exe )
}
2026-08-14 07:52:01 +08:00
return exec . Command ( "cmd" , "/C" , line )
}
return exec . Command ( "sh" , "-c" , line )
}
2026-08-15 17:18:00 +08:00
// 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 ""
}
2026-08-14 07:52:01 +08:00
// StartLaunchApp 在应用目录以独立进程组执行启动命令,并记录 PID 供停止时杀进程树。
2026-08-15 17:18:00 +08:00
// 输出写入日志文件再尾随到 launch_logs( 避免 Windows 管道导致壳进程异常退出)。
2026-08-14 07:52:01 +08:00
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" )
}
2026-08-15 17:18:00 +08:00
st := lpState ( app . ID )
st . set ( "starting" , "" , "" )
a . writeLaunchLog ( app . ID , "info" , "▶ 启动 " + app . Name + " · " + app . StartCmd )
a . emit ( "launchpad:changed" , nil )
2026-08-14 07:52:01 +08:00
cmd := shellCommand ( app . StartCmd )
2026-08-15 17:18:00 +08:00
if dir := strings . TrimSpace ( app . Dir ) ; dir != "" {
if info , err := os . Stat ( dir ) ; err == nil && info . IsDir ( ) {
cmd . Dir = dir
}
2026-08-14 07:52:01 +08:00
}
platform . ConfigureDetached ( cmd )
2026-08-15 17:18:00 +08:00
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
2026-08-14 07:52:01 +08:00
if e = cmd . Start ( ) ; e != nil {
2026-08-15 17:18:00 +08:00
_ = 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 )
2026-08-14 07:52:01 +08:00
return app , e
}
pid := int64 ( cmd . Process . Pid )
_ = a . store . setLaunchPID ( app . ID , pid )
app . LastPID = pid
2026-08-15 17:18:00 +08:00
a . writeLaunchLog ( app . ID , "info" , "进程已创建 pid=" + strconv . FormatInt ( pid , 10 ) )
2026-08-14 07:52:01 +08:00
a . store . Log ( "info" , "启动台" , "已启动 " + app . Name , "pid=" + strconv . FormatInt ( pid , 10 ) + " cmd=" + app . StartCmd )
a . emit ( "launchpad:changed" , nil )
2026-08-15 17:18:00 +08:00
go a . tailLaunchProcLog ( app . ID , logPath )
go a . watchLaunchProcess ( app . ID , app . Name , cmd , app . Port , logFile )
2026-08-14 07:52:01 +08:00
return app , nil
}
2026-08-15 17:18:00 +08:00
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 )
}
2026-08-14 07:52:01 +08:00
// 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 )
2026-08-15 17:18:00 +08:00
lpState ( app . ID ) . set ( "stopped" , "" , "" )
a . writeLaunchLog ( app . ID , "info" , "已执行停止命令" )
2026-08-14 07:52:01 +08:00
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 ( ) )
2026-08-15 17:18:00 +08:00
if id > 0 {
lpState ( id ) . set ( "failed" , e . Error ( ) , "" )
a . writeLaunchLog ( id , "error" , "停止失败:" + e . Error ( ) )
a . emit ( "launchpad:changed" , nil )
}
2026-08-14 07:52:01 +08:00
return e
}
if id > 0 {
_ = a . store . setLaunchPID ( id , 0 )
2026-08-15 17:18:00 +08:00
lpState ( id ) . set ( "stopped" , "" , "" )
a . writeLaunchLog ( id , "info" , "已停止" )
2026-08-14 07:52:01 +08:00
}
a . store . Log ( "info" , "启动台" , "已结束进程" , "pid=" + strconv . Itoa ( int ( pid ) ) )
a . emit ( "launchpad:changed" , nil )
return nil
}
2026-08-15 17:18:00 +08:00
// 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 )
}