magic-agent 1.0.3 → 1.0.49
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 +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +69 -1
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +260 -33
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +101 -10
- package/npm/bin/magic-client.js +118 -0
- 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 +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
/* 后台常驻(`magic-client start|stop|status`)的落地层。
|
|
4
|
+
*
|
|
5
|
+
* ── 为什么需要这一层 ──
|
|
6
|
+
* 手机的连接入口是「一个还在跑的 serve 进程 + 它的端口 + 令牌」。手工 `serve --lan`
|
|
7
|
+
* 是**前台**进程:关掉终端就没了;而端口是 0(系统分配),下次启动又是另一个端口 ——
|
|
8
|
+
* 用户既记不住,也没法把它抄进手机。所以这里把
|
|
9
|
+
* 「pid + 端口 + 令牌 + 局域网地址」落成一份 0600 的状态文件,
|
|
10
|
+
* start / status / stop 三个动作围绕同一份事实工作,谁也不再猜。
|
|
11
|
+
*
|
|
12
|
+
* ── 为什么由 serve 自己写 ready 文件,而不是父进程猜端口 ──
|
|
13
|
+
* 端口在 bind 之后才知道(`net.Listen("tcp","0.0.0.0:0")`),父进程只能事后读日志正则 ——
|
|
14
|
+
* 那是最脆的一种解析(日志格式一改就断)。所以让 serve 在监听成功后**原子写**一份 JSON,
|
|
15
|
+
* 父进程只等这个文件出现。
|
|
16
|
+
*
|
|
17
|
+
* ── 幂等 ──
|
|
18
|
+
* start 发现已有实例活着就**不再起第二个**:第二个会占另一个端口,而手机扫到的还是旧地址,
|
|
19
|
+
* 用户看到的现象是「启动了但连不上」。stop 同理:本来没跑也算成功(脚本可以直接调)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import (
|
|
23
|
+
"crypto/rand"
|
|
24
|
+
"encoding/hex"
|
|
25
|
+
"encoding/json"
|
|
26
|
+
"fmt"
|
|
27
|
+
"net/http"
|
|
28
|
+
"os"
|
|
29
|
+
"os/exec"
|
|
30
|
+
"path/filepath"
|
|
31
|
+
"strings"
|
|
32
|
+
"time"
|
|
33
|
+
|
|
34
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const (
|
|
38
|
+
// EnvState 覆盖状态文件位置(多实例 / 测试用)。
|
|
39
|
+
EnvState = "MAGIC_CLIENT_STATE"
|
|
40
|
+
// EnvLog 覆盖后台日志文件位置。
|
|
41
|
+
EnvLog = "MAGIC_CLIENT_LOG"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const (
|
|
45
|
+
// defaultReadyWait 等 serve 报端口的时长。第一次启动要跑 `--contract` 探测引擎,
|
|
46
|
+
// 慢机器上 3~5s 很常见,给到 15s 留余量。
|
|
47
|
+
defaultReadyWait = 15 * time.Second
|
|
48
|
+
// stopGrace SIGTERM 之后等它自己收尾的时长,超时才 SIGKILL。
|
|
49
|
+
stopGrace = 5 * time.Second
|
|
50
|
+
// probeTimeout 单次探活超时:这是本机请求,慢成这样已经算挂了。
|
|
51
|
+
probeTimeout = 1500 * time.Millisecond
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// DefaultStatePath 状态文件默认位置:~/.magic-client/client.json。
|
|
55
|
+
func DefaultStatePath() string {
|
|
56
|
+
if p := strings.TrimSpace(os.Getenv(EnvState)); p != "" {
|
|
57
|
+
return expandHome(p)
|
|
58
|
+
}
|
|
59
|
+
home, err := os.UserHomeDir()
|
|
60
|
+
if err != nil {
|
|
61
|
+
return filepath.Join(os.TempDir(), "magic-client", "client.json")
|
|
62
|
+
}
|
|
63
|
+
return filepath.Join(home, ".magic-client", "client.json")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// DefaultLogPath 后台日志默认位置:~/.magic-client/logs/client.log。
|
|
67
|
+
//
|
|
68
|
+
// 必须有日志:detach 之后 stderr 没人接,启动失败的原因(契约不匹配、端口占用、
|
|
69
|
+
// ui 目录不存在)就彻底消失了,只剩一句「没起来」。
|
|
70
|
+
func DefaultLogPath() string {
|
|
71
|
+
if p := strings.TrimSpace(os.Getenv(EnvLog)); p != "" {
|
|
72
|
+
return expandHome(p)
|
|
73
|
+
}
|
|
74
|
+
home, err := os.UserHomeDir()
|
|
75
|
+
if err != nil {
|
|
76
|
+
return filepath.Join(os.TempDir(), "magic-client", "client.log")
|
|
77
|
+
}
|
|
78
|
+
return filepath.Join(home, ".magic-client", "logs", "client.log")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// NewToken 生成访问令牌(16 字节 = 32 个十六进制字符,远强于局域网那条 24 字符下限)。
|
|
82
|
+
func NewToken() string {
|
|
83
|
+
b := make([]byte, 16)
|
|
84
|
+
if _, err := rand.Read(b); err != nil {
|
|
85
|
+
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
86
|
+
}
|
|
87
|
+
return hex.EncodeToString(b)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ReadyInfo serve 监听成功后报给父进程的事实。
|
|
91
|
+
type ReadyInfo struct {
|
|
92
|
+
PID int `json:"pid"`
|
|
93
|
+
Port int `json:"port"`
|
|
94
|
+
Addr string `json:"addr"`
|
|
95
|
+
URL string `json:"url"`
|
|
96
|
+
Lan bool `json:"lan"`
|
|
97
|
+
LanAddrs []string `json:"lanAddrs,omitempty"`
|
|
98
|
+
ProxyAddr string `json:"proxyAddr,omitempty"`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// WriteReadyFile 原子写 ready 文件(临时文件 + rename):
|
|
102
|
+
// 父进程按「文件出现」判断就绪,半截 JSON 会让它白等一轮。
|
|
103
|
+
func WriteReadyFile(path string, info ReadyInfo) error {
|
|
104
|
+
if strings.TrimSpace(path) == "" {
|
|
105
|
+
return nil
|
|
106
|
+
}
|
|
107
|
+
if info.PID == 0 {
|
|
108
|
+
info.PID = os.Getpid()
|
|
109
|
+
}
|
|
110
|
+
data, err := json.Marshal(info)
|
|
111
|
+
if err != nil {
|
|
112
|
+
return err
|
|
113
|
+
}
|
|
114
|
+
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
|
115
|
+
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
116
|
+
return err
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
tmp := path + ".tmp"
|
|
120
|
+
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
121
|
+
return err
|
|
122
|
+
}
|
|
123
|
+
return os.Rename(tmp, path)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// readReadyFile 读 ready 文件:不存在就返回 (nil, nil)(父进程轮询用)。
|
|
127
|
+
func readReadyFile(path string) (*ReadyInfo, error) {
|
|
128
|
+
data, err := os.ReadFile(path)
|
|
129
|
+
if err != nil {
|
|
130
|
+
if os.IsNotExist(err) {
|
|
131
|
+
return nil, nil
|
|
132
|
+
}
|
|
133
|
+
return nil, err
|
|
134
|
+
}
|
|
135
|
+
if len(data) == 0 {
|
|
136
|
+
return nil, nil
|
|
137
|
+
}
|
|
138
|
+
var info ReadyInfo
|
|
139
|
+
if err := json.Unmarshal(data, &info); err != nil {
|
|
140
|
+
return nil, err
|
|
141
|
+
}
|
|
142
|
+
if info.Port == 0 {
|
|
143
|
+
return nil, nil
|
|
144
|
+
}
|
|
145
|
+
return &info, nil
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// DaemonState 一份还在跑(或刚停掉)的实例的完整事实。
|
|
149
|
+
//
|
|
150
|
+
// ⚠️ 里面有令牌 → 文件权限恒 0600。这不是「多一层保险」,是唯一的门锁:
|
|
151
|
+
// 能读到这个文件的人本来就能连上服务。
|
|
152
|
+
type DaemonState struct {
|
|
153
|
+
PID int `json:"pid"`
|
|
154
|
+
Port int `json:"port"`
|
|
155
|
+
URL string `json:"url"` // 本机地址(含令牌)
|
|
156
|
+
LanAddrs []string `json:"lanAddrs"` // 手机可用地址(含令牌);空 = 只绑了回环
|
|
157
|
+
Lan bool `json:"lan"`
|
|
158
|
+
Profile string `json:"profile,omitempty"`
|
|
159
|
+
UIRoot string `json:"uiDir,omitempty"`
|
|
160
|
+
MobileDir string `json:"mobileDir,omitempty"`
|
|
161
|
+
ProxyAddr string `json:"proxyAddr,omitempty"`
|
|
162
|
+
Token string `json:"token"`
|
|
163
|
+
StartedAt time.Time `json:"startedAt"`
|
|
164
|
+
LogPath string `json:"logPath"`
|
|
165
|
+
StatePath string `json:"statePath"`
|
|
166
|
+
Exe string `json:"exe"`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Base 本机根地址(不带令牌)。
|
|
170
|
+
func (s *DaemonState) Base() string {
|
|
171
|
+
if s == nil || s.Port == 0 {
|
|
172
|
+
return ""
|
|
173
|
+
}
|
|
174
|
+
return fmt.Sprintf("http://127.0.0.1:%d", s.Port)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// writeDaemonState 落盘(0600,原子)。
|
|
178
|
+
func writeDaemonState(path string, st *DaemonState) error {
|
|
179
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
180
|
+
return err
|
|
181
|
+
}
|
|
182
|
+
data, err := json.MarshalIndent(st, "", " ")
|
|
183
|
+
if err != nil {
|
|
184
|
+
return err
|
|
185
|
+
}
|
|
186
|
+
tmp := path + ".tmp"
|
|
187
|
+
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
188
|
+
return err
|
|
189
|
+
}
|
|
190
|
+
return os.Rename(tmp, path)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ReadDaemonState 读状态文件:不存在 → (nil, nil)。
|
|
194
|
+
func ReadDaemonState(path string) (*DaemonState, error) {
|
|
195
|
+
if strings.TrimSpace(path) == "" {
|
|
196
|
+
path = DefaultStatePath()
|
|
197
|
+
}
|
|
198
|
+
data, err := os.ReadFile(path)
|
|
199
|
+
if err != nil {
|
|
200
|
+
if os.IsNotExist(err) {
|
|
201
|
+
return nil, nil
|
|
202
|
+
}
|
|
203
|
+
return nil, err
|
|
204
|
+
}
|
|
205
|
+
var st DaemonState
|
|
206
|
+
if err := json.Unmarshal(data, &st); err != nil {
|
|
207
|
+
return nil, fmt.Errorf("状态文件 %s 解析失败(删掉它再 start 即可):%w", path, err)
|
|
208
|
+
}
|
|
209
|
+
st.StatePath = path
|
|
210
|
+
return &st, nil
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Health `/desk/health` 的响应(只取我们关心的字段)。
|
|
214
|
+
type Health struct {
|
|
215
|
+
OK bool `json:"ok"`
|
|
216
|
+
App string `json:"app"`
|
|
217
|
+
ContractVersion int `json:"contractVersion"`
|
|
218
|
+
Engines int `json:"engines"`
|
|
219
|
+
DefaultWorkspace string `json:"defaultWorkspace"`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// probeHealth 探活:本机回环 + 令牌。
|
|
223
|
+
//
|
|
224
|
+
// 为什么走 /desk/health 而不是只看 pid:pid 会被复用,而且「进程在、服务不可用」
|
|
225
|
+
// (绑定失败后卡住、core 契约不匹配)也是常见形态。能答上来才算真的能用。
|
|
226
|
+
func probeHealth(port int, token string) (*Health, error) {
|
|
227
|
+
if port <= 0 {
|
|
228
|
+
return nil, fmt.Errorf("端口无效")
|
|
229
|
+
}
|
|
230
|
+
url := fmt.Sprintf("http://127.0.0.1:%d/desk/health", port)
|
|
231
|
+
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
232
|
+
if err != nil {
|
|
233
|
+
return nil, err
|
|
234
|
+
}
|
|
235
|
+
if token != "" {
|
|
236
|
+
req.Header.Set("X-Magic-Token", token)
|
|
237
|
+
}
|
|
238
|
+
cli := &http.Client{Timeout: probeTimeout}
|
|
239
|
+
resp, err := cli.Do(req)
|
|
240
|
+
if err != nil {
|
|
241
|
+
return nil, err
|
|
242
|
+
}
|
|
243
|
+
defer resp.Body.Close()
|
|
244
|
+
if resp.StatusCode != http.StatusOK {
|
|
245
|
+
return nil, fmt.Errorf("健康检查返回 %d", resp.StatusCode)
|
|
246
|
+
}
|
|
247
|
+
var h Health
|
|
248
|
+
if err := json.NewDecoder(resp.Body).Decode(&h); err != nil {
|
|
249
|
+
return nil, err
|
|
250
|
+
}
|
|
251
|
+
return &h, nil
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// TokenPath 令牌文件:与状态文件同目录的 token。
|
|
255
|
+
//
|
|
256
|
+
// 为什么要单独一份、且 stop 时不删:状态文件随进程生命周期来去,令牌却必须**跨重启稳定** ——
|
|
257
|
+
// 手机里存的是「地址 + 令牌」,每次 start 都换一个的话,用户的配对就白做了
|
|
258
|
+
// (现象是「电脑重启后手机又说令牌不对」)。显式传 --token 时不写这份文件。
|
|
259
|
+
func TokenPath(statePath string) string {
|
|
260
|
+
if strings.TrimSpace(statePath) == "" {
|
|
261
|
+
statePath = DefaultStatePath()
|
|
262
|
+
}
|
|
263
|
+
return filepath.Join(filepath.Dir(expandHome(statePath)), "token")
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// resolveToken 取令牌:显式值 > 已存令牌 > 新生成(并落盘)。
|
|
267
|
+
func resolveToken(statePath, explicit string) string {
|
|
268
|
+
if t := strings.TrimSpace(explicit); t != "" {
|
|
269
|
+
return t
|
|
270
|
+
}
|
|
271
|
+
p := TokenPath(statePath)
|
|
272
|
+
if data, err := os.ReadFile(p); err == nil {
|
|
273
|
+
if t := strings.TrimSpace(string(data)); len(t) >= MinLanTokenLen {
|
|
274
|
+
return t
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
t := NewToken()
|
|
278
|
+
if err := os.MkdirAll(filepath.Dir(p), 0o700); err == nil {
|
|
279
|
+
if werr := os.WriteFile(p, []byte(t+"\n"), 0o600); werr != nil {
|
|
280
|
+
// 存不下不是致命错误:本次仍能用,只是下次 start 会换个令牌。
|
|
281
|
+
fmt.Fprintf(os.Stderr, "magic-client: 警告 - 令牌没能存到 %s(%v),下次 start 会换新令牌\n", p, werr)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return t
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// StartOptions `start` 的参数。
|
|
288
|
+
type StartOptions struct {
|
|
289
|
+
// Exe 要拉起的可执行文件;空 = 当前进程自己(`magic-client start` 就是 magic-client)。
|
|
290
|
+
Exe string
|
|
291
|
+
// MagicAgent 交给 serve 的 core 路径;空 = 让它从 PATH 找。
|
|
292
|
+
// 由 CLI 侧填**当前进程自己**:PATH 里那份可能是旧版本,契约一比对就失败。
|
|
293
|
+
MagicAgent string
|
|
294
|
+
|
|
295
|
+
Profile string
|
|
296
|
+
UIRoot string
|
|
297
|
+
MobileDir string
|
|
298
|
+
Addr string
|
|
299
|
+
Token string
|
|
300
|
+
ProxyAddr string
|
|
301
|
+
BrowseUA string
|
|
302
|
+
Lan bool
|
|
303
|
+
StatePath string
|
|
304
|
+
LogPath string
|
|
305
|
+
Wait time.Duration
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// StartDaemon 后台拉起一个 serve 进程,等它报端口,落状态文件。
|
|
309
|
+
func StartDaemon(o StartOptions) (*DaemonState, error) {
|
|
310
|
+
exe := strings.TrimSpace(o.Exe)
|
|
311
|
+
if exe == "" {
|
|
312
|
+
p, err := os.Executable()
|
|
313
|
+
if err != nil {
|
|
314
|
+
return nil, fmt.Errorf("定位自身可执行文件失败:%w", err)
|
|
315
|
+
}
|
|
316
|
+
exe = p
|
|
317
|
+
}
|
|
318
|
+
statePath := strings.TrimSpace(o.StatePath)
|
|
319
|
+
if statePath == "" {
|
|
320
|
+
statePath = DefaultStatePath()
|
|
321
|
+
}
|
|
322
|
+
logPath := strings.TrimSpace(o.LogPath)
|
|
323
|
+
if logPath == "" {
|
|
324
|
+
logPath = DefaultLogPath()
|
|
325
|
+
}
|
|
326
|
+
statePath = expandHome(statePath)
|
|
327
|
+
logPath = expandHome(logPath)
|
|
328
|
+
wait := o.Wait
|
|
329
|
+
if wait <= 0 {
|
|
330
|
+
wait = defaultReadyWait
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
tok := strings.TrimSpace(o.Token)
|
|
334
|
+
if tok == "" {
|
|
335
|
+
tok = resolveToken(statePath, "")
|
|
336
|
+
}
|
|
337
|
+
if o.Lan {
|
|
338
|
+
if err := ValidateLanToken(tok); err != nil {
|
|
339
|
+
return nil, err
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 幂等:已有实例活着就直接返回,不再起第二个。
|
|
344
|
+
prev, err := ReadDaemonState(statePath)
|
|
345
|
+
if err != nil {
|
|
346
|
+
return nil, err
|
|
347
|
+
}
|
|
348
|
+
if prev != nil {
|
|
349
|
+
if h, herr := probeHealth(prev.Port, prev.Token); herr == nil && h.OK {
|
|
350
|
+
return prev, nil
|
|
351
|
+
}
|
|
352
|
+
// 进程不在了 / 答不上来:清掉陈旧状态,当成全新启动。
|
|
353
|
+
_ = os.Remove(statePath)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for _, d := range []string{filepath.Dir(statePath), filepath.Dir(logPath)} {
|
|
357
|
+
if err := os.MkdirAll(d, 0o700); err != nil {
|
|
358
|
+
return nil, fmt.Errorf("建目录 %s 失败:%w", d, err)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
readyPath := statePath + ".ready"
|
|
362
|
+
_ = os.Remove(readyPath)
|
|
363
|
+
|
|
364
|
+
logf, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
|
365
|
+
if err != nil {
|
|
366
|
+
return nil, fmt.Errorf("打开日志文件 %s 失败:%w", logPath, err)
|
|
367
|
+
}
|
|
368
|
+
defer logf.Close()
|
|
369
|
+
|
|
370
|
+
args := []string{"serve", "--ready-file", readyPath, "--token", tok}
|
|
371
|
+
appendIf := func(flag, v string) {
|
|
372
|
+
if v = strings.TrimSpace(v); v != "" {
|
|
373
|
+
args = append(args, flag, v)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
appendIf("--profile", o.Profile)
|
|
377
|
+
appendIf("--ui-dir", o.UIRoot)
|
|
378
|
+
appendIf("--mobile-dir", o.MobileDir)
|
|
379
|
+
appendIf("--addr", o.Addr)
|
|
380
|
+
appendIf("--proxy-addr", o.ProxyAddr)
|
|
381
|
+
appendIf("--browse-ua", o.BrowseUA)
|
|
382
|
+
appendIf("--magic-agent", o.MagicAgent)
|
|
383
|
+
if o.Lan {
|
|
384
|
+
args = append(args, "--lan")
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
cmd := exec.Command(exe, args...)
|
|
388
|
+
// cwd 沿用调用方:core 不传 -w 时默认工作空间就是 cwd(/desk/health 会报给客户端)。
|
|
389
|
+
if wd, werr := os.Getwd(); werr == nil {
|
|
390
|
+
cmd.Dir = wd
|
|
391
|
+
}
|
|
392
|
+
cmd.Stdout = logf
|
|
393
|
+
cmd.Stderr = logf
|
|
394
|
+
detach(cmd) // 脱离终端:关掉启动它的窗口后服务照旧活着
|
|
395
|
+
if err := cmd.Start(); err != nil {
|
|
396
|
+
return nil, fmt.Errorf("启动后台服务失败:%w", err)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
exited := make(chan error, 1)
|
|
400
|
+
go func() { exited <- cmd.Wait() }()
|
|
401
|
+
|
|
402
|
+
var info *ReadyInfo
|
|
403
|
+
deadline := time.Now().Add(wait)
|
|
404
|
+
for info == nil {
|
|
405
|
+
select {
|
|
406
|
+
case werr := <-exited:
|
|
407
|
+
_ = os.Remove(readyPath)
|
|
408
|
+
return nil, fmt.Errorf("后台服务启动即退出(%v),日志见 %s", werr, logPath)
|
|
409
|
+
case <-time.After(50 * time.Millisecond):
|
|
410
|
+
}
|
|
411
|
+
ri, rerr := readReadyFile(readyPath)
|
|
412
|
+
if rerr == nil && ri != nil {
|
|
413
|
+
info = ri
|
|
414
|
+
break
|
|
415
|
+
}
|
|
416
|
+
if time.Now().After(deadline) {
|
|
417
|
+
_ = agent.TerminatePID(cmd.Process.Pid, false)
|
|
418
|
+
_ = os.Remove(readyPath)
|
|
419
|
+
return nil, fmt.Errorf("等了 %s 仍未拿到监听端口,日志见 %s", wait, logPath)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
_ = os.Remove(readyPath)
|
|
423
|
+
|
|
424
|
+
st := &DaemonState{
|
|
425
|
+
PID: info.PID,
|
|
426
|
+
Port: info.Port,
|
|
427
|
+
URL: info.URL,
|
|
428
|
+
LanAddrs: info.LanAddrs,
|
|
429
|
+
Lan: info.Lan,
|
|
430
|
+
Profile: o.Profile,
|
|
431
|
+
UIRoot: o.UIRoot,
|
|
432
|
+
MobileDir: o.MobileDir,
|
|
433
|
+
ProxyAddr: info.ProxyAddr,
|
|
434
|
+
Token: tok,
|
|
435
|
+
StartedAt: time.Now(),
|
|
436
|
+
LogPath: logPath,
|
|
437
|
+
StatePath: statePath,
|
|
438
|
+
Exe: exe,
|
|
439
|
+
}
|
|
440
|
+
if st.PID == 0 {
|
|
441
|
+
st.PID = cmd.Process.Pid
|
|
442
|
+
}
|
|
443
|
+
if err := writeDaemonState(statePath, st); err != nil {
|
|
444
|
+
return nil, err
|
|
445
|
+
}
|
|
446
|
+
return st, nil
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// StopOptions `stop` 的参数。
|
|
450
|
+
type StopOptions struct {
|
|
451
|
+
StatePath string
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// StopDaemon 停掉后台实例。
|
|
455
|
+
//
|
|
456
|
+
// 返回 stopped 表示「这次真的停了它」;本来没跑 → (false, nil, nil),退出码仍是 0
|
|
457
|
+
// (脚本里 `stop` 后接 `start` 不该因为「本来就没跑」而失败)。
|
|
458
|
+
func StopDaemon(o StopOptions) (bool, *DaemonState, error) {
|
|
459
|
+
statePath := strings.TrimSpace(o.StatePath)
|
|
460
|
+
if statePath == "" {
|
|
461
|
+
statePath = DefaultStatePath()
|
|
462
|
+
}
|
|
463
|
+
st, err := ReadDaemonState(statePath)
|
|
464
|
+
if err != nil {
|
|
465
|
+
return false, nil, err
|
|
466
|
+
}
|
|
467
|
+
if st == nil {
|
|
468
|
+
return false, nil, nil
|
|
469
|
+
}
|
|
470
|
+
if !agent.PIDAlive(st.PID) {
|
|
471
|
+
_ = os.Remove(statePath)
|
|
472
|
+
return false, st, nil
|
|
473
|
+
}
|
|
474
|
+
// SIGTERM 先礼:serve 收到后会 srv.Close(),正在跑的请求有机会收尾。
|
|
475
|
+
_ = agent.TerminatePID(st.PID, false)
|
|
476
|
+
deadline := time.Now().Add(stopGrace)
|
|
477
|
+
for time.Now().Before(deadline) {
|
|
478
|
+
if !agent.PIDAlive(st.PID) {
|
|
479
|
+
_ = os.Remove(statePath)
|
|
480
|
+
return true, st, nil
|
|
481
|
+
}
|
|
482
|
+
time.Sleep(50 * time.Millisecond)
|
|
483
|
+
}
|
|
484
|
+
_ = agent.KillPID(st.PID, false)
|
|
485
|
+
_ = os.Remove(statePath)
|
|
486
|
+
return true, st, nil
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// DaemonStatus 一份对外可读的运行状态(给 `status` 与排障用)。
|
|
490
|
+
type DaemonStatus struct {
|
|
491
|
+
Running bool `json:"running"`
|
|
492
|
+
State *DaemonState `json:"state,omitempty"`
|
|
493
|
+
Health *Health `json:"health,omitempty"`
|
|
494
|
+
Note string `json:"note,omitempty"`
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// StatusDaemon 读状态并探活。
|
|
498
|
+
//
|
|
499
|
+
// 判据是「pid 活着 **且** 能答上 /desk/health」:只有 pid 活着但服务不可用时,
|
|
500
|
+
// 报 running 会让人以为能用,实际手机连不上。
|
|
501
|
+
func StatusDaemon(statePath string) (*DaemonStatus, error) {
|
|
502
|
+
if strings.TrimSpace(statePath) == "" {
|
|
503
|
+
statePath = DefaultStatePath()
|
|
504
|
+
}
|
|
505
|
+
st, err := ReadDaemonState(statePath)
|
|
506
|
+
if err != nil {
|
|
507
|
+
return nil, err
|
|
508
|
+
}
|
|
509
|
+
if st == nil {
|
|
510
|
+
return &DaemonStatus{Running: false, Note: "没有状态文件:从未 start,或已 stop"}, nil
|
|
511
|
+
}
|
|
512
|
+
out := &DaemonStatus{State: st}
|
|
513
|
+
if !agent.PIDAlive(st.PID) {
|
|
514
|
+
out.Note = "进程已不在(pid " + fmt.Sprint(st.PID) + " 无响应)"
|
|
515
|
+
return out, nil
|
|
516
|
+
}
|
|
517
|
+
h, herr := probeHealth(st.Port, st.Token)
|
|
518
|
+
if herr != nil {
|
|
519
|
+
out.Note = "进程在,但健康检查失败:" + herr.Error()
|
|
520
|
+
return out, nil
|
|
521
|
+
}
|
|
522
|
+
out.Running = true
|
|
523
|
+
out.Health = h
|
|
524
|
+
return out, nil
|
|
525
|
+
}
|