magic-agent 2.6.0 → 2.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +188 -16
- package/client-ui/pc/app.css +42 -0
- package/client-ui/pc/js/90-glue.js +3 -0
- package/client-ui/shared/conv/03-chat-message.js +25 -19
- package/client-ui/shared/conv/09-stream-apply.js +9 -0
- package/client-ui/shared/conv/29-msg-variants.js +136 -0
- package/client-ui/shared/conv/conv.css +253 -158
- package/client-ui/shared/conversation.js +39 -2
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +2695 -379
- package/client-ui-mobile/reqs.css +11 -4
- package/client-ui-mobile/reqs.js +49 -5
- package/client-ui-mobile/scripts.css +46 -0
- package/client-ui-mobile/scripts.js +66 -6
- package/cmd/magic-client/browsectl.go +1 -1
- package/cmd/magic-client/clientctl.go +37 -2
- package/cmd/magic-client/main.go +159 -12
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +144 -5
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/stream.go +30 -0
- package/internal/agent/streamjson.go +11 -1
- package/internal/cli/ask.go +8 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +11 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/root.go +35 -6
- package/internal/cli/session.go +69 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +209 -18
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +233 -12
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/requirements.go +9 -1
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +167 -13
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +140 -1
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +4 -0
- package/internal/client/scripts_decode_test.go +16 -11
- package/internal/client/scripts_decode_windows.go +0 -24
- package/internal/client/scripts_test.go +14 -1
- package/internal/client/service.go +403 -87
- package/internal/client/service_test.go +166 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +83 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +8 -1
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +11 -0
- package/package.json +8 -3
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// apps.go - 「发现 → 应用」:magic-* 工程自带的工作台(Web App)在手机上挂号。
|
|
4
|
+
//
|
|
5
|
+
// ## 这个功能是什么
|
|
6
|
+
//
|
|
7
|
+
// magic-shorts / magic-video 各自带一个本地工作台(magic-shorts serve → :8899、
|
|
8
|
+
// magic-video workbench → :7789)。它们绑在桌面端的 127.0.0.1 上,手机连不上;
|
|
9
|
+
// 想在手机上看,就得有人在桌面上起进程、再把地址送到手机。这一页就是这个中间人:
|
|
10
|
+
//
|
|
11
|
+
// 手机 → GET /desk/apps → 有哪些应用、装没装、跑没跑、地址
|
|
12
|
+
// 手机 → POST /desk/apps/{id}/start → 在**桌面端**起工作台(后台进程组),等端口应答再回
|
|
13
|
+
// 手机 → POST /desk/apps/{id}/stop → 连它起的子进程一起收掉
|
|
14
|
+
// 手机 → GET /workbench/{id}/… → 反代到 127.0.0.1:<port>(手机内置浏览器的中转入口)
|
|
15
|
+
// 手机 → GET /{根路径}(Referer 是 /workbench/{id}/) → 同样转给工作台
|
|
16
|
+
//
|
|
17
|
+
// 最后一条是必须的:两个工作台的页面全用**根相对地址**(fetch('/api/…')、
|
|
18
|
+
// '/api/media?…'、模板封面 url 也是 '/api/…')。只挂在子路径下时页面能出来、数据全空。
|
|
19
|
+
// 所以手机打开时先到 /workbench/{id}/(探活 + 跳转),再让页面**在根路径下**跑,
|
|
20
|
+
// 由 handleStatic 按 Referer 认出「这是那个工作台」并转发(见 service.go)。
|
|
21
|
+
//
|
|
22
|
+
// ## 为什么放在插件(magic-client)这一侧而不是 core
|
|
23
|
+
//
|
|
24
|
+
// 与 plugins.go / scripts.go 的理由同源:这不是「跑一轮 agent」,而是一个**常驻的
|
|
25
|
+
// Web 服务**。core 进程跑完一轮就退出,不知道「这个服务现在还活着」。只有常驻的
|
|
26
|
+
// 插件知道,也只有它能 spawn / 杀 / 反代。
|
|
27
|
+
//
|
|
28
|
+
// ## ── 纪律 ──
|
|
29
|
+
//
|
|
30
|
+
// 1) **命令只来自下面这张表**。客户端只传 id 与动作,接口不接受命令行 ——
|
|
31
|
+
// 否则这就是一个任意命令执行器。
|
|
32
|
+
// 2) **不假装成功**:起不来就把输出尾巴(stderr)原样回给手机;`running` 以
|
|
33
|
+
// **端口探活**为准,不看「我 spawn 过没有」。别人(菜单栏 App / 终端)起的同一个
|
|
34
|
+
// 工作台,这里也如实显示成「运行中(外部)」,并且能正常打开。
|
|
35
|
+
// 3) **停止只杀自己起的**:外部实例如实回报「请到桌面端停」—— 我们自己起的进程组
|
|
36
|
+
// 能从外面整组带走;别人起的,杀了就是杀了人家正在干的活儿。
|
|
37
|
+
// 4) **反代只连回环**:目标固定成 127.0.0.1:<该应用的端口>,请求路径里进不来主机名。
|
|
38
|
+
//
|
|
39
|
+
// ## 存储
|
|
40
|
+
//
|
|
41
|
+
// ~/.magic-agent/apps/runs.json 运行态(仅自己起的:pid / 端口 / 启动时刻 / 输出尾巴)
|
|
42
|
+
// ~/.magic-agent/apps/logs/<id>.log 完整输出(起不来时看原因)
|
|
43
|
+
//
|
|
44
|
+
// 服务重启后工作台进程还在(脱离的进程组),但插件忘了它 —— 运行态落盘后靠 pid 探活
|
|
45
|
+
// 认领回来,否则会出现「明明在跑,界面说已停止,点启动又报端口被占」。
|
|
46
|
+
|
|
47
|
+
import (
|
|
48
|
+
"bytes"
|
|
49
|
+
"encoding/json"
|
|
50
|
+
"errors"
|
|
51
|
+
"fmt"
|
|
52
|
+
"io"
|
|
53
|
+
"net"
|
|
54
|
+
"net/http"
|
|
55
|
+
"net/http/httputil"
|
|
56
|
+
"net/url"
|
|
57
|
+
"os"
|
|
58
|
+
"os/exec"
|
|
59
|
+
"path/filepath"
|
|
60
|
+
"strconv"
|
|
61
|
+
"strings"
|
|
62
|
+
"sync"
|
|
63
|
+
"time"
|
|
64
|
+
|
|
65
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/* ─────────────── 数据形状(CLIENT-CONTRACT §2 GET /desk/apps) ─────────────── */
|
|
69
|
+
|
|
70
|
+
// AppState 运行态。State: stopped | starting | running。
|
|
71
|
+
type AppState struct {
|
|
72
|
+
State string `json:"state"`
|
|
73
|
+
// Running 端口上真的有人应答(与 Managed 无关:别人起的工作台也算运行中)。
|
|
74
|
+
Running bool `json:"running"`
|
|
75
|
+
// Managed 是本客户端起的(能从这里停);false + Running = 外部起的。
|
|
76
|
+
Managed bool `json:"managed"`
|
|
77
|
+
PID int `json:"pid,omitempty"`
|
|
78
|
+
// Port 工作台现在实际监听的端口(没在跑 = 0)。
|
|
79
|
+
Port int `json:"port,omitempty"`
|
|
80
|
+
// URL 手机可用的入口(相对地址,见 OpenURL)—— 内置浏览器打开的就是它。
|
|
81
|
+
URL string `json:"url,omitempty"`
|
|
82
|
+
// LocalURL 桌面端自己的地址(手机打不开,给人看的)。
|
|
83
|
+
LocalURL string `json:"localUrl,omitempty"`
|
|
84
|
+
// LogTail 最近几行输出(起不来时就是原因,不让人去桌面翻)。
|
|
85
|
+
LogTail []string `json:"logTail,omitempty"`
|
|
86
|
+
StartedAt int64 `json:"startedAt,omitempty"`
|
|
87
|
+
// Error 人话说明(为什么是这个状态)。
|
|
88
|
+
Error string `json:"error,omitempty"`
|
|
89
|
+
// PortBusy 端口上有人听,但**不是**这套工作台(2026-10-27)。
|
|
90
|
+
//
|
|
91
|
+
// 为什么需要:端口探活只证明「有东西在听」。magic-video 上就撞见过 —— 7789 被一个
|
|
92
|
+
// 10 天前起的旧实例占着,它只服务 /api/*、/ 返回 404,于是手机点「打开」永远是一张
|
|
93
|
+
// 白板,而界面还写着「运行中」。这种状态必须与 running 分开说:先告诉人「端口被占」,
|
|
94
|
+
// 再给原因(GET / 返回了什么),别让人对着「运行中」猜。
|
|
95
|
+
PortBusy bool `json:"portBusy,omitempty"`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// AppItem GET /desk/apps 的一项。
|
|
99
|
+
type AppItem struct {
|
|
100
|
+
ID string `json:"id"`
|
|
101
|
+
Name string `json:"name"`
|
|
102
|
+
Tagline string `json:"tagline"`
|
|
103
|
+
Description string `json:"description,omitempty"`
|
|
104
|
+
Homepage string `json:"homepage,omitempty"`
|
|
105
|
+
CLI string `json:"cli"`
|
|
106
|
+
// Landscape 建议手机横屏打开(内置浏览器据此自动转屏,见 appSpec.Landscape)。
|
|
107
|
+
Landscape bool `json:"landscape,omitempty"`
|
|
108
|
+
Command string `json:"command"`
|
|
109
|
+
Port int `json:"port"`
|
|
110
|
+
Installed bool `json:"installed"`
|
|
111
|
+
SourcePath string `json:"sourcePath,omitempty"`
|
|
112
|
+
State AppState `json:"state"`
|
|
113
|
+
// Note 服务端对「装没装 / 起没起」的一句话解释,客户端原样显示,不自己编。
|
|
114
|
+
Note string `json:"note,omitempty"`
|
|
115
|
+
// OpenURL 手机内置浏览器该打开的地址(相对本服务)。
|
|
116
|
+
//
|
|
117
|
+
// 为什么不直接打开页面本身:手机入口可能是局域网 IP、回环或隧道,相对地址永远
|
|
118
|
+
// 跟着当前入口走;先到这个入口是为了**探活**(没在跑就回一句人话,而不是一个
|
|
119
|
+
// 打不开的白屏),再由服务端跳转到 "/" 让工作台在根路径下跑(见文件头)。
|
|
120
|
+
OpenURL string `json:"openUrl,omitempty"`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ─────────────── 注册表(服务端固定表,不接受客户端输入) ─────────────── */
|
|
124
|
+
|
|
125
|
+
type appSpec struct {
|
|
126
|
+
ID string
|
|
127
|
+
Name string
|
|
128
|
+
Tagline string
|
|
129
|
+
Description string
|
|
130
|
+
Homepage string
|
|
131
|
+
// CLI PATH 上的命令名(探测装没装用)。
|
|
132
|
+
CLI string
|
|
133
|
+
// Port 工作台端口。**显式**传给它 —— 不传的话两端都会「从上次端口文件里捡」,
|
|
134
|
+
// 换项目 / 换机器后手机拿到的地址就是坏的。
|
|
135
|
+
Port int
|
|
136
|
+
// Repo 源码目录(默认 ~/.openclaw/workspace/<工程>,可被环境变量覆盖)。
|
|
137
|
+
Repo string
|
|
138
|
+
// Install 装法(手机上展示;实际安装在「发现 → 插件」那一页)。
|
|
139
|
+
Install string
|
|
140
|
+
/* Landscape 这个工作台按**桌面宽屏**排版(2026-10-27)。
|
|
141
|
+
*
|
|
142
|
+
* 只影响手机:内置浏览器竖屏打开时先转成横屏,并把「切回竖屏」摆在明面上。
|
|
143
|
+
* 为什么不让前端自己量:magic-video 的页面是响应式的,竖屏下 scrollWidth 只溢出
|
|
144
|
+
* 三十几像素(恰好切掉顶栏那排 tab),按「溢出多少」判永远判不出来 —— 但它三栏
|
|
145
|
+
* 时间线在竖屏里确实难用。这种事让应用自己声明,比让浏览器猜准。 */
|
|
146
|
+
Landscape bool
|
|
147
|
+
// Build 由源码目录构造启动命令(argv,不过 shell)。返回 nil = 起不来(并给出原因)。
|
|
148
|
+
Build func(repo string, node string) ([]string, error)
|
|
149
|
+
// Urls 这台机器上「这个工作台现在到底在哪」的探测顺序:先看应用自己写到
|
|
150
|
+
// 数据目录的端口文件(~/.magic-video/workbench.port),再看默认端口。
|
|
151
|
+
// 外部起的实例(端口被显式换过)靠它也能被认出来。
|
|
152
|
+
Urls func() []int
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// nodeBin 找 node 可执行文件:PATH 上没有时,顺着 PATH 上的 npm / npx 的链接找同目录的 node。
|
|
156
|
+
//
|
|
157
|
+
// 为什么要这么绕:桌面端若从 GUI(菜单栏 / 启动项)起,PATH 往往只有 /usr/bin:/bin,
|
|
158
|
+
// Homebrew / nvm 的 node 不在里面 —— `magic-shorts serve` 直接「找不到 node」。
|
|
159
|
+
// 顺着 npm 的符号链接走,是最不依赖具体安装方式的一条路。
|
|
160
|
+
func nodeBin() (string, bool) {
|
|
161
|
+
if p, err := exec.LookPath("node"); err == nil {
|
|
162
|
+
return p, true
|
|
163
|
+
}
|
|
164
|
+
for _, tool := range []string{"npm", "npx"} {
|
|
165
|
+
p, err := exec.LookPath(tool)
|
|
166
|
+
if err != nil {
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
if real, err := filepath.EvalSymlinks(p); err == nil {
|
|
170
|
+
p = real
|
|
171
|
+
}
|
|
172
|
+
dir := filepath.Dir(p)
|
|
173
|
+
for _, name := range []string{"node", "node.exe"} {
|
|
174
|
+
cand := filepath.Join(dir, name)
|
|
175
|
+
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
|
|
176
|
+
return cand, true
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return "", false
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// appSpecs 返回注册表与「本机能做什么」的提示(例如没有 node)。
|
|
184
|
+
func appSpecs() ([]appSpec, []string) {
|
|
185
|
+
home, _ := os.UserHomeDir()
|
|
186
|
+
workspace := filepath.Join(home, ".openclaw", "workspace")
|
|
187
|
+
if v := strings.TrimSpace(os.Getenv("MAGIC_CLIENT_WORKSPACE")); v != "" {
|
|
188
|
+
workspace = v
|
|
189
|
+
}
|
|
190
|
+
dataHome := func(app string) string {
|
|
191
|
+
if v := strings.TrimSpace(os.Getenv("MAGIC_CLIENT_APPS_HOME")); v != "" {
|
|
192
|
+
return filepath.Join(v, "."+app)
|
|
193
|
+
}
|
|
194
|
+
return filepath.Join(home, "."+app)
|
|
195
|
+
}
|
|
196
|
+
// portFilePorts 读应用写下的端口文件(一行整数),再补上默认端口(去重保序)。
|
|
197
|
+
portFilePorts := func(defaultPort int, files ...string) func() []int {
|
|
198
|
+
return func() []int {
|
|
199
|
+
out := []int{defaultPort}
|
|
200
|
+
for _, f := range files {
|
|
201
|
+
b, err := os.ReadFile(f)
|
|
202
|
+
if err != nil {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
n, err := strconv.Atoi(strings.TrimSpace(string(b)))
|
|
206
|
+
if err != nil || n <= 0 || n > 65535 {
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
dup := false
|
|
210
|
+
for _, x := range out {
|
|
211
|
+
if x == n {
|
|
212
|
+
dup = true
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if !dup {
|
|
216
|
+
out = append([]int{n}, out...)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
specs := []appSpec{
|
|
224
|
+
{
|
|
225
|
+
ID: "magic-shorts",
|
|
226
|
+
Name: "magic-shorts",
|
|
227
|
+
Tagline: "知识短视频工作台:选题 → 脚本 → 配音 → 素材 → 成片",
|
|
228
|
+
Description: "magic-shorts 的本地工作台:按项目看六个阶段的进度、跑单阶段、看脚本与成片。" +
|
|
229
|
+
"启动后在手机内置浏览器里打开(走桌面端的网络)。",
|
|
230
|
+
Homepage: "https://github.com/javacn/magic-shorts",
|
|
231
|
+
CLI: "magic-shorts",
|
|
232
|
+
Port: 8899,
|
|
233
|
+
Repo: filepath.Join(workspace, "magic-shorts"),
|
|
234
|
+
Install: "npm install -g <magic-shorts 源码目录>",
|
|
235
|
+
Build: func(repo, node string) ([]string, error) {
|
|
236
|
+
server := filepath.Join(repo, "server.js")
|
|
237
|
+
if _, err := os.Stat(server); err != nil {
|
|
238
|
+
return nil, fmt.Errorf("%s 里没有 server.js", repo)
|
|
239
|
+
}
|
|
240
|
+
if node == "" {
|
|
241
|
+
return nil, errors.New("这台桌面找不到 node")
|
|
242
|
+
}
|
|
243
|
+
return []string{node, server}, nil
|
|
244
|
+
},
|
|
245
|
+
Urls: portFilePorts(8899, filepath.Join(dataHome("magic-shorts"), "workbench.port")),
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
ID: "magic-video",
|
|
249
|
+
Name: "magic-video",
|
|
250
|
+
Tagline: "短剧 / 小说 / 视频分镜工作台:一站式平台",
|
|
251
|
+
Description: "magic-video 的本地工作台:小说、短剧、视频三条线的管线可视化与运行控制。" +
|
|
252
|
+
"启动后在手机内置浏览器里打开(走桌面端的网络)。",
|
|
253
|
+
Homepage: "https://github.com/javacn/magic-video",
|
|
254
|
+
CLI: "magic-video",
|
|
255
|
+
Port: 7789,
|
|
256
|
+
Repo: filepath.Join(workspace, "magic-video"),
|
|
257
|
+
Install: "npm install -g @magicstock/magic-cli",
|
|
258
|
+
// 三栏 + 时间线 + 分章表格:竖屏里顶栏那排 tab 直接被切(2026-10-27 实测)。
|
|
259
|
+
Landscape: true,
|
|
260
|
+
Build: func(repo, node string) ([]string, error) {
|
|
261
|
+
dir := filepath.Join(repo, "workbench")
|
|
262
|
+
server := filepath.Join(dir, "server.js")
|
|
263
|
+
if _, err := os.Stat(server); err != nil {
|
|
264
|
+
return nil, fmt.Errorf("%s 里没有 workbench/server.js", repo)
|
|
265
|
+
}
|
|
266
|
+
if node == "" {
|
|
267
|
+
return nil, errors.New("这台桌面找不到 node")
|
|
268
|
+
}
|
|
269
|
+
// server.js 在 cwd 里找 workbench/lib/*,所以要先进 workbench/。
|
|
270
|
+
// 端口显式给:不给时它会对「上次端口」做粘性探测,而反代目标固定,两边必须一致。
|
|
271
|
+
script := "process.chdir(" + jsQuote(dir) + ");" +
|
|
272
|
+
"require(" + jsQuote(server) + ").start(" + strconv.Itoa(7789) + ");"
|
|
273
|
+
return []string{node, "-e", script}, nil
|
|
274
|
+
},
|
|
275
|
+
Urls: portFilePorts(7789, filepath.Join(dataHome("magic-video"), "workbench.port")),
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
// 允许用环境变量指向别处的源码(与 plugins.go 的 MAGIC_CLIENT_*_DIR 同一套名字)。
|
|
279
|
+
for i := range specs {
|
|
280
|
+
env := "MAGIC_CLIENT_VIDEO_DIR"
|
|
281
|
+
if specs[i].ID == "magic-shorts" {
|
|
282
|
+
env = "MAGIC_CLIENT_SHORTS_DIR"
|
|
283
|
+
}
|
|
284
|
+
if v := strings.TrimSpace(os.Getenv(env)); v != "" {
|
|
285
|
+
specs[i].Repo = v
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
var notes []string
|
|
289
|
+
if _, ok := nodeBin(); !ok {
|
|
290
|
+
notes = append(notes, "这台桌面找不到 node —— 工作台起不来")
|
|
291
|
+
}
|
|
292
|
+
return specs, notes
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// jsQuote 把路径转成 JS 字符串字面量(桌面路径里可能有空格 / 反斜杠 / 中文)。
|
|
296
|
+
func jsQuote(s string) string {
|
|
297
|
+
b, _ := json.Marshal(s)
|
|
298
|
+
return string(b)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func findApp(id string) (appSpec, bool) {
|
|
302
|
+
specs, _ := appSpecs()
|
|
303
|
+
id = strings.TrimSpace(strings.ToLower(id))
|
|
304
|
+
for _, s := range specs {
|
|
305
|
+
if s.ID == id {
|
|
306
|
+
return s, true
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return appSpec{}, false
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* ─────────────── 管理器 ─────────────── */
|
|
313
|
+
|
|
314
|
+
// appRun 自己起的一次运行(落盘)。
|
|
315
|
+
type appRun struct {
|
|
316
|
+
PID int `json:"pid"`
|
|
317
|
+
Port int `json:"port"`
|
|
318
|
+
StartedAt int64 `json:"startedAt"`
|
|
319
|
+
Lines []string `json:"lines,omitempty"`
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
type appManager struct {
|
|
323
|
+
path string // ~/.magic-agent/apps/runs.json;空 = 不落盘
|
|
324
|
+
|
|
325
|
+
mu sync.Mutex
|
|
326
|
+
runs map[string]*appRun
|
|
327
|
+
// probe 端口探活缓存:手机页面会轮询,不能每次探两遍(启动/停止后强制刷新)。
|
|
328
|
+
probe map[int]appProbe
|
|
329
|
+
/* specOf 覆盖注册表里的某几项(只给测试用:把真实端口 8899 / 7789 换成临时端口,
|
|
330
|
+
否则本机开着的工作台会把用例短路成「运行中(外部)」)。nil = 用注册表原样。 */
|
|
331
|
+
specOf func(appSpec) appSpec
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// spec 取注册表项(应用测试覆盖)。
|
|
335
|
+
func (m *appManager) spec(spec appSpec) appSpec {
|
|
336
|
+
if m.specOf != nil {
|
|
337
|
+
return m.specOf(spec)
|
|
338
|
+
}
|
|
339
|
+
return spec
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
type appProbe struct {
|
|
343
|
+
at time.Time
|
|
344
|
+
running bool
|
|
345
|
+
// busy = 端口有人在听,但探针认不出它是工作台(见 appManager.probePort)。
|
|
346
|
+
busy bool
|
|
347
|
+
// why = 为什么判成 busy(「GET / 返回 404」这种人话),直接给用户看。
|
|
348
|
+
why string
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const (
|
|
352
|
+
envAppsDir = "MAGIC_CLIENT_APPS_DIR"
|
|
353
|
+
// appProbeTTL 端口探活结果的缓存时长。
|
|
354
|
+
appProbeTTL = 1 * time.Second
|
|
355
|
+
// appStartWait 起工作台时最多等多久(Node 起一个工作台通常 1–3 秒;
|
|
356
|
+
// magic-shorts 首次还要做素材时长预热,多留些余量)。
|
|
357
|
+
appStartWait = 20 * time.Second
|
|
358
|
+
// appLogLines 落盘保留的输出行数(手机上只需要尾巴)。
|
|
359
|
+
appLogLines = 40
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
func newAppManager(s *Service) *appManager {
|
|
363
|
+
m := &appManager{runs: map[string]*appRun{}, probe: map[int]appProbe{}}
|
|
364
|
+
dir := strings.TrimSpace(os.Getenv(envAppsDir))
|
|
365
|
+
if dir == "" {
|
|
366
|
+
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
|
367
|
+
dir = filepath.Join(home, ".magic-agent", "apps")
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if dir == "" {
|
|
371
|
+
return m
|
|
372
|
+
}
|
|
373
|
+
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
374
|
+
if s.log != nil {
|
|
375
|
+
s.log.Printf("应用目录创建失败 %s(%v),运行态将不落盘", dir, err)
|
|
376
|
+
}
|
|
377
|
+
return m
|
|
378
|
+
}
|
|
379
|
+
m.path = filepath.Join(dir, "runs.json")
|
|
380
|
+
if b, err := os.ReadFile(m.path); err == nil && json.Valid(b) {
|
|
381
|
+
var runs map[string]*appRun
|
|
382
|
+
if json.Unmarshal(b, &runs) == nil {
|
|
383
|
+
for id, r := range runs {
|
|
384
|
+
if r == nil || r.PID <= 0 || !agent.PIDAlive(r.PID) {
|
|
385
|
+
continue // 早退了:当作没起过
|
|
386
|
+
}
|
|
387
|
+
m.runs[id] = r
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return m
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
func (s *Service) appMgr() *appManager {
|
|
395
|
+
s.mu.Lock()
|
|
396
|
+
defer s.mu.Unlock()
|
|
397
|
+
if s.apps == nil {
|
|
398
|
+
s.apps = newAppManager(s)
|
|
399
|
+
}
|
|
400
|
+
return s.apps
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// saveRuns 落盘运行态(调用方持锁或单 goroutine 场景;内部自己加锁)。
|
|
404
|
+
func (m *appManager) saveRuns() {
|
|
405
|
+
if m.path == "" {
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
m.mu.Lock()
|
|
409
|
+
b, err := json.MarshalIndent(m.runs, "", " ")
|
|
410
|
+
m.mu.Unlock()
|
|
411
|
+
if err != nil {
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
tmp := m.path + ".tmp"
|
|
415
|
+
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
_ = os.Rename(tmp, m.path)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// probeRunning 探测 127.0.0.1:port 上有没有 HTTP 服务(任何状态码都算在跑)。
|
|
422
|
+
func (m *appManager) probeRunning(port int, fresh bool) bool {
|
|
423
|
+
return m.probePort(port, fresh).running
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* probePort 探一个端口:**光有东西在听不算数**,还得是这套工作台的页面。
|
|
427
|
+
*
|
|
428
|
+
* 判据是 GET / 的响应:2xx/3xx + Content-Type 是 text/html。三条都满足才算 running。
|
|
429
|
+
* 只听端口(旧行为)会把「别的程序占着端口」误报成「运行中」,手机上于是点开一片白 ——
|
|
430
|
+
* magic-video 的 7789 就是这么被一个只用 API 的旧实例骗过去的(/ 返回 404)。
|
|
431
|
+
*
|
|
432
|
+
* 监听但没有页面 → busy:不是运行中,但也不是「没起」,得分开告诉用户。
|
|
433
|
+
*/
|
|
434
|
+
func (m *appManager) probePort(port int, fresh bool) appProbe {
|
|
435
|
+
if port <= 0 {
|
|
436
|
+
return appProbe{}
|
|
437
|
+
}
|
|
438
|
+
if !fresh {
|
|
439
|
+
m.mu.Lock()
|
|
440
|
+
p, ok := m.probe[port]
|
|
441
|
+
m.mu.Unlock()
|
|
442
|
+
if ok && time.Since(p.at) < appProbeTTL {
|
|
443
|
+
return p
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
client := http.Client{Timeout: 1200 * time.Millisecond}
|
|
447
|
+
resp, err := client.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/")
|
|
448
|
+
p := appProbe{at: time.Now()}
|
|
449
|
+
if err != nil {
|
|
450
|
+
// 连不上:要么没人听,要么只听了 TCP 不说 HTTP(都按「没在跑」算)。
|
|
451
|
+
m.mu.Lock()
|
|
452
|
+
m.probe[port] = p
|
|
453
|
+
m.mu.Unlock()
|
|
454
|
+
return p
|
|
455
|
+
}
|
|
456
|
+
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
|
457
|
+
resp.Body.Close()
|
|
458
|
+
ctype := strings.ToLower(resp.Header.Get("Content-Type"))
|
|
459
|
+
switch {
|
|
460
|
+
case resp.StatusCode >= 500:
|
|
461
|
+
p.why = fmt.Sprintf("GET / 返回 %d", resp.StatusCode)
|
|
462
|
+
case resp.StatusCode >= 400:
|
|
463
|
+
p.why = fmt.Sprintf("端口 %d 上有个程序在听,但它不提供工作台页面(GET / 返回 %d)", port, resp.StatusCode)
|
|
464
|
+
case !strings.Contains(ctype, "text/html"):
|
|
465
|
+
p.why = fmt.Sprintf("端口 %d 上有个程序在听,但返回的不是页面(%s)", port, strings.TrimSpace(ctype))
|
|
466
|
+
default:
|
|
467
|
+
p.running = true
|
|
468
|
+
}
|
|
469
|
+
p.busy = !p.running
|
|
470
|
+
m.mu.Lock()
|
|
471
|
+
m.probe[port] = p
|
|
472
|
+
m.mu.Unlock()
|
|
473
|
+
return p
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// locate 探测这个工作台现在在哪:先看自己起的 pid 对应的端口,再按应用自己的端口文件顺序探。
|
|
477
|
+
// 返回 (端口, 是否在跑)。
|
|
478
|
+
func (m *appManager) locate(spec appSpec, fresh bool) (int, bool) {
|
|
479
|
+
port, running, _, _ := m.locateDetail(spec, fresh)
|
|
480
|
+
return port, running
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// locateDetail 同 locate,但把「端口被占」的原因也带出来(给 info 用)。
|
|
484
|
+
func (m *appManager) locateDetail(spec appSpec, fresh bool) (port int, running bool, busy bool, why string) {
|
|
485
|
+
spec = m.spec(spec)
|
|
486
|
+
m.mu.Lock()
|
|
487
|
+
run := m.runs[spec.ID]
|
|
488
|
+
m.mu.Unlock()
|
|
489
|
+
ports := []int{}
|
|
490
|
+
if run != nil && agent.PIDAlive(run.PID) {
|
|
491
|
+
ports = append(ports, run.Port)
|
|
492
|
+
}
|
|
493
|
+
if spec.Urls != nil {
|
|
494
|
+
ports = append(ports, spec.Urls()...)
|
|
495
|
+
} else {
|
|
496
|
+
ports = append(ports, spec.Port)
|
|
497
|
+
}
|
|
498
|
+
for _, p := range ports {
|
|
499
|
+
if p <= 0 {
|
|
500
|
+
continue
|
|
501
|
+
}
|
|
502
|
+
pr := m.probePort(p, fresh)
|
|
503
|
+
if pr.running {
|
|
504
|
+
return p, true, false, ""
|
|
505
|
+
}
|
|
506
|
+
if pr.busy && !busy {
|
|
507
|
+
port, busy, why = p, true, pr.why
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return port, false, busy, why
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// info 组装一个应用的当前状态(不启动任何东西)。
|
|
514
|
+
func (m *appManager) info(spec appSpec, fresh bool) AppItem {
|
|
515
|
+
item := AppItem{
|
|
516
|
+
ID: spec.ID, Name: spec.Name, Tagline: spec.Tagline, Description: spec.Description,
|
|
517
|
+
Homepage: spec.Homepage, CLI: spec.CLI, Landscape: spec.Landscape,
|
|
518
|
+
Command: "PORT=" + strconv.Itoa(spec.Port) + " " + spec.CLI +
|
|
519
|
+
" " + appLaunchArg(spec) + " # 由本客户端等价启动(node 直接跑工作台入口)",
|
|
520
|
+
Port: spec.Port,
|
|
521
|
+
}
|
|
522
|
+
if _, err := exec.LookPath(spec.CLI); err == nil {
|
|
523
|
+
item.Installed = true
|
|
524
|
+
}
|
|
525
|
+
if st, err := os.Stat(spec.Repo); err == nil && st.IsDir() {
|
|
526
|
+
item.SourcePath = spec.Repo
|
|
527
|
+
}
|
|
528
|
+
m.mu.Lock()
|
|
529
|
+
run := m.runs[spec.ID]
|
|
530
|
+
m.mu.Unlock()
|
|
531
|
+
|
|
532
|
+
st := AppState{State: "stopped", URL: workbookBase(spec.ID)}
|
|
533
|
+
if run != nil && !agent.PIDAlive(run.PID) {
|
|
534
|
+
// 进程没了(自己退了 / 在桌面强杀):清掉记录,别让它一直挂着。
|
|
535
|
+
m.mu.Lock()
|
|
536
|
+
delete(m.runs, spec.ID)
|
|
537
|
+
m.mu.Unlock()
|
|
538
|
+
m.saveRuns()
|
|
539
|
+
run = nil
|
|
540
|
+
}
|
|
541
|
+
if run != nil {
|
|
542
|
+
st.PID = run.PID
|
|
543
|
+
st.StartedAt = run.StartedAt
|
|
544
|
+
st.LogTail = tailLines(run.Lines, 8)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
port, running, busy, why := m.locateDetail(spec, fresh)
|
|
548
|
+
switch {
|
|
549
|
+
case running:
|
|
550
|
+
st.State = "running"
|
|
551
|
+
st.Running = true
|
|
552
|
+
st.Port = port
|
|
553
|
+
st.LocalURL = fmt.Sprintf("http://127.0.0.1:%d/", port)
|
|
554
|
+
item.OpenURL = st.URL
|
|
555
|
+
if run != nil {
|
|
556
|
+
st.Managed = true
|
|
557
|
+
item.Note = "运行中(本客户端启动),可以从手机停止"
|
|
558
|
+
} else {
|
|
559
|
+
item.Note = "运行中(桌面端终端 / 菜单栏起的):手机可以打开,停止请到桌面端"
|
|
560
|
+
if last := tailFile(m.logDir(), spec.ID, 8); len(last) > 0 {
|
|
561
|
+
st.LogTail = last
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
case run != nil:
|
|
565
|
+
st.State = "starting"
|
|
566
|
+
st.Port = run.Port
|
|
567
|
+
item.Note = "启动中…"
|
|
568
|
+
case busy:
|
|
569
|
+
/* 端口被占、但占着的不是这套工作台:状态既不是 running 也不是 stopped。
|
|
570
|
+
说清「谁占了、为什么打开是白的」,并给出唯一的出路(桌面端先停掉那个)。 */
|
|
571
|
+
st.State = "blocked"
|
|
572
|
+
st.Port = port
|
|
573
|
+
st.PortBusy = true
|
|
574
|
+
st.Error = why
|
|
575
|
+
item.Note = why + " —— 先到桌面端把它停掉,再从这里启动"
|
|
576
|
+
default:
|
|
577
|
+
switch {
|
|
578
|
+
case item.SourcePath == "" && !item.Installed:
|
|
579
|
+
item.Note = "未找到:" + spec.Repo + " 不在,PATH 上也没有 " + spec.CLI
|
|
580
|
+
case item.SourcePath == "":
|
|
581
|
+
item.Note = "源码目录不在:" + spec.Repo + "(PATH 上有 " + spec.CLI + ",但不是这套工作台的源码)"
|
|
582
|
+
default:
|
|
583
|
+
item.Note = "已停止:启动后由手机内置浏览器打开(走桌面端的网络)"
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
item.State = st
|
|
587
|
+
return item
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// appLaunchArg 命令展示用的子命令名(只是给人看的说明,不是真实 argv)。
|
|
591
|
+
func appLaunchArg(spec appSpec) string {
|
|
592
|
+
if spec.ID == "magic-video" {
|
|
593
|
+
return "workbench --no-browser"
|
|
594
|
+
}
|
|
595
|
+
return "serve"
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
func workbookBase(id string) string { return workbenchPrefix + id + "/" }
|
|
599
|
+
|
|
600
|
+
func (m *appManager) logDir() string {
|
|
601
|
+
if m.path == "" {
|
|
602
|
+
return ""
|
|
603
|
+
}
|
|
604
|
+
return filepath.Join(filepath.Dir(m.path), "logs")
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// tailFile 读自己起过的进程留下的输出尾巴(外部实例读不到,给空)。
|
|
608
|
+
func tailFile(dir, id string, n int) []string {
|
|
609
|
+
if dir == "" {
|
|
610
|
+
return nil
|
|
611
|
+
}
|
|
612
|
+
b, err := os.ReadFile(filepath.Join(dir, id+".log"))
|
|
613
|
+
if err != nil {
|
|
614
|
+
return nil
|
|
615
|
+
}
|
|
616
|
+
return tailLines(strings.Split(strings.TrimRight(string(b), "\r\n"), "\n"), n)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// tailLines 取最后 n 行(feishu.go 里的 tail 是「截最后 n 个字符」,别混)。
|
|
620
|
+
func tailLines(lines []string, n int) []string {
|
|
621
|
+
lines = append([]string(nil), lines...)
|
|
622
|
+
if len(lines) <= n {
|
|
623
|
+
return lines
|
|
624
|
+
}
|
|
625
|
+
return lines[len(lines)-n:]
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// list 全部应用(不启动任何东西;外部实例也如实显示为运行中)。
|
|
629
|
+
func (m *appManager) list() []AppItem {
|
|
630
|
+
specs, _ := appSpecs()
|
|
631
|
+
items := make([]AppItem, 0, len(specs))
|
|
632
|
+
for _, s := range specs {
|
|
633
|
+
items = append(items, m.info(s, false))
|
|
634
|
+
}
|
|
635
|
+
return items
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// start 起一个工作台,等到端口真的应答再返回。
|
|
639
|
+
func (m *appManager) start(spec appSpec) (AppItem, error) {
|
|
640
|
+
// 日志目录跟着**管理器**走(测试里是临时目录)。
|
|
641
|
+
logDir := m.logDir()
|
|
642
|
+
if m.specOf != nil {
|
|
643
|
+
orig := spec
|
|
644
|
+
spec = m.spec(spec)
|
|
645
|
+
if orig.Build != nil {
|
|
646
|
+
spec.Build = orig.Build
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
m.mu.Lock()
|
|
650
|
+
busy := m.runs[spec.ID] != nil && agent.PIDAlive(m.runs[spec.ID].PID)
|
|
651
|
+
m.mu.Unlock()
|
|
652
|
+
if busy {
|
|
653
|
+
return AppItem{}, errAppStarting
|
|
654
|
+
}
|
|
655
|
+
// 端口上已经有人在跑这套工作台(终端 / 菜单栏起的)→ 不重复起,直接给现状。
|
|
656
|
+
if _, ok := m.locate(spec, true); ok {
|
|
657
|
+
return m.info(spec, true), nil
|
|
658
|
+
}
|
|
659
|
+
/* 端口被别的程序占着(典型:上一版只服务 /api 的旧实例):
|
|
660
|
+
与其等 node 报 EADDRINUSE 再翻译,不如现在就把「谁占了、为什么」说清楚。 */
|
|
661
|
+
if _, _, busy, why := m.locateDetail(spec, true); busy {
|
|
662
|
+
return AppItem{}, errors.New(why + " —— 先到桌面端把它停掉,再从这里启动")
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
node, _ := nodeBin()
|
|
666
|
+
argv, err := spec.Build(spec.Repo, node)
|
|
667
|
+
if err != nil {
|
|
668
|
+
return AppItem{}, fmt.Errorf("起不来:%v(源码目录:%s,可用环境变量指向别处)", err, spec.Repo)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
var lf *os.File
|
|
672
|
+
if logDir != "" {
|
|
673
|
+
if err := os.MkdirAll(logDir, 0o700); err == nil {
|
|
674
|
+
if f, err := os.OpenFile(filepath.Join(logDir, spec.ID+".log"), os.O_CREATE|os.O_WRONLY, 0o600); err == nil {
|
|
675
|
+
lf = f
|
|
676
|
+
// 上一次运行的输出不该混进这一次的尾巴里:每次启动前清空。
|
|
677
|
+
_ = f.Truncate(0)
|
|
678
|
+
fmt.Fprintf(f, "── %s %s ──\n", time.Now().Format("2006-01-02 15:04:05"), strings.Join(argv, " "))
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/* 进程组 + 不绑请求 context:与 scripts.go 同一套。手机断连 / 退后台不该把工作台
|
|
684
|
+
带走 —— 工作台是给人刷的,人一离开页面它就没了才叫离谱。 */
|
|
685
|
+
cmd := exec.Command(argv[0], argv[1:]...)
|
|
686
|
+
cmd.Dir = spec.Repo
|
|
687
|
+
cmd.Env = agent.ChildEnvWith([]string{"PORT=" + strconv.Itoa(spec.Port), "HOST=127.0.0.1"})
|
|
688
|
+
agent.ConfigureProcGroup(cmd)
|
|
689
|
+
noConsole(cmd)
|
|
690
|
+
if lf != nil {
|
|
691
|
+
cmd.Stdout = lf
|
|
692
|
+
cmd.Stderr = lf
|
|
693
|
+
}
|
|
694
|
+
if err := cmd.Start(); err != nil {
|
|
695
|
+
if lf != nil {
|
|
696
|
+
fmt.Fprintf(lf, "启动失败:%v\n", err)
|
|
697
|
+
lf.Close()
|
|
698
|
+
}
|
|
699
|
+
return AppItem{}, fmt.Errorf("启动工作台失败:%w", err)
|
|
700
|
+
}
|
|
701
|
+
if lf != nil {
|
|
702
|
+
_ = lf.Close() // 子进程已经继承了 fd
|
|
703
|
+
}
|
|
704
|
+
pid := cmd.Process.Pid
|
|
705
|
+
run := &appRun{PID: pid, Port: spec.Port, StartedAt: time.Now().UnixMilli()}
|
|
706
|
+
m.mu.Lock()
|
|
707
|
+
m.runs[spec.ID] = run
|
|
708
|
+
m.mu.Unlock()
|
|
709
|
+
m.saveRuns()
|
|
710
|
+
|
|
711
|
+
exited := make(chan struct{})
|
|
712
|
+
go func() {
|
|
713
|
+
_ = cmd.Wait() // 收尸:不 Wait 会留僵尸进程
|
|
714
|
+
close(exited)
|
|
715
|
+
m.mu.Lock()
|
|
716
|
+
if r := m.runs[spec.ID]; r != nil && r.PID == pid {
|
|
717
|
+
if lines := tailFile(logDir, spec.ID, appLogLines); len(lines) > 0 {
|
|
718
|
+
r.Lines = lines
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
m.mu.Unlock()
|
|
722
|
+
m.saveRuns()
|
|
723
|
+
m.probeRunning(spec.Port, true)
|
|
724
|
+
}()
|
|
725
|
+
|
|
726
|
+
deadline := time.Now().Add(appStartWait)
|
|
727
|
+
for time.Now().Before(deadline) {
|
|
728
|
+
if m.probeRunning(spec.Port, true) {
|
|
729
|
+
m.mu.Lock()
|
|
730
|
+
if r := m.runs[spec.ID]; r != nil && r.PID == pid {
|
|
731
|
+
r.Lines = tailFile(logDir, spec.ID, appLogLines)
|
|
732
|
+
}
|
|
733
|
+
m.mu.Unlock()
|
|
734
|
+
m.saveRuns()
|
|
735
|
+
return m.info(spec, true), nil
|
|
736
|
+
}
|
|
737
|
+
if !agent.PIDAlive(pid) {
|
|
738
|
+
break
|
|
739
|
+
}
|
|
740
|
+
time.Sleep(200 * time.Millisecond)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 起不来:收掉进程组,把输出尾巴交给手机 —— 原因写在日志里,别让人去桌面翻。
|
|
744
|
+
alive := agent.PIDAlive(pid)
|
|
745
|
+
if alive {
|
|
746
|
+
_ = agent.KillPID(pid, true)
|
|
747
|
+
}
|
|
748
|
+
/* 等收尸:进程自己退出(例如端口被占时 node 直接抛错)时,cmd.Wait 的 goroutine
|
|
749
|
+
与日志写盘都还没跑完,立刻读会读到空 —— 那样手机上只剩「进程已退出」四个字,
|
|
750
|
+
真正的原因(EADDRINUSE …)被吞掉。最多等 2 秒。 */
|
|
751
|
+
select {
|
|
752
|
+
case <-exited:
|
|
753
|
+
case <-time.After(2 * time.Second):
|
|
754
|
+
}
|
|
755
|
+
m.mu.Lock()
|
|
756
|
+
if r := m.runs[spec.ID]; r != nil && r.PID == pid {
|
|
757
|
+
delete(m.runs, spec.ID)
|
|
758
|
+
}
|
|
759
|
+
m.mu.Unlock()
|
|
760
|
+
m.saveRuns()
|
|
761
|
+
m.probeRunning(spec.Port, true)
|
|
762
|
+
|
|
763
|
+
reason := fmt.Sprintf("%d 秒内端口 %d 没有应答", int(appStartWait.Seconds()), spec.Port)
|
|
764
|
+
if !alive {
|
|
765
|
+
reason = "进程已退出"
|
|
766
|
+
}
|
|
767
|
+
// tail 去掉第一行(这次的启动标题),只给真正的输出。
|
|
768
|
+
lines := tailFile(logDir, spec.ID, 13)
|
|
769
|
+
if len(lines) > 1 {
|
|
770
|
+
lines = lines[1:]
|
|
771
|
+
}
|
|
772
|
+
if len(lines) == 0 {
|
|
773
|
+
return AppItem{}, fmt.Errorf("%s 启动失败:%s", spec.Name, reason)
|
|
774
|
+
}
|
|
775
|
+
return AppItem{}, fmt.Errorf("%s 启动失败:%s\n%s", spec.Name, reason, strings.Join(lines, "\n"))
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
var (
|
|
779
|
+
errAppStarting = errors.New("这个工作台正在启动,稍等片刻")
|
|
780
|
+
errAppExternal = errors.New("这个工作台不是在手机上启动的(桌面端终端 / 菜单栏起的),请到桌面端停止")
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
// stop 停一个工作台:只停自己起的(整组),外部实例如实回报。
|
|
784
|
+
func (m *appManager) stop(spec appSpec) (AppItem, error) {
|
|
785
|
+
m.mu.Lock()
|
|
786
|
+
run := m.runs[spec.ID]
|
|
787
|
+
m.mu.Unlock()
|
|
788
|
+
if run == nil || !agent.PIDAlive(run.PID) {
|
|
789
|
+
if _, ok := m.locate(spec, true); ok {
|
|
790
|
+
return AppItem{}, errAppExternal
|
|
791
|
+
}
|
|
792
|
+
m.mu.Lock()
|
|
793
|
+
delete(m.runs, spec.ID)
|
|
794
|
+
m.mu.Unlock()
|
|
795
|
+
m.saveRuns()
|
|
796
|
+
return m.info(spec, true), nil // 没在跑 → 幂等成功
|
|
797
|
+
}
|
|
798
|
+
pid := run.PID
|
|
799
|
+
// 杀整组:unix kill(-pid) 带走 node 起的全部子进程;Windows taskkill /T /F。
|
|
800
|
+
_ = agent.KillPID(pid, true)
|
|
801
|
+
// 等它真的下去,否则手机点完「停止」立刻重画还会看到「运行中」。
|
|
802
|
+
for i := 0; i < 40; i++ {
|
|
803
|
+
if !agent.PIDAlive(pid) {
|
|
804
|
+
break
|
|
805
|
+
}
|
|
806
|
+
time.Sleep(100 * time.Millisecond)
|
|
807
|
+
}
|
|
808
|
+
m.mu.Lock()
|
|
809
|
+
if r := m.runs[spec.ID]; r != nil && r.PID == pid {
|
|
810
|
+
delete(m.runs, spec.ID)
|
|
811
|
+
}
|
|
812
|
+
m.mu.Unlock()
|
|
813
|
+
m.saveRuns()
|
|
814
|
+
m.probeRunning(run.Port, true)
|
|
815
|
+
return m.info(spec, true), nil
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/* appErr 本页的错误响应:中文**不转义**(\u77ed\u89c6\u9891 这种前端一旦当纯文本显示就是乱码样)。
|
|
819
|
+
*
|
|
820
|
+
* 为什么不改全局 writeErr:别的接口的错误体是给程序读的,escaped 也一样合法;
|
|
821
|
+
* 这一页的错误会原样显示给用户(起不来的 stderr 尾巴、一句人话),所以单独一份。 */
|
|
822
|
+
func appErr(w http.ResponseWriter, code int, msg string) {
|
|
823
|
+
var buf bytes.Buffer
|
|
824
|
+
enc := json.NewEncoder(&buf)
|
|
825
|
+
enc.SetEscapeHTML(false)
|
|
826
|
+
_ = enc.Encode(map[string]any{"error": msg})
|
|
827
|
+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
828
|
+
w.WriteHeader(code)
|
|
829
|
+
_, _ = w.Write(buf.Bytes())
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/* ─────────────── 反代 ─────────────── */
|
|
833
|
+
|
|
834
|
+
// workbenchPrefix 手机可用的工作台入口前缀:/workbench/{id}/。
|
|
835
|
+
const workbenchPrefix = "/workbench/"
|
|
836
|
+
|
|
837
|
+
/* workbenchCookie 记住「这个浏览器的手机界面当前在看哪个工作台」,给页面里的根相对
|
|
838
|
+
* '/api/…' 兜底用(见 workbenchFor)。HttpOnly:只是一条给服务端看的标记,脚本不用读。 */
|
|
839
|
+
const workbenchCookie = "magic_wb"
|
|
840
|
+
|
|
841
|
+
// workbenchTarget 解析 /workbench/{id} 或 /workbench/{id}/{sub}。
|
|
842
|
+
func workbenchTarget(p string) (id, sub string, ok bool) {
|
|
843
|
+
if !strings.HasPrefix(p, workbenchPrefix) {
|
|
844
|
+
return "", "", false
|
|
845
|
+
}
|
|
846
|
+
rest := strings.TrimPrefix(p, workbenchPrefix)
|
|
847
|
+
id, sub = rest, ""
|
|
848
|
+
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
|
849
|
+
id, sub = rest[:i], rest[i:]
|
|
850
|
+
}
|
|
851
|
+
if id == "" || strings.Contains(sub, "..") {
|
|
852
|
+
return "", "", false
|
|
853
|
+
}
|
|
854
|
+
return id, sub, true
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// newWorkbenchProxy 建一个指向 127.0.0.1:<port> 的反代。
|
|
858
|
+
func newWorkbenchProxy(spec appSpec, port int) *httputil.ReverseProxy {
|
|
859
|
+
host := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
|
860
|
+
return &httputil.ReverseProxy{
|
|
861
|
+
Director: func(req *http.Request) {
|
|
862
|
+
req.URL.Scheme = "http"
|
|
863
|
+
req.URL.Host = host
|
|
864
|
+
req.Header.Set("X-Forwarded-Prefix", workbookBase(spec.ID))
|
|
865
|
+
},
|
|
866
|
+
FlushInterval: 60 * time.Millisecond, // 工作台有长轮询 / 流式输出,别攒着
|
|
867
|
+
ErrorHandler: func(w http.ResponseWriter, req *http.Request, err error) {
|
|
868
|
+
appErr(w, http.StatusBadGateway, spec.Name+" 工作台没有响应:"+err.Error())
|
|
869
|
+
},
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// handleWorkbench /workbench/{id}/{...} → 工作台。
|
|
874
|
+
//
|
|
875
|
+
// 第一个请求(/workbench/{id}/,手机内置浏览器打开的就是它)先探活:
|
|
876
|
+
// 没在跑就回一句人话;在跑就 302 到 "/",让工作台**在根路径下**跑 ——
|
|
877
|
+
// 页面里的 '/api/…' 于是打回本服务,再由 handleStatic 按 Referer 认出来转发。
|
|
878
|
+
// 其余子路径(/workbench/{id}/xxx)照常反代,给不依赖根路径的地址用。
|
|
879
|
+
func (s *Service) handleWorkbench(w http.ResponseWriter, r *http.Request) {
|
|
880
|
+
id, sub, ok := workbenchTarget(r.URL.Path)
|
|
881
|
+
if !ok {
|
|
882
|
+
appErr(w, http.StatusNotFound, "用法:/workbench/{id}/")
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
spec, found := findApp(id)
|
|
886
|
+
if !found {
|
|
887
|
+
appErr(w, http.StatusNotFound, "没有这个应用:"+id)
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
mgr := s.appMgr()
|
|
891
|
+
port, running := mgr.locate(spec, true)
|
|
892
|
+
if !running {
|
|
893
|
+
appErr(w, http.StatusServiceUnavailable, spec.Name+" 没有在运行。先到「发现 → 应用」点启动。")
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
if sub == "" || sub == "/" {
|
|
897
|
+
/* 跳到 "/" 让工作台在根路径下跑,并带一个 via=<id> 标记:
|
|
898
|
+
前端内置浏览器看到的地址就变成 "/"(应用页不再把它当站点重载);
|
|
899
|
+
点「刷新」时再带回这里(先探活、再跳),工作台退出了会得到一句人话而不是白屏。
|
|
900
|
+
也正因为带了这个标记,前端的「关闭浏览器」不会被当成「离开站点」而反复重开。 */
|
|
901
|
+
/* 同时种 cookie:页面里根相对的 '/api/…' 可能既没 Referer 也没 via(见 workbenchFor)。
|
|
902
|
+
Path=/ + SameSite=Lax:只影响根路径下的请求,不外泄到别的站点。 */
|
|
903
|
+
http.SetCookie(w, &http.Cookie{
|
|
904
|
+
Name: workbenchCookie,
|
|
905
|
+
Value: spec.ID,
|
|
906
|
+
Path: "/",
|
|
907
|
+
SameSite: http.SameSiteLaxMode,
|
|
908
|
+
HttpOnly: true,
|
|
909
|
+
})
|
|
910
|
+
target := "/?via=" + url.QueryEscape(spec.ID)
|
|
911
|
+
if q := r.URL.RawQuery; q != "" {
|
|
912
|
+
target += "&" + q
|
|
913
|
+
}
|
|
914
|
+
http.Redirect(w, r, target, http.StatusFound)
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
s.serveWorkbench(w, r, spec, port, sub)
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// r2WithPath 复制请求并改写路径(反代 Director 之后还会再次改写,这里只负责把
|
|
921
|
+
// /workbench/{id}/xxx 换成 /xxx,保留 query)。
|
|
922
|
+
func r2WithPath(r *http.Request, path, orig string) *http.Request {
|
|
923
|
+
if path == orig {
|
|
924
|
+
return r
|
|
925
|
+
}
|
|
926
|
+
c := r.Clone(r.Context())
|
|
927
|
+
u := *r.URL
|
|
928
|
+
u.Path = path
|
|
929
|
+
u.RawPath = ""
|
|
930
|
+
c.URL = &u
|
|
931
|
+
return c
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// workbenchFor 判定「这次根路径请求是不是某个运行中的工作台发起的」,是则返回它与端口。
|
|
935
|
+
//
|
|
936
|
+
// 判据是 **Referer**:工作台页面(以及它里边的 fetch)一定带着页面自己的地址
|
|
937
|
+
// http(s)://<手机入口>/workbench/{id}/…。要求 Referer 的宿主与本次请求一致 ——
|
|
938
|
+
// 别的站点伪造一个 Referer 也只会拿到「桌面端 127.0.0.1 上的那个工作台」,
|
|
939
|
+
// 那本来就是这台机器的东西,不扩大攻击面。
|
|
940
|
+
func (s *Service) workbenchFor(r *http.Request) (appSpec, int, bool) {
|
|
941
|
+
/* 两条认法,缺一不可:
|
|
942
|
+
1. Referer 的路径是 /workbench/{id}/…(页面加深、工作台内部跳转都带着它);
|
|
943
|
+
2. 或者地址上带 ?via={id} —— 入口 302 过来的第一跳。
|
|
944
|
+
⚠️ 只靠 1 不够:移动端内置浏览器的 iframe 历史上带 referrerpolicy="no-referrer",
|
|
945
|
+
浏览器一个头都不发,服务端认不出这是工作台,结果把客户的界面(或 401)丢给页面。
|
|
946
|
+
via 是服务端自己拼的标记,浏览器也留得住(站内跳转不丢),比裸 Referer 可靠。 */
|
|
947
|
+
var id string
|
|
948
|
+
if ref := strings.TrimSpace(r.Header.Get("Referer")); ref != "" {
|
|
949
|
+
if u, err := url.Parse(ref); err == nil && strings.EqualFold(u.Host, r.Host) {
|
|
950
|
+
if v, _, ok := workbenchTarget(u.Path); ok {
|
|
951
|
+
id = v
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if id == "" {
|
|
956
|
+
via := strings.TrimSpace(r.URL.Query().Get("via"))
|
|
957
|
+
if via == "" {
|
|
958
|
+
/* 页面里的 '/api/…' 是 XHR / fetch:地址上没 via、Referer 也可能被浏览器抹掉。
|
|
959
|
+
入口那一跳给浏览器种了 cookie(同源、根路径、只放应用 id),这里认它。 */
|
|
960
|
+
if ck, err := r.Cookie(workbenchCookie); err == nil {
|
|
961
|
+
via = strings.TrimSpace(ck.Value)
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if via != "" {
|
|
965
|
+
if spec, found := findApp(via); found {
|
|
966
|
+
if port, running := s.appMgr().locate(spec, false); running {
|
|
967
|
+
return spec, port, true
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return appSpec{}, 0, false
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if id == "" {
|
|
974
|
+
return appSpec{}, 0, false
|
|
975
|
+
}
|
|
976
|
+
spec, found := findApp(id)
|
|
977
|
+
if !found {
|
|
978
|
+
return appSpec{}, 0, false
|
|
979
|
+
}
|
|
980
|
+
port, running := s.appMgr().locate(spec, false)
|
|
981
|
+
if !running {
|
|
982
|
+
return appSpec{}, 0, false
|
|
983
|
+
}
|
|
984
|
+
return spec, port, true
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// serveWorkbench 把根路径请求转给工作台(handleStatic 命中时调用)。
|
|
988
|
+
// path 为空 = 沿用请求自己的路径(页面里的 '/api/…' 原样交给工作台)。
|
|
989
|
+
func (s *Service) serveWorkbench(w http.ResponseWriter, r *http.Request, spec appSpec, port int, path string) {
|
|
990
|
+
if path == "" {
|
|
991
|
+
newWorkbenchProxy(spec, port).ServeHTTP(w, r)
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
newWorkbenchProxy(spec, port).ServeHTTP(w, r2WithPath(r, path, r.URL.Path))
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/* ─────────────── HTTP 入口 ─────────────── */
|
|
998
|
+
|
|
999
|
+
// handleApps GET /desk/apps:应用清单 + 状态。
|
|
1000
|
+
func (s *Service) handleApps(w http.ResponseWriter, r *http.Request) {
|
|
1001
|
+
if r.Method != http.MethodGet {
|
|
1002
|
+
appErr(w, http.StatusMethodNotAllowed, "GET only")
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
specs, notes := appSpecs()
|
|
1006
|
+
mgr := s.appMgr()
|
|
1007
|
+
items := make([]AppItem, 0, len(specs))
|
|
1008
|
+
for _, spec := range specs {
|
|
1009
|
+
items = append(items, mgr.info(spec, false))
|
|
1010
|
+
}
|
|
1011
|
+
writeJSON(w, http.StatusOK, map[string]any{"apps": items, "notes": notes})
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// handleAppAction POST /desk/apps/{id}/{start|stop}
|
|
1015
|
+
//
|
|
1016
|
+
// 回 SSE(与插件安装同一口径):启动要等端口应答(几秒),输出逐行回来,
|
|
1017
|
+
// 手机上看得见在动;`event: end` 带完整的最新状态与 ok/error。
|
|
1018
|
+
func (s *Service) handleAppAction(w http.ResponseWriter, r *http.Request) {
|
|
1019
|
+
if r.Method != http.MethodPost {
|
|
1020
|
+
appErr(w, http.StatusMethodNotAllowed, "POST only")
|
|
1021
|
+
return
|
|
1022
|
+
}
|
|
1023
|
+
rest := strings.TrimPrefix(r.URL.Path, "/desk/apps/")
|
|
1024
|
+
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
|
1025
|
+
if len(parts) != 2 {
|
|
1026
|
+
appErr(w, http.StatusNotFound, "用法:POST /desk/apps/{id}/{start|stop}")
|
|
1027
|
+
return
|
|
1028
|
+
}
|
|
1029
|
+
spec, ok := findApp(parts[0])
|
|
1030
|
+
if !ok {
|
|
1031
|
+
appErr(w, http.StatusNotFound, "没有这个应用:"+parts[0])
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
action := parts[1]
|
|
1035
|
+
if action != "start" && action != "stop" {
|
|
1036
|
+
appErr(w, http.StatusNotFound, "动作只有 start / stop")
|
|
1037
|
+
return
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
|
1041
|
+
w.Header().Set("Cache-Control", "no-cache")
|
|
1042
|
+
w.Header().Set("X-Accel-Buffering", "no")
|
|
1043
|
+
flusher, _ := w.(http.Flusher)
|
|
1044
|
+
send := func(event string, payload any) {
|
|
1045
|
+
b, _ := json.Marshal(payload)
|
|
1046
|
+
if event != "" {
|
|
1047
|
+
fmt.Fprintf(w, "event: %s\n", event)
|
|
1048
|
+
}
|
|
1049
|
+
fmt.Fprintf(w, "data: %s\n\n", b)
|
|
1050
|
+
if flusher != nil {
|
|
1051
|
+
flusher.Flush()
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
send("open", map[string]any{"id": spec.ID, "action": action})
|
|
1056
|
+
|
|
1057
|
+
/* 与 /desk/ask、插件安装同一口径:客户端断连不是「取消」。
|
|
1058
|
+
起工作台起一半被手机锁屏杀掉,端口上可能已经有人在应答,状态就会两头说不清。 */
|
|
1059
|
+
mgr := s.appMgr()
|
|
1060
|
+
var (
|
|
1061
|
+
item AppItem
|
|
1062
|
+
err error
|
|
1063
|
+
)
|
|
1064
|
+
if action == "start" {
|
|
1065
|
+
item, err = mgr.start(spec)
|
|
1066
|
+
} else {
|
|
1067
|
+
item, err = mgr.stop(spec)
|
|
1068
|
+
}
|
|
1069
|
+
if err != nil {
|
|
1070
|
+
code := http.StatusInternalServerError
|
|
1071
|
+
if errors.Is(err, errAppStarting) || errors.Is(err, errAppExternal) {
|
|
1072
|
+
code = http.StatusConflict
|
|
1073
|
+
}
|
|
1074
|
+
send("end", map[string]any{"ok": false, "code": code, "error": err.Error(), "app": mgr.info(spec, true)})
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
send("end", map[string]any{"ok": true, "code": 0, "app": item})
|
|
1078
|
+
}
|