magic-agent 2.4.3 → 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.
Files changed (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -288,3 +288,106 @@ func TestAutoConsolidateMarksAuto(t *testing.T) {
288
288
  t.Errorf("自动归纳应带 --requirement-auto,实际 %v", readCalls(t, logPath))
289
289
  }
290
290
  }
291
+
292
+ // TestAutoConsolidateUsesDefaultEngine 自动归纳要和手动「整理」同一个口径:
293
+ // 用「我的 → 默认引擎/模型」(/desk/prefs 存的那份),而不是固定 llm。
294
+ //
295
+ // 2026-10-26 用户:「手动自动都要默认设置的引擎」。改之前自动路径硬传空串,
296
+ // 界面上配了 dsh 也没用,后台永远走 llm 的默认模型。
297
+ func TestAutoConsolidateUsesDefaultEngine(t *testing.T) {
298
+ // 隔离家目录:mobile-prefs.json 落这里(Windows 读 USERPROFILE,两边都设)。
299
+ home := t.TempDir()
300
+ t.Setenv("HOME", home)
301
+ t.Setenv("USERPROFILE", home)
302
+ if err := os.MkdirAll(filepath.Join(home, ".magic-agent"), 0o755); err != nil {
303
+ t.Fatal(err)
304
+ }
305
+ prefs := `{"magic.engine":"dsh","magic.model":"modelverse/deepseek-v4.1-flash-1017",` +
306
+ `"magic.fallback.engine":"claude","magic.fallback.model":"opus"}`
307
+ if err := os.WriteFile(filepath.Join(home, ".magic-agent", "mobile-prefs.json"),
308
+ []byte(prefs), 0o600); err != nil {
309
+ t.Fatal(err)
310
+ }
311
+
312
+ logPath := filepath.Join(t.TempDir(), "calls.log")
313
+ core := reqCore(t, `"feature.session.events","feature.session.control",`+
314
+ `"`+FeatureRequirements+`"`, logPath)
315
+ svc := newTestService(t, core)
316
+ svc.runAutoConsolidate("/proj")
317
+
318
+ var line string
319
+ for _, c := range readCalls(t, logPath) {
320
+ if strings.Contains(c, "--consolidate-requirements /proj") {
321
+ line = c
322
+ }
323
+ }
324
+ if line == "" {
325
+ t.Fatalf("自动归纳没调 core:%v", readCalls(t, logPath))
326
+ }
327
+ if !strings.Contains(line, "--requirement-engine dsh") {
328
+ t.Errorf("自动归纳应带默认引擎 dsh,实际 %q", line)
329
+ }
330
+ if !strings.Contains(line, "--requirement-model modelverse/deepseek-v4.1-flash-1017") {
331
+ t.Errorf("自动归纳应带默认模型,实际 %q", line)
332
+ }
333
+ if !strings.Contains(line, "--requirement-auto") {
334
+ t.Errorf("带上引擎也不能丢 --requirement-auto,实际 %q", line)
335
+ }
336
+ // 备用引擎是「主引擎失败才切」的,绝不能当成默认值顶上。
337
+ if strings.Contains(line, "claude") || strings.Contains(line, "opus") {
338
+ t.Errorf("自动归纳不该用备用引擎,实际 %q", line)
339
+ }
340
+ }
341
+
342
+ // TestAutoConsolidateNoPrefsFallsBack 没配过默认引擎 = 不传,交给 core 的默认(llm)。
343
+ // 既不能编一个引擎出来,也不能因为文件缺失让后台路径报错。
344
+ func TestAutoConsolidateNoPrefsFallsBack(t *testing.T) {
345
+ home := t.TempDir()
346
+ t.Setenv("HOME", home)
347
+ t.Setenv("USERPROFILE", home) // 没有 mobile-prefs.json
348
+
349
+ logPath := filepath.Join(t.TempDir(), "calls.log")
350
+ core := reqCore(t, `"feature.session.events","feature.session.control",`+
351
+ `"`+FeatureRequirements+`"`, logPath)
352
+ svc := newTestService(t, core)
353
+ svc.runAutoConsolidate("/proj")
354
+
355
+ var line string
356
+ for _, c := range readCalls(t, logPath) {
357
+ if strings.Contains(c, "--consolidate-requirements /proj") {
358
+ line = c
359
+ }
360
+ }
361
+ if line == "" {
362
+ t.Fatalf("自动归纳没调 core:%v", readCalls(t, logPath))
363
+ }
364
+ if strings.Contains(line, "--requirement-engine") || strings.Contains(line, "--requirement-model") {
365
+ t.Errorf("没配默认引擎时不该带引擎/模型,实际 %q", line)
366
+ }
367
+ }
368
+
369
+ // TestAutoConsolidateRespectsEngineWhitelist profile 白名单外的默认引擎不能让自动归纳
370
+ // 每轮都失败(core 会 403):传空回落 core 默认,而不是硬传。
371
+ func TestAutoConsolidateRespectsEngineWhitelist(t *testing.T) {
372
+ home := t.TempDir()
373
+ t.Setenv("HOME", home)
374
+ t.Setenv("USERPROFILE", home)
375
+ if err := os.MkdirAll(filepath.Join(home, ".magic-agent"), 0o755); err != nil {
376
+ t.Fatal(err)
377
+ }
378
+ if err := os.WriteFile(filepath.Join(home, ".magic-agent", "mobile-prefs.json"),
379
+ []byte(`{"magic.engine":"dsh","magic.model":"m"}`), 0o600); err != nil {
380
+ t.Fatal(err)
381
+ }
382
+ logPath := filepath.Join(t.TempDir(), "calls.log")
383
+ svc := newTestService(t, reqCore(t, `"feature.session.events","feature.session.control",`+
384
+ `"`+FeatureRequirements+`"`, logPath))
385
+ svc.opts.Profile.Engines = []string{"claude"}
386
+ svc.runAutoConsolidate("/proj")
387
+
388
+ for _, c := range readCalls(t, logPath) {
389
+ if strings.Contains(c, "--requirement-engine") {
390
+ t.Errorf("白名单外的引擎不该透传,实际 %q", c)
391
+ }
392
+ }
393
+ }
@@ -0,0 +1,288 @@
1
+ package client
2
+
3
+ /* restart.go - `POST /desk/restart`:让手机上「我的 → 重启服务」能重启桌面端客户端。
4
+ *
5
+ * ── 为什么不能在本进程里直接做 ──
6
+ * 重启 = 停掉自己再拉起自己。StopDaemon 会 TerminatePID 本进程,写在同一个进程里
7
+ * 等于「自杀到一半」:响应发不出去、后面的 start 也轮不到。
8
+ * 所以这里只做三件事:**先立标志、先答 200、再由进程内定时器拉起**一个脱离终端的
9
+ * `magic-client restart` 助手;助手停旧实例、按状态文件里的参数拉起新实例
10
+ * (见 daemon.go 的 RestartDaemon)。
11
+ *
12
+ * ── 地址变了怎么办(2026-10-25 用户:「重启后如果地址变化了也要刷新原环境的连接地址和token」)──
13
+ * 令牌跨重启稳定(token 文件),地址却未必:换网 / 换 Wi-Fi 后网卡 IP 变了、状态文件丢了
14
+ * 换了令牌、端口撞了。手机存的是「地址 + 令牌」,旧的就永远连不上。解法是让**旧实例**
15
+ * 在死之前把「新实例的手机入口 + 令牌」挂到自己的 /desk/health 上(restartHandoff):
16
+ * 手机探活时顺走,改完地址与令牌再连(见 client-ui-mobile 的 restartClient)。
17
+ *
18
+ * ── 为什么要「先标志、定时器后触发」(2026-10-25 用户定调)──
19
+ * 旧链路在 handler 里当场 spawn 带 `--delay 1s` 的助手,两个毛病:
20
+ * ① 重复点击(双击 / 手机重试 / 两台设备同时点)各起一个助手,两个助手互相把对方
21
+ * 刚拉起的实例停掉 —— 端口抢占,手机 30 秒以上还「重启后连不上」;
22
+ * ② 助手在 handler 里就起身,与「响应 flush 回手机」只隔一个 --delay,先后靠运气。
23
+ * 新链路:标志让重复点击变成一句「已在重启中」(幂等,不再排第二个定时器);
24
+ * 定时器到点才拉助手,严格晚于 handler 返回 —— 响应一定先落地,服务才可能被杀。
25
+ * 标志同时经 /desk/health.restarting 带出去:手机探活看到它 = 旧实例认账、即将下线。
26
+ *
27
+ * ── 为什么先校验状态文件 ──
28
+ * 重启参数全来自 `~/.magic-client/client.json`。如果那个文件不存在(前台 serve 起的)
29
+ * 或指向的不是本进程(同机跑着两个实例),照着它重启会重启**别人**。两种都当场拒绝,
30
+ * 让手机看到能落地的一句话,而不是「点了没反应」。
31
+ */
32
+
33
+ import (
34
+ "log"
35
+ "net"
36
+ "net/http"
37
+ "os"
38
+ "os/exec"
39
+ "strconv"
40
+ "strings"
41
+ "sync"
42
+ "time"
43
+ )
44
+
45
+ // restartDelay 定时器等多久再拉助手:给这次 200 响应留落地时间。
46
+ // 是 var 不是 const:测试里改小,不用真等一秒。
47
+ var restartDelay = time.Second
48
+
49
+ // restartHandoffTTL 旧实例认账、把「新实例的地址 + 令牌」经 /desk/health 交代出去的时长。
50
+ //
51
+ // 为什么需要(2026-10-25 用户:「重启后如果地址变化了也要刷新原环境的连接地址和token」):
52
+ // 手机里存的是「地址 + 令牌」。令牌跨重启稳定,地址却会变 —— 换了 Wi-Fi / 热点、watchdog
53
+ // 起了另一个监听地址、状态文件丢了重发了一个令牌……新实例起来后旧地址就**永远**连不上了,
54
+ // 手机只能看到一片「连不上」,不知道新地址是什么。而旧实例恰恰在「刚答完 200、还没死」的那
55
+ // 一小段里能算出新实例的地址(按即将生效的参数),所以让它把结论挂在自己的 /desk/health 上,
56
+ // 手机在探活时顺手取走,改完地址与令牌再连。
57
+ //
58
+ // 2 分钟 ≈ 助手停旧起新的正常耗时(3–7 秒,抢端口时见过 30 秒以上)的余量:
59
+ // 新实例真起来后旧实例就没了,这段窗口只是兜住「新实例没起来时旧实例还活着」的情形。
60
+ const restartHandoffTTL = 2 * time.Minute
61
+
62
+ // restartHandoffMu 守着下面那组交接结果(定时器 goroutine 写、handler 读)。
63
+ var (
64
+ restartHandoffMu sync.Mutex
65
+ restartHandoffURLs []string // 新实例的手机可用入口(不带令牌,形如 http://10.0.0.5:9100/)
66
+ restartHandoffToken string // 新实例的令牌(同机局域网门锁,只回给已持令牌的 /desk/health)
67
+ restartHandoffUntil time.Time
68
+ )
69
+
70
+ // setRestartHandoff 记下「新实例会是什么样」,供 /desk/health 交代出去。
71
+ func setRestartHandoff(urls []string, token string) {
72
+ restartHandoffMu.Lock()
73
+ defer restartHandoffMu.Unlock()
74
+ restartHandoffURLs = append([]string(nil), urls...)
75
+ restartHandoffToken = token
76
+ restartHandoffUntil = time.Now().Add(restartHandoffTTL)
77
+ }
78
+
79
+ // restartHandoff 当前该交代的新实例信息;现在不该交代 → nil。
80
+ //
81
+ // 正常情况下(旧实例已经出过交接)就按交接本身交代,不论重启标志:手机可能在
82
+ // restarting 变 true 之后就取走了、第二次探活发生在标志被复位/用例隔离之后。
83
+ // 什么都没交接(进程刚起来)→ 不交代。
84
+ func restartHandoff() map[string]any {
85
+ if !restartRequested() && !restartHandoffArmed() {
86
+ return nil
87
+ }
88
+ restartHandoffMu.Lock()
89
+ defer restartHandoffMu.Unlock()
90
+ if time.Now().After(restartHandoffUntil) {
91
+ return nil
92
+ }
93
+ if len(restartHandoffURLs) == 0 && restartHandoffToken == "" {
94
+ return nil
95
+ }
96
+ return map[string]any{
97
+ "urls": append([]string(nil), restartHandoffURLs...),
98
+ "token": restartHandoffToken,
99
+ }
100
+ }
101
+
102
+ // restartHandoffArmed 是否真的出过一次交接(与过期无关)。
103
+ func restartHandoffArmed() bool {
104
+ restartHandoffMu.Lock()
105
+ defer restartHandoffMu.Unlock()
106
+ return !restartHandoffUntil.IsZero()
107
+ }
108
+
109
+ // handoffOfPlan 按重启助手要用的启动参数,算出新实例的入口与令牌。
110
+ //
111
+ // 顺序有讲究:**公网域名排第一**。隧道是壳起的、magic-client 重启不碰它,所以域名是
112
+ // 「重启后最不容易失效」的入口(2026-10-25 用户:「重启也尽量不要变 cloudflare 域名
113
+ // 就可以了」)。局域网地址排在后面,只在没开公网时才当主入口。
114
+ //
115
+ // 端口:新实例显式 `--addr` 时就是它;局域网实例没写 addr 时 serve 侧固定绑
116
+ // DefaultLanAddr(见 cmd/magic-client/main.go),RestartDaemon → StartDaemon 也显式带上。
117
+ // 两侧都拿不到端口 = 会退化成随机端口,算不准就不报局域网地址。
118
+ // 本地回环(127.0.0.1:端口)对手机没用,不放进 urls。
119
+ func handoffOfPlan(plan *DaemonState, publicURL string) ([]string, string) {
120
+ if plan == nil {
121
+ return nil, ""
122
+ }
123
+ token := strings.TrimSpace(plan.Token)
124
+ var out []string
125
+ if u := strings.TrimRight(strings.TrimSpace(publicURL), "/"); u != "" {
126
+ out = append(out, u+"/")
127
+ }
128
+ if !plan.Lan {
129
+ return out, token
130
+ }
131
+ listen := strings.TrimSpace(plan.Addr)
132
+ if listen == "" {
133
+ listen = DefaultLanAddr
134
+ }
135
+ host, portStr, err := net.SplitHostPort(listen)
136
+ if err != nil {
137
+ return out, token
138
+ }
139
+ port, err := strconv.Atoi(portStr)
140
+ if err != nil || port <= 0 {
141
+ return out, token
142
+ }
143
+ if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
144
+ return out, token // 只绑了回环:手机够不着,别报一个连不上的地址
145
+ }
146
+ for _, a := range LanAddresses(port) {
147
+ out = append(out, "http://"+a+"/")
148
+ }
149
+ return out, token
150
+ }
151
+
152
+ // restartPending 本进程是否已受理过一次重启(「标志」)。
153
+ //
154
+ // 为什么必须在服务端记:重复点击的对手不只是这一台手机 —— 手机重试、另一台设备
155
+ // 同时点,都会各带一个 POST 过来。没有标志就会各排一个定时器、各起一个助手,
156
+ // 两个助手互相把对方刚拉起的实例停掉(端口抢占,输家要等 ready 超时)。
157
+ var (
158
+ restartMu sync.Mutex
159
+ restartPending bool
160
+ )
161
+
162
+ // restartSpawner 定时器到点时真正干活的人:脱离终端拉一个 `magic-client restart`
163
+ // 助手停旧起新。包级变量是为了测试能换成不碰真进程的假动作。
164
+ var restartSpawner = spawnRestartHelper
165
+
166
+ // restartOnTimer 定时器到点时做的事:先把「新实例的样子」挂到 /desk/health 上交代出去,
167
+ // 再拉助手。交接必须抢在停旧实例(spawn 之后秒级)之前,否则手机根本来不及取。
168
+ // 是 var 而不是直接调:用例要能观察「交接在助手起身之前就有了」。
169
+ var restartOnTimer = func(st *DaemonState, publicURL, exe string, lg *log.Logger) {
170
+ // 新实例的启动参数 = 旧实例状态文件里那一套(RestartDaemon → StartDaemon 原样用)。
171
+ urls, token := handoffOfPlan(st, publicURL)
172
+ setRestartHandoff(urls, token)
173
+ restartSpawner(exe, lg, statePathOf(st))
174
+ }
175
+
176
+ // statePathOf 这份状态记录自己所在的路径(重启助手要照着它去找回同一份参数)。
177
+ func statePathOf(st *DaemonState) string {
178
+ if st == nil {
179
+ return ""
180
+ }
181
+ return st.StatePath
182
+ }
183
+
184
+ // restartRequested 本进程是否已受理过重启。/desk/health 带这个字段,
185
+ // 手机据此把「旧实例认账」当成「它马上要下线」,不必等连接真的断。
186
+ func restartRequested() bool {
187
+ restartMu.Lock()
188
+ defer restartMu.Unlock()
189
+ return restartPending
190
+ }
191
+
192
+ // handleRestart 重启后台实例(手机「我的」页那枚按钮)。
193
+ func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) {
194
+ if r.Method != http.MethodPost {
195
+ writeErr(w, http.StatusMethodNotAllowed, "POST only")
196
+ return
197
+ }
198
+ /* 认领自己(2026-10-26 用户:「通过 magic-test 启动的无法在移动端点击重启服务重启」)。
199
+ *
200
+ * 这里原来只读 DefaultStatePath()(= `magic-client start` 的 client.json),
201
+ * 于是**前台 serve** 一律撞 409「当前实例不是后台启动的」。而 magic-test 的桥接正是
202
+ * `spawn(bin, ['serve', ...])` 起的实例 —— 手机上连的就是它,按钮永远点不动。
203
+ *
204
+ * selfDaemonState() 是「按本进程 pid 认领状态文件」的通用实现(先看 --state / 环境变量,
205
+ * 再扫目录里 client*.json),前台 serve 现在也登记了自己那份(serve.json),
206
+ * 两条启动路径因此共用同一条重启链路。
207
+ *
208
+ * 仍然拒绝「状态文件指向别人」:同机可能跑着好几个实例,重启错的等于把别人的服务踢掉。 */
209
+ st := s.selfDaemonState()
210
+ if st == nil {
211
+ /* 分两种说法,别让人对着同一句话反复试:
212
+ · 目录里**有**别人的登记 → 这台进程是手工 serve 且没登记(老版本 / 显式跳过),
213
+ 只能去终端重启;
214
+ · 目录里一份都没有 → 从没 start 过,也没新版本的自登记。 */
215
+ writeErr(w, http.StatusConflict,
216
+ "这个实例没有可用的状态记录(不是 `magic-client start` 起的,也不是登记过自己的 serve):"+
217
+ "不能自重启。请到桌面终端执行 magic-client restart,或用 magic-client start 重启服务")
218
+ return
219
+ }
220
+ exe, err := os.Executable()
221
+ if err != nil {
222
+ writeErr(w, http.StatusInternalServerError, "定位自身可执行文件失败:"+err.Error())
223
+ return
224
+ }
225
+
226
+ /* 标志:本进程已受理过一次重启。重复点击(双击 / 手机重试 / 另一台设备同时点)
227
+ 直接答「已在重启中」—— 不再排第二个定时器、不再起第二个助手。 */
228
+ restartMu.Lock()
229
+ if restartPending {
230
+ restartMu.Unlock()
231
+ writeJSON(w, http.StatusOK, map[string]any{
232
+ "ok": true, "restarting": true, "already_pending": true, "pid": os.Getpid(),
233
+ })
234
+ return
235
+ }
236
+ restartPending = true
237
+ restartMu.Unlock()
238
+
239
+ // 先答,再让定时器触发:handler 返回后响应才真正落回手机,AfterFunc 在这一刻
240
+ // 之后才跑 —— 助手起身严格晚于响应落地,不存在「先把服务杀了、响应卡在缓冲区」。
241
+ writeJSON(w, http.StatusOK, map[string]any{
242
+ "ok": true,
243
+ "restarting": true,
244
+ "pid": os.Getpid(),
245
+ })
246
+ /* 公网域名要在**启动定时器之前**取:它是「重启后最不容易失效」的入口
247
+ (隧道由壳托管、客户端重启不碰它),必须进交接里告诉手机。 */
248
+ pub := s.publicURLOrEmpty()
249
+ time.AfterFunc(restartDelay, func() { restartOnTimer(st, pub, exe, s.log) })
250
+ s.log.Printf("收到重启请求:%s 后由定时器拉起助手停旧起新(旧实例即将停止);"+
251
+ "期间 /desk/health 会交代新实例的地址与令牌", restartDelay)
252
+ }
253
+
254
+ // spawnRestartHelper 脱离终端拉一个 `magic-client restart` 助手:它停旧实例、按状态
255
+ // 文件里的参数起新实例。不带 --delay —— 定时器已经等过 restartDelay 了。
256
+ //
257
+ // ⚠️ `--state` 必须显式传(2026-10-26):助手默认读 ~/.magic-client/client.json,
258
+ // 而本进程可能是**前台 serve**(magic-test 桥接那种),它的登记在 <数据目录>/serve.json。
259
+ // 不传的话助手会去重启**另一个**实例 —— 最坏情况是把同机 9100 那个 `start` 实例停掉,
260
+ // 而用户手机连着的这个照旧活着:界面上就是「点了重启,连的那个没动静,另一个却重启了」。
261
+ func spawnRestartHelper(exe string, lg *log.Logger, statePath string) {
262
+ logPath := DefaultLogPath()
263
+ var logf *os.File
264
+ if logPath != "" {
265
+ logf, _ = os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
266
+ }
267
+ args := []string{"restart"}
268
+ if p := strings.TrimSpace(statePath); p != "" {
269
+ args = append(args, "--state", p)
270
+ }
271
+ cmd := exec.Command(exe, args...)
272
+ if logf != nil {
273
+ cmd.Stdout, cmd.Stderr = logf, logf
274
+ }
275
+ detach(cmd)
276
+ if err := cmd.Start(); err != nil {
277
+ if lg != nil {
278
+ lg.Printf("重启失败(spawn %s restart):%v(手机侧可去桌面执行 magic-client restart)", exe, err)
279
+ }
280
+ _ = logf.Close()
281
+ return
282
+ }
283
+ // 子进程已拿到自己的句柄:父进程这份可以关了(它马上要被停)。
284
+ _ = logf.Close()
285
+ if lg != nil {
286
+ lg.Printf("重启助手已启动:%s restart --state %s(旧实例即将停止)", exe, statePath)
287
+ }
288
+ }
@@ -0,0 +1,230 @@
1
+ package client
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "log"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "testing"
13
+ "time"
14
+ )
15
+
16
+ /*
17
+ 重启交接(2026-10-25 用户:「重启后如果地址变化了也要刷新原环境的连接地址和token」):
18
+
19
+ 旧实例受理重启后,在定时器到点、停自己之前,把「新实例的手机入口 + 令牌」挂到
20
+ /desk/health.restartHandoff 上;手机探活时取走,改完原环境再连。
21
+ */
22
+
23
+ func resetRestartHandoffForTest() {
24
+ restartHandoffMu.Lock()
25
+ restartHandoffURLs, restartHandoffToken, restartHandoffUntil = nil, "", time.Time{}
26
+ restartHandoffMu.Unlock()
27
+ }
28
+
29
+ // healthBody 打一次 /desk/health,返回原始 JSON。
30
+ func healthBody(t *testing.T, svc *Service) map[string]any {
31
+ t.Helper()
32
+ rec := httptest.NewRecorder()
33
+ svc.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/desk/health", nil))
34
+ if rec.Code != http.StatusOK {
35
+ t.Fatalf("health = %d,want 200(body: %s)", rec.Code, rec.Body.String())
36
+ }
37
+ var out map[string]any
38
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
39
+ t.Fatalf("解析 health 失败: %v(%s)", err, rec.Body.String())
40
+ }
41
+ return out
42
+ }
43
+
44
+ // 没受理重启 / 已过期 → 不带 restartHandoff(老语义:只有 restarting 标志)。
45
+ // 受理后 → 带 urls + token;交接值在定时器到点(助手起身)之前就已有,不是等助手回来。
46
+ func TestHealthRestartHandoff(t *testing.T) {
47
+ prof := DefaultProfile()
48
+ prof.Storage.Dir = t.TempDir()
49
+ svc := &Service{log: log.New(io.Discard, "", 0), opts: Options{Profile: prof}}
50
+
51
+ resetRestartPendingForTest()
52
+ resetRestartHandoffForTest()
53
+ defer resetRestartPendingForTest()
54
+ defer resetRestartHandoffForTest()
55
+
56
+ if raw, ok := healthBody(t, svc)["restartHandoff"]; ok && raw != nil {
57
+ armed := restartHandoffArmed()
58
+ restartHandoffMu.Lock()
59
+ until := restartHandoffUntil
60
+ restartHandoffMu.Unlock()
61
+ t.Fatalf("没重启时 restartHandoff 应为 null(json 里键常在、值为 null),实际 %v(armed=%v until=%v pending=%v)",
62
+ raw, armed, until, restartRequested())
63
+ }
64
+
65
+ setRestartHandoff([]string{"http://10.1.2.3:9100/"}, "new-token-0123456789abcdef")
66
+ got, ok := healthBody(t, svc)["restartHandoff"].(map[string]any)
67
+ if !ok {
68
+ t.Fatal("受理重启后 health 应带 restartHandoff")
69
+ }
70
+ if got["token"] != "new-token-0123456789abcdef" {
71
+ t.Errorf("restartHandoff.token = %v,want 新实例令牌", got["token"])
72
+ }
73
+ urls, _ := got["urls"].([]any)
74
+ if len(urls) != 1 || urls[0] != "http://10.1.2.3:9100/" {
75
+ t.Errorf("restartHandoff.urls = %v,want [http://10.1.2.3:9100/]", got["urls"])
76
+ }
77
+
78
+ // 过期(旧实例一直没被停掉)→ 不再交代,别让手机拿一份可能已过时的地址一直重连。
79
+ restartHandoffMu.Lock()
80
+ restartHandoffUntil = time.Now().Add(-time.Second)
81
+ restartHandoffMu.Unlock()
82
+ if raw := healthBody(t, svc)["restartHandoff"]; raw != nil {
83
+ t.Errorf("交接过期后 health 不该再带 restartHandoff,实际 %v", raw)
84
+ }
85
+ // 从没交接过的实例(刚起来)即便 restarting 被立着也不报空交接。
86
+ resetRestartHandoffForTest()
87
+ if raw := healthBody(t, svc)["restartHandoff"]; raw != nil {
88
+ t.Errorf("没算出交接内容时不该带空 restartHandoff,实际 %v", raw)
89
+ }
90
+ }
91
+
92
+ // 只绑回环 / 关掉局域网:手机够不着,不是「地址变了」,不交代(仍交代令牌 —— 令牌换了
93
+ // 手机同样连不上,哪怕地址没变)。
94
+ func TestHandoffOfPlanSkipsLoopback(t *testing.T) {
95
+ urls, token := handoffOfPlan(&DaemonState{Lan: false, Addr: "127.0.0.1:9100", Token: "tk-0123456789abcdef0123"}, "")
96
+ if len(urls) != 0 {
97
+ t.Errorf("只绑回环时不该报 URL,实际 %v", urls)
98
+ }
99
+ if token != "tk-0123456789abcdef0123" {
100
+ t.Errorf("令牌应原样带出,实际 %q", token)
101
+ }
102
+ if urls, _ := handoffOfPlan(nil, ""); urls != nil {
103
+ t.Errorf("没有状态文件时不该报 URL,实际 %v", urls)
104
+ }
105
+ // 局域网实例没写 addr:serve 侧固定绑 DefaultLanAddr,端口必须算成 9100 而不是 0。
106
+ urls, _ = handoffOfPlan(&DaemonState{Lan: true, Port: 9100, Token: "tk"}, "")
107
+ for _, u := range urls {
108
+ if !strings.HasSuffix(u, ":9100/") {
109
+ t.Errorf("局域网实例的入口应带默认端口 9100,实际 %q", u)
110
+ }
111
+ if strings.Contains(u, "127.0.0.1") || strings.Contains(u, "localhost") {
112
+ t.Errorf("不该把回环地址报给手机:%q", u)
113
+ }
114
+ }
115
+ }
116
+
117
+ // 公网域名排第一(2026-10-25 用户:「重启也尽量不要变 cloudflare 域名就可以了」):
118
+ // 隧道由壳托管、客户端重启不碰它,域名是重启后最不容易失效的入口。
119
+ func TestHandoffOfPlanPrefersPublicURL(t *testing.T) {
120
+ urls, _ := handoffOfPlan(&DaemonState{Lan: true, Port: 9100, Token: "tk"}, "https://abc-xyz.trycloudflare.com")
121
+ if len(urls) == 0 || urls[0] != "https://abc-xyz.trycloudflare.com/" {
122
+ t.Fatalf("公网域名应排在交接第一位,实际 %v", urls)
123
+ }
124
+ // 只绑回环(--local)也照样交公网域名:手机走隧道,域名是唯一入口。
125
+ urls, _ = handoffOfPlan(&DaemonState{Lan: false, Addr: "127.0.0.1:9100", Token: "tk"}, "https://abc.trycloudflare.com")
126
+ if len(urls) != 1 || urls[0] != "https://abc.trycloudflare.com/" {
127
+ t.Errorf("--local 实例应只交公网域名,实际 %v", urls)
128
+ }
129
+ }
130
+
131
+ // 域名落盘后,重启(新进程)第一眼就能从 /desk/health 拿到它 —— 不必等探测。
132
+ func TestPublicURLSurvivesRestart(t *testing.T) {
133
+ prof := DefaultProfile()
134
+ prof.Storage.Dir = t.TempDir()
135
+ svc := &Service{opts: Options{Profile: prof}}
136
+ svc.savePublicURL("https://keep-me.trycloudflare.com")
137
+
138
+ // 模拟重启:另一个 Service(同一 profile)没有内存缓存。
139
+ fresh := &Service{opts: Options{Profile: prof}, log: log.New(io.Discard, "", 0)}
140
+ got := fresh.publicURL()
141
+ if got != "https://keep-me.trycloudflare.com" {
142
+ t.Fatalf("重启后 publicURL = %q,want 落盘域名(否则手机会退回局域网地址)", got)
143
+ }
144
+ // 落盘值读进内存后应立刻可用,且不因为「第一次探测还没回来」而丢。
145
+ if fresh.pubURL == "" {
146
+ t.Error("落盘域名应已进内存缓存")
147
+ }
148
+ }
149
+
150
+ // 隧道真没了:连续两次探空、且过了宽限期才删落盘值(只探空一次就删太激进)。
151
+ func TestSavedPublicURLClearedOnlyAfterRepeatedMiss(t *testing.T) {
152
+ prof := DefaultProfile()
153
+ prof.Storage.Dir = t.TempDir()
154
+ svc := &Service{opts: Options{Profile: prof}}
155
+
156
+ svc.savePublicURL("https://gone.trycloudflare.com")
157
+ svc.pubLoaded = true
158
+ svc.pubURL = "https://gone.trycloudflare.com"
159
+ svc.pubMiss = 0
160
+
161
+ // 第一次探空:宽限期内 → 域名顶着,文件也不删。
162
+ svc.pubGraceTo = time.Now().Add(publicGrace)
163
+ svc.applyProbeResult("")
164
+ if svc.pubURL != "https://gone.trycloudflare.com" {
165
+ t.Errorf("宽限期内探空不该撤掉已知域名,实际 %q", svc.pubURL)
166
+ }
167
+ if svc.loadSavedPublicURL() == "" {
168
+ t.Error("第一次探空不该删落盘域名")
169
+ }
170
+
171
+ // 宽限期结束 + 连续第二次探空 → 认定隧道没了:内存与落盘都清。
172
+ svc.pubGraceTo = time.Time{}
173
+ svc.applyProbeResult("")
174
+ if svc.pubURL != "" {
175
+ t.Errorf("两次探空后应清掉内存域名,实际 %q", svc.pubURL)
176
+ }
177
+ if v := svc.loadSavedPublicURL(); v != "" {
178
+ t.Errorf("两次探空后应删掉落盘域名,实际 %q", v)
179
+ }
180
+ }
181
+
182
+ // 定时器到点:先落交接,再拉助手(手机必须在旧实例被停之前就能取到新地址)。
183
+ func TestRestartTimerHandsOffBeforeSpawn(t *testing.T) {
184
+ dir := t.TempDir()
185
+ statePath := filepath.Join(dir, "client.json")
186
+ t.Setenv(EnvState, statePath)
187
+ exe, err := os.Executable()
188
+ if err != nil {
189
+ t.Fatal(err)
190
+ }
191
+ st := &DaemonState{PID: os.Getpid(), Port: 9100, Token: "old-token", Exe: exe, Lan: true}
192
+ if err := writeDaemonState(statePath, st); err != nil {
193
+ t.Fatal(err)
194
+ }
195
+
196
+ resetRestartPendingForTest()
197
+ resetRestartHandoffForTest()
198
+ defer resetRestartPendingForTest()
199
+ defer resetRestartHandoffForTest()
200
+
201
+ spawnedToken := make(chan string, 1)
202
+ oldSpawner := restartSpawner
203
+ restartSpawner = func(string, *log.Logger, string) {
204
+ // 助手起身的那一刻,交接必须已经在 /desk/health 上(否则手机来不及取)。
205
+ h, ok := restartHandoff()["token"].(string)
206
+ if !ok {
207
+ h = ""
208
+ }
209
+ spawnedToken <- h
210
+ }
211
+ defer func() { restartSpawner = oldSpawner }()
212
+ oldDelay := restartDelay
213
+ restartDelay = 50 * time.Millisecond
214
+ defer func() { restartDelay = oldDelay }()
215
+
216
+ s := &Service{log: log.New(io.Discard, "", 0)}
217
+ rec := httptest.NewRecorder()
218
+ s.handleRestart(rec, httptest.NewRequest(http.MethodPost, "/desk/restart", nil))
219
+ if rec.Code != http.StatusOK {
220
+ t.Fatalf("POST /desk/restart = %d,want 200(body: %s)", rec.Code, rec.Body.String())
221
+ }
222
+ select {
223
+ case tok := <-spawnedToken:
224
+ if tok != "old-token" {
225
+ t.Errorf("助手起身时交接里的令牌 = %q,want 状态文件里的令牌 old-token", tok)
226
+ }
227
+ case <-time.After(2 * time.Second):
228
+ t.Fatal("定时器没有拉起助手")
229
+ }
230
+ }