hostpad 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # hostpad
2
2
 
3
- HostPad 的 Mac 端伴侣:**MCP 服务器 + iPhone 加密同步桥**,让 AI agent(ZCode / Claude 等)直接在你的 iPhone 上开发、调试、迭代 iOS 工具(HTML/JS 小工具,跑在 [HostPad App] 里)。
3
+ HostPad 的 Mac 端伴侣:**MCP 服务器 + iPhone 加密同步桥**,让 AI agent(ZCode / Claude 等)直接在你的 iPhone 上开发、调试、迭代 HostPad 工具(**跑在 JavaScriptCore 的 JS 程序 + 宿主 API**,UI 经 `host.ui.render()` 渲染 HTML 片段——**不是网页,entry 必须是 main.js 这类 JS 入口**。能力面详见 `get_dev_guide` 工具)。
4
4
 
5
5
  单进程双面:
6
6
 
@@ -27,13 +27,14 @@ npx hostpad
27
27
  }
28
28
  ```
29
29
 
30
- ## MCP 工具(9 个)
30
+ ## MCP 工具(10 个)
31
31
 
32
32
  | 工具 | 作用 |
33
33
  |------|------|
34
34
  | `list_devices` | 列出连接的 iPhone(配对状态 / 设备名 / 地址) |
35
35
  | `pair` | 用手机屏显的 6 位配对码确认配对 |
36
- | `create_tool` / `update_tool` / `list_tools` / `delete_tool` | 管理工具(update 为覆盖合并:只传改动文件,推送即热重载) |
36
+ | `get_dev_guide` | **写工具前先读**:运行时能力面(能/不能用清单、host API、UI/交互模型、最小模板) |
37
+ | `create_tool` / `update_tool` / `list_tools` / `delete_tool` | 管理工具(update 为覆盖合并:只传改动文件,推送即热重载);list_tools 返回 toolsDir,直接落盘到 `<toolsDir>/<toolId>/` 也会自动推送 |
37
38
  | `reload` | 重推工具(激活) |
38
39
  | `get_logs` | 读手机回流的日志环形缓冲(工具 console.* 与系统事件,游标分页) |
39
40
  | `rpc_call` | 调用手机上运行中工具的 `onMessage` 处理器,返回值回传 |
@@ -61,4 +62,10 @@ npx hostpad [--port 8787] [--tools ./tools] [--insecure] [--pair-code 123456] [-
61
62
  - Node ≥ 18(零第三方依赖之外仅 `ws`)
62
63
  - iPhone 端需 HostPad App
63
64
 
65
+ ## 联调提示
66
+
67
+ - MCP stdio 走标准管道,单行长度无限制(130KB+ 的 `create_tool` 实测通过)。
68
+ **不要用 FIFO 手工模拟 stdin**:macOS kqueue 在 FIFO 上对长写入不再唤醒读端,
69
+ 大 payload 会假性"卡死",这不是本服务的限制。
70
+
64
71
  License: 目前未授权发布(UNLICENSED),供 HostPad 项目配套使用。
package/bin/hostpad.js CHANGED
@@ -6,6 +6,8 @@ const os = require('node:os');
6
6
  const readline = require('node:readline');
7
7
  const { createBridge } = require('../lib/bridge.js');
8
8
  const { createMcpServer } = require('../lib/mcp.js');
9
+ const { startDiscovery } = require('../lib/discovery.js');
10
+ const { GUIDE } = require('../lib/guide.js');
9
11
 
10
12
  // ---------- 参数 ----------
11
13
  function arg(name, def) {
@@ -14,8 +16,12 @@ function arg(name, def) {
14
16
  ? process.argv[i + 1] : def;
15
17
  }
16
18
  const PORT = parseInt(arg('port', '8787'), 10);
17
- const TOOLS = arg('tools', './tools');
18
- const STATE = arg('state', null);
19
+ // 默认落 ~/.hostpad/(用户级固定目录):相对 CWD ./tools 换目录启动就「工具全没」,
20
+ // npx 缓存包内的 state 清缓存即丢配对 token
21
+ const HOSTPAD_HOME = require('node:path').join(os.homedir(), '.hostpad');
22
+ const TOOLS = arg('tools', require('node:path').join(HOSTPAD_HOME, 'tools'));
23
+ const LEGACY_STATE = require('node:path').join(__dirname, '..', '.hostpad-state.json');
24
+ const STATE = arg('state', require('node:path').join(HOSTPAD_HOME, 'state.json'));
19
25
  const INSECURE = process.argv.includes('--insecure');
20
26
  const PAIR_CODE = arg('pair-code', null);
21
27
  const AUTO_CONFIRM = parseInt(arg('auto-confirm', '0'), 10);
@@ -23,10 +29,10 @@ const AUTO_CONFIRM = parseInt(arg('auto-confirm', '0'), 10);
23
29
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
24
30
  console.log(`hostpad — HostPad 的 MCP 服务器 + 手机同步桥
25
31
 
26
- 用法:npx hostpad [--port 8787] [--tools ./tools] [--insecure] [--pair-code 123456] [--auto-confirm 800]
32
+ 用法:npx hostpad [--port 8787] [--tools <dir>] [--insecure] [--pair-code 123456] [--auto-confirm 800]
27
33
 
28
34
  --port WebSocket 桥端口(默认 8787)
29
- --tools 工具目录(默认 ./tools
35
+ --tools 工具目录(默认 ~/.hostpad/tools;直接落盘到此目录会自动推送)
30
36
  --insecure 明文模式(本地快迭代/soak 用;默认加密 + 配对)
31
37
  --pair-code 固定配对码(自动化用)
32
38
  --auto-confirm <ms> 出现待配对设备后 ms 毫秒自动确认(需 --pair-code;自动化用)
@@ -39,10 +45,17 @@ MCP 客户端(ZCode/Claude 等)配置:
39
45
  }
40
46
 
41
47
  // ---------- 桥 ----------
48
+ require('node:fs').mkdirSync(HOSTPAD_HOME, { recursive: true });
49
+ // 一次性迁移:旧版本 state 落在 npm 包内,清 npx 缓存会丢全部配对 token
50
+ if (STATE !== LEGACY_STATE && !require('node:fs').existsSync(STATE)
51
+ && require('node:fs').existsSync(LEGACY_STATE)) {
52
+ require('node:fs').copyFileSync(LEGACY_STATE, STATE);
53
+ console.error('[hostpad] 已迁移配对 state →', STATE);
54
+ }
42
55
  const bridge = createBridge({
43
56
  port: PORT,
44
57
  toolsDir: TOOLS,
45
- statePath: STATE ?? require('node:path').join(__dirname, '..', '.hostpad-state.json'),
58
+ statePath: STATE,
46
59
  secure: !INSECURE,
47
60
  pairCode: PAIR_CODE,
48
61
  log: (...a) => console.error('[bridge]', ...a), // stdout 留给 MCP 协议
@@ -83,18 +96,26 @@ const tools = [
83
96
  },
84
97
  handler: async ({ code }) => bridge.pairConfirm(String(code)),
85
98
  },
99
+ {
100
+ name: 'get_dev_guide',
101
+ description: '【写工具前先读】HostPad 工具运行时能力面:能/不能用清单、host API、UI/交互模型、权限与资源限制、最小模板。工具跑在 JavaScriptCore(非网页)',
102
+ inputSchema: { type: 'object', properties: {} },
103
+ handler: async () => ({ guide: GUIDE }),
104
+ },
86
105
  {
87
106
  name: 'create_tool',
88
- description: '创建工具并推送到手机(推送即激活)。manifest: {id,name,version,entry,permissions?}',
107
+ description: '创建工具并推送到手机(推送即激活)。工具跑在 JavaScriptCore:entry 必须是 JS 入口文件(如 main.js,顶层执行,可选 main()),不能是 HTML;无 DOM/window/定时器/fetch;UI 用 host.ui.render({title, body:HTML片段}),交互用 data-hostpad 属性 + host.ui.onMessage;网络 host.http.request 需 permissions:["http"]。完整指南调 get_dev_guide',
89
108
  inputSchema: {
90
109
  type: 'object',
91
110
  properties: {
92
111
  manifest: {
93
112
  type: 'object',
113
+ description: '工具清单;entry 见 get_dev_guide',
94
114
  properties: {
95
115
  id: { type: 'string' }, name: { type: 'string' },
96
- version: { type: 'string' }, entry: { type: 'string' },
97
- permissions: { type: 'array', items: { type: 'string' } },
116
+ version: { type: 'string' },
117
+ entry: { type: 'string', description: 'JS 入口文件(如 main.js),不能是 HTML/CSS' },
118
+ permissions: { type: 'array', items: { type: 'string' }, description: '可选:http / clipboard / notifications' },
98
119
  },
99
120
  required: ['id', 'name', 'version', 'entry'],
100
121
  },
@@ -109,7 +130,7 @@ const tools = [
109
130
  },
110
131
  {
111
132
  name: 'update_tool',
112
- description: '更新工具并推送(覆盖合并:只传要改的文件)。可只改 manifest 或只改 files',
133
+ description: '更新工具并推送(覆盖合并:只传要改的文件)。可只改 manifest 或只改 files。entry 仍须为 JS 入口文件(契约见 get_dev_guide)',
113
134
  inputSchema: {
114
135
  type: 'object',
115
136
  properties: {
@@ -126,9 +147,12 @@ const tools = [
126
147
  },
127
148
  {
128
149
  name: 'list_tools',
129
- description: '列出 Mac 侧工具目录中的全部工具',
150
+ description: '列出 Mac 侧工具目录中的全部工具(返回含 toolsDir:直接把文件落盘到 <toolsDir>/<toolId>/ 也会触发自动推送)',
130
151
  inputSchema: { type: 'object', properties: {} },
131
- handler: async () => ({ tools: bridge.listTools() }),
152
+ handler: async () => ({
153
+ toolsDir: bridge.store.dir,
154
+ tools: bridge.listTools(),
155
+ }),
132
156
  },
133
157
  {
134
158
  name: 'delete_tool',
@@ -205,4 +229,6 @@ bridge.start().then(() => {
205
229
  for (const ip of nets) console.error(`[hostpad] 手机连接:ws://${ip}:${bridge.port}`);
206
230
  console.error(`[hostpad] 工具目录:${require('node:path').resolve(TOOLS)}`);
207
231
  console.error('[hostpad] MCP stdio 就绪(agent 请经 stdin/stdout 对话)');
232
+ // P1:局域网自动发现——UDP 广播 ws 地址(8792,每 2s),手机端「发现服务器」即收即连
233
+ startDiscovery({ port: bridge.port, insecure: INSECURE });
208
234
  });
package/lib/bridge.js CHANGED
@@ -20,13 +20,14 @@ function createBridge({
20
20
  pairCode = null,
21
21
  maxPairFails = 5,
22
22
  lockMs = 60_000,
23
+ codeTtlMs = 60_000,
23
24
  logsCap = 1000,
24
25
  watch = true,
25
26
  log = console.log,
26
27
  } = {}) {
27
28
  const store = createToolStore(toolsDir);
28
29
  const ratelimiter = createRateLimiter({ maxFails: maxPairFails, lockMs });
29
- const codeStore = createCodeStore({ ttl: 60_000 });
30
+ const codeStore = createCodeStore({ ttl: codeTtlMs });
30
31
 
31
32
  // ---------- 设备 token 状态(兼容 M1 字符串数组) ----------
32
33
  function loadState() {
@@ -100,13 +101,24 @@ function createBridge({
100
101
 
101
102
  // ---------- 配对 ----------
102
103
 
104
+ /** 发配对挑战;TTL 前 5s 自动换新码重发——输错/超时后手机屏上永远是有效码 */
103
105
  function startPairing(conn) {
106
+ if (conn.pairTimer) { clearTimeout(conn.pairTimer); conn.pairTimer = null; }
104
107
  conn.pair = null;
105
108
  const pairId = crypto.randomUUID();
106
109
  const code = codeStore.issue(pairId, pairCode);
107
110
  conn.pair = { pairId };
108
111
  sendTo(conn, { type: 'pair-challenge', pairId, code });
109
- log(`[pair] 设备 ${conn.device?.name ?? conn.ip} 显示配对码(60s 有效)`);
112
+ conn.pairTimer = setTimeout(() => {
113
+ conn.pairTimer = null;
114
+ if (conn.pair?.pairId === pairId) startPairing(conn);
115
+ }, Math.max(codeTtlMs - 5_000, Math.floor(codeTtlMs / 2)));
116
+ log(`[pair] 设备 ${conn.device?.name ?? conn.ip} 显示配对码(${codeTtlMs / 1000}s 内有效,过期自动换新)`);
117
+ }
118
+
119
+ function stopPairing(conn) {
120
+ if (conn.pairTimer) { clearTimeout(conn.pairTimer); conn.pairTimer = null; }
121
+ conn.pair = null;
110
122
  }
111
123
 
112
124
  /** MCP pair(code):对任一待配对连接校验配对码;错误计失败,达到阈值锁定 IP */
@@ -115,6 +127,7 @@ function createBridge({
115
127
  for (const conn of conns) {
116
128
  if (!conn.pair || conn.authed) continue;
117
129
  if (codeStore.verify(conn.pair.pairId, code)) {
130
+ const pairId = conn.pair.pairId;
118
131
  ratelimiter.reset(conn.ip);
119
132
  const token = crypto.randomUUID();
120
133
  tokens.push({
@@ -125,22 +138,23 @@ function createBridge({
125
138
  });
126
139
  saveState();
127
140
  conn.authed = true;
128
- const pairId = conn.pair.pairId;
129
- conn.pair = null;
141
+ stopPairing(conn);
130
142
  sendTo(conn, { type: 'pair-granted', pairId, token });
131
143
  log(`[pair] ✓ 配对成功 ${conn.device?.name ?? conn.ip}`);
132
144
  pushAll(conn, 'initial');
133
145
  resolve({ ok: true, device: conn.device });
134
146
  return;
135
147
  }
136
- // 该连接的码被消费但不匹配:清掉待配对态,手机重新 auth 可再触发
137
- conn.pair = null;
148
+ // 该连接的码被消费但不匹配:立即换新码重发(手机 UI 自动刷新),
149
+ // 失败计限速;达到阈值锁定 IP 并断开
138
150
  ratelimiter.fail(conn.ip);
139
151
  if (ratelimiter.isLocked(conn.ip)) {
152
+ stopPairing(conn);
140
153
  conn.ws.close(4003, 'locked');
141
154
  reject(new Error('IP 已锁定(配对失败次数过多),60s 后自动解除'));
142
155
  return;
143
156
  }
157
+ startPairing(conn);
144
158
  }
145
159
  reject(new Error('配对码不符或已过期'));
146
160
  });
@@ -292,6 +306,7 @@ function createBridge({
292
306
  });
293
307
  ws.on('close', () => {
294
308
  conns.delete(conn);
309
+ stopPairing(conn);
295
310
  for (const [rpcId, p] of pendingRpc) {
296
311
  if (p.conn === conn) { clearTimeout(p.timer); p.reject(new Error('设备断开')); pendingRpc.delete(rpcId); }
297
312
  }
@@ -0,0 +1,107 @@
1
+ // P1:服务器自动发现(发送侧)——UDP 广播,零依赖(仅 node:dgram / node:os)
2
+ //
3
+ // agent 进程启动后每 2s 向 255.255.255.255:8792 广播一行 UTF-8 JSON:
4
+ // {"v":1,"type":"hostpad-discovery","proto":"1.3","port":<ws端口>,"name":"<电脑名>",
5
+ // "insecure":<bool>,"ip":"<本机非internal IPv4>"}
6
+ // 协议要点:iOS Network 框架收 UDP 广播时 remoteEndpoint 常退化为广播地址本身(拿不到
7
+ // 发送方单播 IP),故报文显式携带 ip 字段为准——App 端 ws 地址 = ws://<ip>:<port>。
8
+ // 多网卡:每个非 internal IPv4 地址各广播一条(同名多 IP 在 App 端各自成候选,去重键 ip:port)。
9
+ // 生命周期:socket 与定时器均 unref,不阻止进程退出;stop() 显式收尾(幂等,测试用)。
10
+ 'use strict';
11
+ const dgram = require('node:dgram');
12
+ const os = require('node:os');
13
+
14
+ const DISCOVERY_PORT = 8792;
15
+ const BROADCAST_ADDR = '255.255.255.255';
16
+ const INTERVAL_MS = 2000;
17
+
18
+ /** 枚举本机可广播 IPv4(os.networkInterfaces() 里 family=IPv4 且非 internal;去重) */
19
+ function localIPv4s(interfaces = os.networkInterfaces()) {
20
+ const out = [];
21
+ for (const addrs of Object.values(interfaces ?? {})) {
22
+ for (const n of addrs ?? []) {
23
+ if (n && (n.family === 'IPv4' || n.family === 4) && !n.internal) out.push(n.address);
24
+ }
25
+ }
26
+ return [...new Set(out)];
27
+ }
28
+
29
+ /** 纯函数:要广播的 JSON 文本数组(一网卡一包;无可用网卡 → [],不抛错) */
30
+ function buildPackets(port, insecure, name, interfaces = os.networkInterfaces()) {
31
+ return localIPv4s(interfaces).map((ip) => JSON.stringify({
32
+ v: 1,
33
+ type: 'hostpad-discovery',
34
+ proto: '1.3',
35
+ port,
36
+ name,
37
+ insecure: Boolean(insecure),
38
+ ip,
39
+ }));
40
+ }
41
+
42
+ function createDiscovery({
43
+ port,
44
+ insecure = false,
45
+ name = os.hostname(),
46
+ broadcast = true, // 测试旗标:false 时单播到 target(回环收不到广播)
47
+ target = BROADCAST_ADDR,
48
+ discoveryPort = DISCOVERY_PORT,
49
+ intervalMs = INTERVAL_MS,
50
+ log = console.error, // 与 bin 口径一致:诊断走 stderr,stdout 留给 MCP 协议
51
+ } = {}) {
52
+ let socket = null;
53
+ let timer = null;
54
+ let stopped = false;
55
+ let logged = false;
56
+
57
+ function failOnce(stage, err) {
58
+ if (logged) return; // 协议口径:错误静默,只记一次
59
+ logged = true;
60
+ log(`[discovery] ${stage}失败:${err?.message ?? err}(发现广播已停)`);
61
+ }
62
+
63
+ function announce() {
64
+ if (!socket || stopped) return;
65
+ for (const packet of buildPackets(port, insecure, name)) {
66
+ socket.send(packet, discoveryPort, target, (err) => { if (err) failOnce('send', err); });
67
+ }
68
+ }
69
+
70
+ function start() {
71
+ socket = dgram.createSocket('udp4');
72
+ socket.on('error', (err) => { failOnce('socket', err); stop(); });
73
+ socket.bind(() => { // 随机本地口发送;bind 后再设 SO_BROADCAST 最稳妥
74
+ if (stopped) return;
75
+ if (broadcast) {
76
+ try { socket.setBroadcast(true); } catch (err) { failOnce('setBroadcast', err); }
77
+ }
78
+ announce(); // 启动即发一包,不等第一个 interval
79
+ });
80
+ socket.unref();
81
+ timer = setInterval(announce, intervalMs);
82
+ timer.unref();
83
+ return api;
84
+ }
85
+
86
+ function stop() {
87
+ stopped = true;
88
+ if (timer) { clearInterval(timer); timer = null; }
89
+ if (socket) { try { socket.close(); } catch { /* 已关 */ } socket = null; }
90
+ }
91
+
92
+ const api = { start, stop };
93
+ return api;
94
+ }
95
+
96
+ // ---- 模块面:单例接线(bin/server.js 启动处调用;测试里 stop() 收尾) ----
97
+ let active = null;
98
+ function startDiscovery(opts = {}) {
99
+ stop();
100
+ active = createDiscovery(opts).start();
101
+ return active;
102
+ }
103
+ function stop() {
104
+ if (active) { active.stop(); active = null; }
105
+ }
106
+
107
+ module.exports = { startDiscovery, stop, buildPackets, localIPv4s, DISCOVERY_PORT, INTERVAL_MS };
package/lib/guide.js ADDED
@@ -0,0 +1,100 @@
1
+ // 运行时能力面指南(MCP get_dev_guide 返回体)。
2
+ // 写给 AI agent:第一句话先纠正「网页 App」心智——工具是 JavaScriptCore 程序,
3
+ // 不是 index.html。此文件与 docs/manifest.md 同源,改一处记得同步另一处。
4
+ 'use strict';
5
+
6
+ const FENCE = '```';
7
+ const GUIDE = `# HostPad 工具开发指南
8
+
9
+ ## 一句话心智
10
+
11
+ 工具是**跑在 JavaScriptCore 的 JS 程序**,不是网页:没有 window/DOM/定时器/fetch。
12
+ manifest.entry 必须是 JS 入口文件(如 main.js,顶层执行,可选定义 main())。
13
+ UI 通过 host.ui.render() 输出 HTML 片段(白名单净化后进 WebView 壳)。
14
+
15
+ ## 不能用(会 ReferenceError 或静默失败)
16
+
17
+ - window / document / navigator 及一切 DOM API(querySelector、addEventListener…)
18
+ - getUserMedia / Canvas / Audio(无媒体面,相机类工具目前做不了)
19
+ - setTimeout / setInterval / requestAnimationFrame(一个都没有;异步只能靠
20
+ host.http/notify 的回调、host.ui.onMessage 事件、挂件 refreshAfterMinutes)
21
+ - fetch / XMLHttpRequest / WebSocket(用 host.http.request,回调式)
22
+ - localStorage / sessionStorage(用 host.kv)
23
+ - atob / btoa / TextEncoder / TextDecoder(JSCore 不带;base64 需自带实现)
24
+ - import / require / <script src>(无模块系统;多文件用 host.fs.read() 读进来自己 eval)
25
+ - HTML/CSS 文件作为 entry 或被自动加载(style.css 没人加载;
26
+ 样式写进 render 片段的 <style> 或元素 style 属性)
27
+
28
+ ## host API(v0.4)
29
+
30
+ ${FENCE}js
31
+ host.env // { pushId, toolId, version, permissions }
32
+ host.console.log/warn/error(...) // 回流 Mac 端 get_logs
33
+ host.ui.render({ title, body }) // body = HTML 片段(见下方净化规则)
34
+ host.ui.onMessage(fn) // fn({ name, values });返回值经 rpc_call 回传
35
+ host.ui.openURL(url, { inApp = true }) // 免权限;仅 http/https
36
+ host.ui.snapshot(spec) // 桌面挂件快照(entries/timer/refreshAfterMinutes)
37
+ host.kv.get/set/remove/keys/clear // 键值存储(跨启动持久)
38
+ host.fs.read/write/list/delete/exists // 工具专属文件沙盒(UTF-8 文本)
39
+ host.db.exec/query(sql, params) // SQLite(与 kv 同库)
40
+ host.http.request(url, options, cb) // 需 permissions: ["http"]
41
+ // options: { method, headers, body, timeoutMs(≤120000) }
42
+ // cb(err, res),res = { status, headers, body }
43
+ host.clipboard.setText(text) // 需 permissions: ["clipboard"](只写)
44
+ host.notify({ title, body }, cb?) // 需 permissions: ["notifications"]
45
+ host.share(text) // 系统分享面板;免权限
46
+ ${FENCE}
47
+
48
+ ## UI 片段与交互(关键差异)
49
+
50
+ render 的 body 经白名单净化:h1-h6/p/div/span/表格/a/button/img/input/select/
51
+ textarea/form/style 等 40 个标签放行;script/iframe/object/embed 连内容删除;
52
+ 一切 on* 事件属性剥除。**交互唯一通道是 data-hostpad 属性**:
53
+
54
+ ${FENCE}html
55
+ <form>
56
+ <input name="repo" value="apple/swift">
57
+ <button data-hostpad='{"name":"load"}'>加载</button>
58
+ </form>
59
+ ${FENCE}
60
+ 点击时壳自动收集作用域内(最近 form,否则整个 body)带 name 的输入值,
61
+ host.ui.onMessage 收到 { name: "load", values: { repo: "apple/swift" } }。
62
+ 注意 img 的 data: src 会被剥(base64 内联图不可用,只能 http(s) 远程图)。
63
+
64
+ ## 权限模型
65
+
66
+ manifest.permissions 声明 "逃出沙盒" 的能力:http / clipboard / notifications。
67
+ 存储(kv/fs/db)不设权限(沙盒即边界);share/openURL 用户可见、免声明。
68
+
69
+ ## 资源限制
70
+
71
+ - 入口执行与每次消息处理各有 5 秒看门狗(超时隔离)
72
+ - 内存预算约 50MB(相对工具启动时的进程增量)
73
+ - 大数据建议边算边丢;base64 大块文本尤其吃亏
74
+
75
+ ## 最小可运行模板(main.js)
76
+
77
+ ${FENCE}js
78
+ host.ui.render({
79
+ title: 'Hello',
80
+ body: '<p id="out">已运行 ' + host.env.version + '</p>' +
81
+ '<button data-hostpad="ping">点我</button>',
82
+ });
83
+ host.ui.onMessage(function (msg) {
84
+ if (msg.name === 'ping') {
85
+ host.ui.render({ title: 'Hello', body: '<p>pong</p>' });
86
+ return { ok: true };
87
+ }
88
+ });
89
+ ${FENCE}
90
+
91
+ ## 两种部署路径
92
+
93
+ 1. MCP:create_tool / update_tool(推送即热加载激活)
94
+ 2. 落盘:直接把 {manifest.json, main.js, …} 写进工具目录(list_tools 返回 toolsDir),
95
+ fs.watch 检测到变化后约 200ms 自动推送。
96
+ 不要经 FIFO 手工写 stdin 测 MCP——stdio 是标准管道,行长无限制;
97
+ FIFO 上大 payload 的「卡死」是 macOS kqueue 平台行为,不是本服务限制。
98
+ `;
99
+
100
+ module.exports = { GUIDE };
package/lib/ratelimit.js CHANGED
@@ -38,8 +38,11 @@ function createCodeStore({ ttl = 60_000, now = Date.now } = {}) {
38
38
  const codes = new Map(); // pairId -> { code, expiresAt }
39
39
  return {
40
40
  issue(pairId, fixedCode) {
41
+ // 配对挑战会周期性重发新码,顺带清掉过期条目防缓慢堆积
42
+ const nowT = now();
43
+ for (const [k, v] of codes) if (nowT > v.expiresAt) codes.delete(k);
41
44
  const code = fixedCode || String(Math.floor(100000 + Math.random() * 900000));
42
- codes.set(pairId, { code, expiresAt: now() + ttl });
45
+ codes.set(pairId, { code, expiresAt: nowT + ttl });
43
46
  return code;
44
47
  },
45
48
  verify(pairId, code) {
package/lib/tools.js CHANGED
@@ -7,12 +7,24 @@ const crypto = require('node:crypto');
7
7
 
8
8
  const ID_RE = /^[A-Za-z0-9._-]+$/;
9
9
 
10
+ // entry 是 JavaScriptCore 的 JS 入口(不是网页文件)——HTML 当 entry 是最高频接入错误,
11
+ // 在落盘前拒收并把正确心智写进错误消息
12
+ const ENTRY_JS_RE = /\.m?js$/i;
13
+
10
14
  function assertManifest(m) {
11
15
  if (!m || typeof m !== 'object') throw new Error('manifest 必须是对象');
12
16
  for (const key of ['id', 'name', 'version', 'entry']) {
13
17
  if (typeof m[key] !== 'string' || !m[key]) throw new Error(`manifest 缺字段: ${key}`);
14
18
  }
15
19
  if (!ID_RE.test(m.id)) throw new Error(`manifest.id 非法: ${m.id}`);
20
+ if (/\.(html?|css)$/i.test(m.entry) || /^index\.html$/i.test(m.entry)) {
21
+ throw new Error(
22
+ `manifest.entry 不能是 ${m.entry}:工具跑在 JavaScriptCore(无浏览器/DOM),entry 必须是 JS 入口文件(如 main.js,顶层执行,可选定义 main())。` +
23
+ 'UI 用 host.ui.render({ title, body: "HTML 片段" }) 输出,交互用 data-hostpad 属性 + host.ui.onMessage。完整指南:get_dev_guide');
24
+ }
25
+ if (!ENTRY_JS_RE.test(m.entry)) {
26
+ throw new Error(`manifest.entry 应为 JS 入口文件(如 main.js),当前: ${m.entry}`);
27
+ }
16
28
  }
17
29
 
18
30
  function assertFilePath(p) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostpad",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "HostPad 的 MCP 服务器 + iPhone 同步桥:AI agent 经 MCP 管理与调试 iOS 工具(写→推→读日志闭环)",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
package/server.js CHANGED
@@ -13,6 +13,7 @@ const fs = require('node:fs');
13
13
  const path = require('node:path');
14
14
  const readline = require('node:readline');
15
15
  const { createBridge } = require('./lib/bridge.js');
16
+ const { startDiscovery } = require('./lib/discovery.js');
16
17
 
17
18
  const argv = process.argv.slice(2);
18
19
  function arg(name, def) {
@@ -63,6 +64,8 @@ if (PAIRING && AUTO_CONFIRM_MS > 0 && FIXED_CODE) {
63
64
  }
64
65
 
65
66
  bridge.start().then(() => {
67
+ // P1:局域网自动发现广播(开发态默认明文,--pairing 即加密口径——insecure 按实际模式标注)
68
+ startDiscovery({ port: PORT, insecure: !PAIRING });
66
69
  console.log(`[agent] listening ws://0.0.0.0:${bridge.port}`);
67
70
  console.log('[agent] watching for changes…(改 tools/ 下任意文件即热推送)');
68
71
  if (SOAK_N > 0) runSoak().catch((e) => { console.log('[soak] error', e); process.exit(2); });