@mobileaidev/ai-app-bridge 0.2.9 → 0.2.10

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/bin/mcp-server.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { spawn } = require('child_process');
4
- const path = require('path');
5
-
6
- const packageInfo = require('../package.json');
7
- const bridgeDir = __dirname;
8
- const cliScript = path.join(bridgeDir, 'ai-app-bridge.js');
9
- const nodeBinary = process.env.AI_APP_BRIDGE_NODE || process.execPath;
3
+ const { spawn } = require('child_process');
4
+ const path = require('path');
5
+
6
+ const packageInfo = require('../package.json');
7
+ const { WebBridgeProvider } = require('./web-provider');
8
+ const bridgeDir = __dirname;
9
+ const cliScript = path.join(bridgeDir, 'ai-app-bridge.js');
10
+ const nodeBinary = process.env.AI_APP_BRIDGE_NODE || process.execPath;
10
11
  const supportedProtocolVersions = ['2025-06-18', '2024-11-05'];
11
12
  const defaultProtocolVersion = supportedProtocolVersions[0];
12
13
  const mcpSurface = (process.env.AI_APP_BRIDGE_MCP_SURFACE || 'compact').toLowerCase();
@@ -16,6 +17,7 @@ const serverInstructions = [
16
17
  'Always pass packageName for app-specific commands, or pass an explicit port. Do not rely on a sample/default package in MCP sessions.',
17
18
  'Use freeze-app/thaw-app only as an optional stabilization control for dynamic or transient screens: thaw before reads/actions/captures, freeze after evidence capture only when it helps reasoning, and thaw before the next operation or before finishing so the app is not left frozen.',
18
19
  ].join(' ');
20
+ const webProvider = new WebBridgeProvider();
19
21
 
20
22
  let buffer = Buffer.alloc(0);
21
23
  let responseFormat = null;
@@ -498,8 +500,23 @@ const commandDefinitions = [
498
500
  { command: 'flutter-h5-scroll', domain: 'webview', summary: 'Scroll Flutter H5 content or a DOM element into view.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'deltaX', 'deltaY'] },
499
501
  { command: 'webview-pages', domain: 'webview', summary: 'List attachable Android WebView DevTools/CDP pages.', targetApp: true, options: ['serial', 'packageName', 'webviewPort', 'socketName', 'targetId', 'pageUrlFilter', 'keepForward'] },
500
502
  { command: 'webview-network', domain: 'webview', summary: 'Capture WebView Network events through CDP.', targetApp: true, options: ['serial', 'packageName', 'webviewPort', 'socketName', 'targetId', 'pageUrlFilter', 'urlFilter', 'durationMs', 'script', 'includeResponseBody', 'bodyMaxBytes', 'maxEvents'] },
501
- { command: 'webview-console', domain: 'webview', summary: 'Capture WebView console/log events through CDP.', targetApp: true, options: ['serial', 'packageName', 'webviewPort', 'socketName', 'targetId', 'pageUrlFilter', 'durationMs', 'script', 'maxEvents'] },
502
- { command: 'forward', domain: 'advanced', summary: 'Create the ADB port forward for the bridge.', targetApp: true, options: ['serial', 'packageName', 'port'] },
503
+ { command: 'webview-console', domain: 'webview', summary: 'Capture WebView console/log events through CDP.', targetApp: true, options: ['serial', 'packageName', 'webviewPort', 'socketName', 'targetId', 'pageUrlFilter', 'durationMs', 'script', 'maxEvents'] },
504
+ { command: 'web-provider-status', domain: 'web', summary: 'Read desktop Web Bridge provider status.', options: [] },
505
+ { command: 'web-session-start', domain: 'web', summary: 'Start the desktop Web Bridge WebSocket session server.', options: ['host', 'webPort', 'path', 'token'] },
506
+ { command: 'web-connect-info', domain: 'web', summary: 'Read the Web Bridge endpoint and token for SDK clients.', options: [] },
507
+ { command: 'web-sessions', domain: 'web', summary: 'List connected Web Bridge SDK sessions.', options: [] },
508
+ { command: 'web-status', domain: 'web', summary: 'Read status and capture counts for a Web Bridge session.', targetKind: 'web-target', options: ['sessionId'] },
509
+ { command: 'web-dom', domain: 'web', summary: 'Read or refresh a Web Bridge DOM snapshot.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'selector', 'refresh', 'timeoutMs'] },
510
+ { command: 'web-logs', domain: 'web', summary: 'Read Web Bridge log records.', targetKind: 'web-target', options: ['sessionId', 'sinceId', 'sinceMs', 'limit'] },
511
+ { command: 'web-network', domain: 'web', summary: 'Read Web Bridge network records.', targetKind: 'web-target', options: ['sessionId', 'sinceId', 'sinceMs', 'limit'] },
512
+ { command: 'web-state', domain: 'web', summary: 'Read Web Bridge state records.', targetKind: 'web-target', options: ['sessionId', 'sinceId', 'sinceMs', 'limit'] },
513
+ { command: 'web-events', domain: 'web', summary: 'Read Web Bridge event records.', targetKind: 'web-target', options: ['sessionId', 'sinceId', 'sinceMs', 'limit'] },
514
+ { command: 'web-command', domain: 'web', summary: 'Run a whitelisted command in a connected Web Bridge SDK session.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'name', 'arguments', 'timeoutMs'] },
515
+ { command: 'web-click', domain: 'web', summary: 'Click a DOM element through the Web Bridge SDK command path.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'selector', 'targetText', 'timeoutMs'] },
516
+ { command: 'web-input', domain: 'web', summary: 'Set text in a DOM input through the Web Bridge SDK command path.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'selector', 'value', 'timeoutMs'] },
517
+ { command: 'web-wait', domain: 'web', summary: 'Wait for text or selector through the Web Bridge SDK command path.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'selector', 'targetText', 'timeoutMs'] },
518
+ { command: 'web-scroll', domain: 'web', summary: 'Scroll a Web Bridge DOM target.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'selector', 'deltaX', 'deltaY', 'timeoutMs'] },
519
+ { command: 'forward', domain: 'advanced', summary: 'Create the ADB port forward for the bridge.', targetApp: true, options: ['serial', 'packageName', 'port'] },
503
520
  { command: 'remove-forward', domain: 'advanced', summary: 'Remove the ADB port forward for the bridge.', options: ['serial', 'port'] },
504
521
  { command: 'batch', domain: 'advanced', summary: 'Run multiple AI App Bridge commands serially in one MCP call.', options: ['defaults', 'steps', 'stopOnError', 'includeRaw', 'maxRawChars'] },
505
522
  { command: 'smoke', domain: 'diagnostics', summary: 'Run the native sample smoke test.', options: ['serial', 'packageName', 'outFile', 'artifactDir', 'skipFlutterLaunch'] },
@@ -589,14 +606,15 @@ function capabilityPayload(args = {}) {
589
606
  };
590
607
  }
591
608
 
592
- function shapeCommandDefinition(definition, includeOptions) {
593
- return {
594
- command: definition.command,
595
- summary: definition.summary,
596
- targetApp: Boolean(definition.targetApp),
597
- ...(includeOptions ? { options: definition.options || [] } : {}),
598
- };
599
- }
609
+ function shapeCommandDefinition(definition, includeOptions) {
610
+ return {
611
+ command: definition.command,
612
+ summary: definition.summary,
613
+ targetApp: Boolean(definition.targetApp),
614
+ targetKind: definition.targetKind || (definition.targetApp ? 'android-app' : 'none'),
615
+ ...(includeOptions ? { options: definition.options || [] } : {}),
616
+ };
617
+ }
600
618
 
601
619
  async function runGeneric(args = {}) {
602
620
  const command = normalizeCommandName(args.command);
@@ -606,10 +624,10 @@ async function runGeneric(args = {}) {
606
624
  const commandArgs = {
607
625
  ...(args.arguments && typeof args.arguments === 'object' ? args.arguments : {}),
608
626
  };
609
- for (const key of ['adb', 'serial', 'port', 'packageName']) {
610
- if (args[key] !== undefined && commandArgs[key] === undefined) {
611
- commandArgs[key] = args[key];
612
- }
627
+ for (const key of ['adb', 'serial', 'port', 'packageName', 'sessionId', 'targetId', 'webPort']) {
628
+ if (args[key] !== undefined && commandArgs[key] === undefined) {
629
+ commandArgs[key] = args[key];
630
+ }
613
631
  }
614
632
  if (command === 'batch') {
615
633
  return runBatch(commandArgs);
@@ -621,19 +639,27 @@ function normalizeCommandName(value) {
621
639
  return String(value || '').trim().replace(/_/g, '-');
622
640
  }
623
641
 
624
- function runBridgeChecked(command, args = {}) {
625
- if (command === 'clear-app-data' && !args.packageName) {
626
- return toolText('clear-app-data: packageName is required in MCP mode so the command cannot clear a default package.', true);
627
- }
642
+ function runBridgeChecked(command, args = {}) {
643
+ const definition = commandByName.get(command);
644
+ if (definition?.domain === 'web') {
645
+ return runWebChecked(command, args);
646
+ }
647
+ if (command === 'clear-app-data' && !args.packageName) {
648
+ return toolText('clear-app-data: packageName is required in MCP mode so the command cannot clear a default package.', true);
649
+ }
628
650
  if ((command === 'freeze-app' || command === 'thaw-app') && !args.packageName) {
629
651
  return toolText(`${command}: packageName is required in MCP mode so the command cannot signal a default package.`, true);
630
652
  }
631
- const definition = commandByName.get(command);
632
- if (definition?.targetApp && !args.packageName && !args.port) {
633
- return toolText(`${command}: packageName or explicit port is required in MCP mode so the command cannot fall back to a default package.`, true);
634
- }
635
- return runBridge(command, args);
636
- }
653
+ if (definition?.targetApp && !args.packageName && !args.port) {
654
+ return toolText(`${command}: packageName or explicit port is required in MCP mode so the command cannot fall back to a default package.`, true);
655
+ }
656
+ return runBridge(command, args);
657
+ }
658
+
659
+ async function runWebChecked(command, args = {}) {
660
+ const result = await webProvider.run(command, args);
661
+ return toolJson(result, result?.ok === false);
662
+ }
637
663
 
638
664
  async function runBatch(args = {}, runner = runBridgeChecked) {
639
665
  const startedAtMs = Date.now();
@@ -654,10 +680,10 @@ async function runBatch(args = {}, runner = runBridgeChecked) {
654
680
  }
655
681
 
656
682
  const defaults = args.defaults && typeof args.defaults === 'object' ? { ...args.defaults } : {};
657
- for (const key of ['adb', 'serial', 'port', 'packageName', 'artifactDir']) {
658
- if (args[key] !== undefined && defaults[key] === undefined) {
659
- defaults[key] = args[key];
660
- }
683
+ for (const key of ['adb', 'serial', 'port', 'packageName', 'artifactDir', 'sessionId', 'targetId', 'webPort']) {
684
+ if (args[key] !== undefined && defaults[key] === undefined) {
685
+ defaults[key] = args[key];
686
+ }
661
687
  }
662
688
 
663
689
  const normalizedSteps = [];
@@ -706,10 +732,10 @@ async function runBatch(args = {}, runner = runBridgeChecked) {
706
732
  ...defaults,
707
733
  ...(step.arguments && typeof step.arguments === 'object' ? step.arguments : {}),
708
734
  };
709
- for (const key of ['adb', 'serial', 'port', 'packageName']) {
710
- if (step[key] !== undefined) {
711
- stepArgs[key] = step[key];
712
- }
735
+ for (const key of ['adb', 'serial', 'port', 'packageName', 'sessionId', 'targetId', 'webPort']) {
736
+ if (step[key] !== undefined) {
737
+ stepArgs[key] = step[key];
738
+ }
713
739
  }
714
740
  try {
715
741
  const toolResult = await runner(step.command, stepArgs);
@@ -717,11 +743,13 @@ async function runBatch(args = {}, runner = runBridgeChecked) {
717
743
  const passed = !parsed.isError && parsed.payload?.ok !== false;
718
744
  const stepResult = {
719
745
  id: step.id,
720
- command: step.command,
721
- status: passed ? 'passed' : 'failed',
722
- ok: passed,
723
- packageName: stepArgs.packageName,
724
- port: stepArgs.port,
746
+ command: step.command,
747
+ status: passed ? 'passed' : 'failed',
748
+ ok: passed,
749
+ packageName: stepArgs.packageName,
750
+ sessionId: stepArgs.sessionId,
751
+ targetId: stepArgs.targetId,
752
+ port: stepArgs.port,
725
753
  durationMs: Date.now() - stepStartedAtMs,
726
754
  summary: summarizeToolPayload(parsed),
727
755
  };
@@ -801,8 +829,11 @@ function summarizeToolPayload(parsed) {
801
829
  ok: payload.ok,
802
830
  error: payload.error || null,
803
831
  };
804
- if (payload.packageName) summary.packageName = payload.packageName;
805
- if (payload.app?.packageName) summary.app = payload.app.packageName;
832
+ if (payload.packageName) summary.packageName = payload.packageName;
833
+ if (payload.sessionId) summary.sessionId = payload.sessionId;
834
+ if (payload.targetId) summary.targetId = payload.targetId;
835
+ if (payload.session?.sessionId) summary.sessionId = payload.session.sessionId;
836
+ if (payload.app?.packageName) summary.app = payload.app.packageName;
806
837
  if (payload.activity) summary.activity = payload.activity;
807
838
  if (payload.component) summary.component = payload.component;
808
839
  if (payload.transport) summary.transport = payload.transport;
@@ -814,8 +845,9 @@ function summarizeToolPayload(parsed) {
814
845
  port: payload.debugBridge.port,
815
846
  };
816
847
  }
817
- if (payload.count !== undefined) summary.count = payload.count;
818
- if (payload.nodeCount !== undefined) summary.nodeCount = payload.nodeCount;
848
+ if (payload.count !== undefined) summary.count = payload.count;
849
+ if (payload.sessionCount !== undefined) summary.sessionCount = payload.sessionCount;
850
+ if (payload.nodeCount !== undefined) summary.nodeCount = payload.nodeCount;
819
851
  if (Array.isArray(payload.items)) summary.items = payload.items.length;
820
852
  if (payload.values && typeof payload.values === 'object') {
821
853
  summary.values = Object.keys(payload.values).length;
@@ -0,0 +1,618 @@
1
+ const crypto = require('crypto');
2
+ const http = require('http');
3
+ const { URL } = require('url');
4
+ const { WebSocketServer } = require('ws');
5
+
6
+ const defaultHost = '127.0.0.1';
7
+ const defaultPort = 18180;
8
+ const defaultPath = '/ai-app-bridge-web';
9
+ const maxCaptureEntries = 500;
10
+ const defaultCommandTimeoutMs = 5000;
11
+
12
+ class WebBridgeProvider {
13
+ constructor() {
14
+ this.server = null;
15
+ this.wss = null;
16
+ this.host = defaultHost;
17
+ this.port = defaultPort;
18
+ this.path = defaultPath;
19
+ this.token = '';
20
+ this.sessions = new Map();
21
+ this.pendingCommands = new Map();
22
+ this.captureSequence = 0;
23
+ }
24
+
25
+ async run(command, args = {}) {
26
+ switch (command) {
27
+ case 'web-provider-status':
28
+ return this.providerStatus();
29
+ case 'web-session-start':
30
+ return this.start(args);
31
+ case 'web-connect-info':
32
+ return this.connectInfo();
33
+ case 'web-sessions':
34
+ return this.listSessions();
35
+ case 'web-status':
36
+ return this.sessionStatus(args);
37
+ case 'web-dom':
38
+ return this.dom(args);
39
+ case 'web-logs':
40
+ return this.captureResponse(args, 'logs');
41
+ case 'web-network':
42
+ return this.captureResponse(args, 'network');
43
+ case 'web-state':
44
+ return this.stateResponse(args);
45
+ case 'web-events':
46
+ return this.captureResponse(args, 'events');
47
+ case 'web-command':
48
+ return this.command(args);
49
+ case 'web-click':
50
+ return this.command({ ...args, name: 'click' });
51
+ case 'web-input':
52
+ return this.command({ ...args, name: 'input' });
53
+ case 'web-wait':
54
+ return this.command({ ...args, name: 'waitFor' });
55
+ case 'web-scroll':
56
+ return this.command({ ...args, name: 'scroll' });
57
+ default:
58
+ return { ok: false, error: 'unknown_web_command', command };
59
+ }
60
+ }
61
+
62
+ async start(args = {}) {
63
+ if (this.server) {
64
+ return { ok: true, alreadyRunning: true, ...this.connectInfo() };
65
+ }
66
+
67
+ this.host = stringArg(args.host, defaultHost);
68
+ this.port = numberArg(args.webPort, numberArg(args.port, defaultPort));
69
+ this.path = stringArg(args.path, defaultPath);
70
+ this.token = stringArg(args.token, randomToken());
71
+
72
+ this.server = http.createServer((request, response) => this.handleHttp(request, response));
73
+ this.wss = new WebSocketServer({ server: this.server });
74
+ this.wss.on('connection', (socket, request) => this.handleConnection(socket, request));
75
+
76
+ await new Promise((resolve, reject) => {
77
+ const onError = (error) => {
78
+ this.server = null;
79
+ this.wss = null;
80
+ reject(error);
81
+ };
82
+ this.server.once('error', onError);
83
+ this.server.listen(this.port, this.host, () => {
84
+ this.server.off('error', onError);
85
+ const address = this.server.address();
86
+ this.port = typeof address === 'object' && address ? address.port : this.port;
87
+ resolve();
88
+ });
89
+ });
90
+
91
+ return { ok: true, started: true, ...this.connectInfo() };
92
+ }
93
+
94
+ providerStatus() {
95
+ return {
96
+ ok: true,
97
+ running: Boolean(this.server),
98
+ host: this.host,
99
+ port: this.server ? this.port : null,
100
+ path: this.path,
101
+ sessionCount: this.sessions.size,
102
+ updatedAtMs: Date.now(),
103
+ };
104
+ }
105
+
106
+ connectInfo() {
107
+ if (!this.server) {
108
+ return {
109
+ ok: false,
110
+ running: false,
111
+ error: 'web_provider_not_running',
112
+ suggestion: 'Run web-session-start first.',
113
+ };
114
+ }
115
+ const endpoint = `ws://${this.host}:${this.port}${this.path}`;
116
+ return {
117
+ ok: true,
118
+ running: true,
119
+ endpoint,
120
+ httpEndpoint: `http://${this.host}:${this.port}`,
121
+ token: this.token,
122
+ sessionCount: this.sessions.size,
123
+ updatedAtMs: Date.now(),
124
+ };
125
+ }
126
+
127
+ listSessions() {
128
+ return {
129
+ ok: true,
130
+ running: Boolean(this.server),
131
+ sessions: [...this.sessions.values()].map((session) => this.sessionSummary(session)),
132
+ count: this.sessions.size,
133
+ updatedAtMs: Date.now(),
134
+ };
135
+ }
136
+
137
+ sessionStatus(args = {}) {
138
+ const session = this.requireSession(args);
139
+ if (!session.ok) return session;
140
+ return {
141
+ ok: true,
142
+ session: this.sessionSummary(session.value),
143
+ capture: captureCounts(session.value),
144
+ updatedAtMs: Date.now(),
145
+ };
146
+ }
147
+
148
+ async dom(args = {}) {
149
+ const sessionResult = this.requireSession(args);
150
+ if (!sessionResult.ok) return sessionResult;
151
+ const session = sessionResult.value;
152
+ const targetId = stringArg(args.targetId, 'main');
153
+ const refresh = args.refresh !== false;
154
+ if (refresh && session.socket?.readyState === 1) {
155
+ try {
156
+ const result = await this.sendCommand(session, {
157
+ name: 'domSnapshot',
158
+ targetId,
159
+ args: {
160
+ selector: args.selector,
161
+ maxControls: args.maxControls,
162
+ },
163
+ }, numberArg(args.timeoutMs, defaultCommandTimeoutMs));
164
+ const dom = result.dom || result.value || result;
165
+ if (dom && typeof dom === 'object') {
166
+ session.domByTarget.set(targetId, { ...dom, targetId, updatedAtMs: Date.now() });
167
+ }
168
+ } catch (error) {
169
+ if (!session.domByTarget.has(targetId)) {
170
+ return { ok: false, error: 'web_dom_unavailable', message: error.message };
171
+ }
172
+ }
173
+ }
174
+ const dom = session.domByTarget.get(targetId);
175
+ if (!dom) {
176
+ return { ok: false, error: 'web_dom_absent', sessionId: session.sessionId, targetId };
177
+ }
178
+ return {
179
+ ok: true,
180
+ sessionId: session.sessionId,
181
+ targetId,
182
+ dom,
183
+ updatedAtMs: Date.now(),
184
+ };
185
+ }
186
+
187
+ captureResponse(args = {}, stream) {
188
+ const sessionResult = this.requireSession(args);
189
+ if (!sessionResult.ok) return sessionResult;
190
+ const session = sessionResult.value;
191
+ const filter = captureFilter(args);
192
+ const source = session.captures[stream] || [];
193
+ const filtered = source.filter((item) => captureMatches(item, filter));
194
+ const items = filtered.length > filter.limit ? filtered.slice(-filter.limit) : filtered;
195
+ return {
196
+ ok: true,
197
+ type: stream,
198
+ sessionId: session.sessionId,
199
+ items: items.map((item) => ({ ...item })),
200
+ count: items.length,
201
+ sinceId: filter.sinceId ?? null,
202
+ sinceMs: filter.sinceMs ?? null,
203
+ limit: filter.limit,
204
+ updatedAtMs: Date.now(),
205
+ };
206
+ }
207
+
208
+ stateResponse(args = {}) {
209
+ const sessionResult = this.requireSession(args);
210
+ if (!sessionResult.ok) return sessionResult;
211
+ const session = sessionResult.value;
212
+ const filter = captureFilter(args);
213
+ const values = {};
214
+ const items = [];
215
+ for (const [key, item] of session.stateEntries.entries()) {
216
+ if (!captureMatches(item, filter)) continue;
217
+ values[key] = item.value;
218
+ items.push({ ...item });
219
+ }
220
+ const limited = items.length > filter.limit ? items.slice(-filter.limit) : items;
221
+ return {
222
+ ok: true,
223
+ type: 'state',
224
+ sessionId: session.sessionId,
225
+ values,
226
+ items: limited,
227
+ count: limited.length,
228
+ sinceId: filter.sinceId ?? null,
229
+ sinceMs: filter.sinceMs ?? null,
230
+ limit: filter.limit,
231
+ updatedAtMs: Date.now(),
232
+ };
233
+ }
234
+
235
+ async command(args = {}) {
236
+ const sessionResult = this.requireSession(args);
237
+ if (!sessionResult.ok) return sessionResult;
238
+ const session = sessionResult.value;
239
+ const name = stringArg(args.name || args.action || args.webCommand, '');
240
+ if (!name) {
241
+ return { ok: false, error: 'web_command_name_required' };
242
+ }
243
+ try {
244
+ const result = await this.sendCommand(session, {
245
+ name,
246
+ targetId: stringArg(args.targetId, 'main'),
247
+ args: args.arguments && typeof args.arguments === 'object' ? args.arguments : commandArgs(args),
248
+ }, numberArg(args.timeoutMs, defaultCommandTimeoutMs));
249
+ return {
250
+ ok: result.ok !== false,
251
+ sessionId: session.sessionId,
252
+ targetId: stringArg(args.targetId, 'main'),
253
+ command: name,
254
+ result,
255
+ updatedAtMs: Date.now(),
256
+ };
257
+ } catch (error) {
258
+ return {
259
+ ok: false,
260
+ error: 'web_command_failed',
261
+ message: error.message,
262
+ sessionId: session.sessionId,
263
+ command: name,
264
+ };
265
+ }
266
+ }
267
+
268
+ sendCommand(session, command, timeoutMs) {
269
+ if (!session.socket || session.socket.readyState !== 1) {
270
+ return Promise.reject(new Error('web_session_not_connected'));
271
+ }
272
+ const commandId = `web-command-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
273
+ const payload = {
274
+ type: 'command',
275
+ commandId,
276
+ command,
277
+ sentAtMs: Date.now(),
278
+ };
279
+ return new Promise((resolve, reject) => {
280
+ const timer = setTimeout(() => {
281
+ this.pendingCommands.delete(commandId);
282
+ reject(new Error('web_command_timeout'));
283
+ }, timeoutMs);
284
+ this.pendingCommands.set(commandId, {
285
+ sessionId: session.sessionId,
286
+ resolve: (value) => {
287
+ clearTimeout(timer);
288
+ resolve(value);
289
+ },
290
+ reject: (error) => {
291
+ clearTimeout(timer);
292
+ reject(error);
293
+ },
294
+ });
295
+ session.socket.send(JSON.stringify(payload), (error) => {
296
+ if (!error) return;
297
+ const pending = this.pendingCommands.get(commandId);
298
+ this.pendingCommands.delete(commandId);
299
+ if (pending) {
300
+ clearTimeout(timer);
301
+ reject(error);
302
+ }
303
+ });
304
+ });
305
+ }
306
+
307
+ handleHttp(request, response) {
308
+ setCors(response);
309
+ if (request.method === 'OPTIONS') {
310
+ response.writeHead(204);
311
+ response.end();
312
+ return;
313
+ }
314
+ if (request.method === 'GET' && request.url?.startsWith('/health')) {
315
+ writeJson(response, this.providerStatus());
316
+ return;
317
+ }
318
+ if (request.method === 'GET' && request.url?.startsWith('/sessions')) {
319
+ writeJson(response, this.listSessions());
320
+ return;
321
+ }
322
+ writeJson(response, { ok: false, error: 'not_found' }, 404);
323
+ }
324
+
325
+ handleConnection(socket, request) {
326
+ const parsed = new URL(request.url || '/', `http://${this.host}:${this.port}`);
327
+ if (parsed.pathname !== this.path) {
328
+ socket.close(1008, 'invalid_path');
329
+ return;
330
+ }
331
+ if (this.token && parsed.searchParams.get('token') !== this.token) {
332
+ socket.close(1008, 'invalid_token');
333
+ return;
334
+ }
335
+
336
+ socket.on('message', (raw) => this.handleSocketMessage(socket, raw));
337
+ socket.on('close', () => this.markSocketClosed(socket));
338
+ socket.on('error', () => this.markSocketClosed(socket));
339
+ }
340
+
341
+ handleSocketMessage(socket, raw) {
342
+ let message;
343
+ try {
344
+ message = JSON.parse(raw.toString());
345
+ } catch (_) {
346
+ socket.send(JSON.stringify({ type: 'error', error: 'invalid_json' }));
347
+ return;
348
+ }
349
+ switch (message.type) {
350
+ case 'hello':
351
+ this.registerSession(socket, message);
352
+ break;
353
+ case 'capture':
354
+ this.recordCapture(socket, message);
355
+ break;
356
+ case 'dom':
357
+ this.recordDom(socket, message);
358
+ break;
359
+ case 'commandResult':
360
+ this.resolveCommand(socket, message);
361
+ break;
362
+ case 'heartbeat':
363
+ this.touchSession(socket);
364
+ break;
365
+ default:
366
+ socket.send(JSON.stringify({ type: 'error', error: 'unknown_message_type', messageType: message.type || '' }));
367
+ break;
368
+ }
369
+ }
370
+
371
+ registerSession(socket, message) {
372
+ const sessionId = stringArg(message.sessionId, `web-session-${randomToken(6)}`);
373
+ const targetId = stringArg(message.targetId, 'main');
374
+ const existing = this.sessions.get(sessionId);
375
+ const session = existing || createSession(sessionId);
376
+ session.socket = socket;
377
+ session.connected = true;
378
+ session.appName = stringArg(message.appName, session.appName || '');
379
+ session.url = stringArg(message.url, session.url || '');
380
+ session.origin = stringArg(message.origin, session.origin || '');
381
+ session.route = stringArg(message.route, session.route || '');
382
+ session.capabilities = message.capabilities && typeof message.capabilities === 'object' ? message.capabilities : session.capabilities;
383
+ session.targets.set(targetId, {
384
+ targetId,
385
+ url: session.url,
386
+ origin: session.origin,
387
+ updatedAtMs: Date.now(),
388
+ });
389
+ session.updatedAtMs = Date.now();
390
+ socket.aiAppBridgeSessionId = sessionId;
391
+ this.sessions.set(sessionId, session);
392
+ socket.send(JSON.stringify({
393
+ type: 'helloAck',
394
+ ok: true,
395
+ sessionId,
396
+ targetId,
397
+ serverTimeMs: Date.now(),
398
+ }));
399
+ }
400
+
401
+ recordCapture(socket, message) {
402
+ const session = this.sessionForSocket(socket);
403
+ if (!session) return;
404
+ const stream = normalizeStream(message.stream || message.typeName);
405
+ if (!stream) return;
406
+ const rawItem = message.item || message.record || {};
407
+ const item = this.baseRecord(stream, session, rawItem);
408
+ if (stream === 'state') {
409
+ const namespace = stringArg(item.namespace, 'app');
410
+ const key = stringArg(item.key, 'value');
411
+ item.namespace = namespace;
412
+ item.key = key;
413
+ session.stateEntries.set(`${namespace}.${key}`, item);
414
+ } else {
415
+ boundedPush(session.captures[stream], item, maxCaptureEntries);
416
+ }
417
+ session.updatedAtMs = Date.now();
418
+ }
419
+
420
+ recordDom(socket, message) {
421
+ const session = this.sessionForSocket(socket);
422
+ if (!session) return;
423
+ const targetId = stringArg(message.targetId, 'main');
424
+ const dom = message.dom && typeof message.dom === 'object' ? message.dom : message;
425
+ session.domByTarget.set(targetId, {
426
+ ...dom,
427
+ targetId,
428
+ sessionId: session.sessionId,
429
+ updatedAtMs: Date.now(),
430
+ });
431
+ session.updatedAtMs = Date.now();
432
+ }
433
+
434
+ resolveCommand(socket, message) {
435
+ const pending = this.pendingCommands.get(message.commandId);
436
+ if (!pending) return;
437
+ const session = this.sessionForSocket(socket);
438
+ if (!session || session.sessionId !== pending.sessionId) {
439
+ pending.reject(new Error('web_command_session_mismatch'));
440
+ this.pendingCommands.delete(message.commandId);
441
+ return;
442
+ }
443
+ this.pendingCommands.delete(message.commandId);
444
+ if (message.ok === false) {
445
+ pending.resolve({
446
+ ok: false,
447
+ error: message.error || 'web_command_error',
448
+ value: message.value,
449
+ });
450
+ return;
451
+ }
452
+ pending.resolve(message.result !== undefined ? message.result : { ok: true, value: message.value });
453
+ }
454
+
455
+ baseRecord(stream, session, rawItem) {
456
+ const type = stream === 'logs' ? 'log' : stream === 'events' ? 'event' : stream;
457
+ return {
458
+ id: ++this.captureSequence,
459
+ type,
460
+ source: stringArg(rawItem.source, 'web-sdk'),
461
+ timestampMs: numberArg(rawItem.timestampMs, Date.now()),
462
+ sessionId: session.sessionId,
463
+ targetId: stringArg(rawItem.targetId, 'main'),
464
+ ...rawItem,
465
+ };
466
+ }
467
+
468
+ requireSession(args = {}) {
469
+ if (!this.server) {
470
+ return { ok: false, error: 'web_provider_not_running', suggestion: 'Run web-session-start first.' };
471
+ }
472
+ const sessionId = stringArg(args.sessionId, '');
473
+ if (sessionId) {
474
+ const session = this.sessions.get(sessionId);
475
+ if (!session) return { ok: false, error: 'web_session_not_found', sessionId };
476
+ return { ok: true, value: session };
477
+ }
478
+ const live = [...this.sessions.values()].filter((session) => session.connected);
479
+ if (live.length === 1) return { ok: true, value: live[0] };
480
+ return {
481
+ ok: false,
482
+ error: live.length === 0 ? 'web_session_required' : 'web_session_ambiguous',
483
+ sessions: live.map((session) => this.sessionSummary(session)),
484
+ };
485
+ }
486
+
487
+ sessionForSocket(socket) {
488
+ const sessionId = socket.aiAppBridgeSessionId;
489
+ return sessionId ? this.sessions.get(sessionId) : null;
490
+ }
491
+
492
+ touchSession(socket) {
493
+ const session = this.sessionForSocket(socket);
494
+ if (session) session.updatedAtMs = Date.now();
495
+ }
496
+
497
+ markSocketClosed(socket) {
498
+ const session = this.sessionForSocket(socket);
499
+ if (!session || session.socket !== socket) return;
500
+ session.connected = false;
501
+ session.socket = null;
502
+ session.updatedAtMs = Date.now();
503
+ }
504
+
505
+ sessionSummary(session) {
506
+ return {
507
+ sessionId: session.sessionId,
508
+ appName: session.appName,
509
+ connected: session.connected,
510
+ url: session.url,
511
+ origin: session.origin,
512
+ route: session.route,
513
+ capabilities: session.capabilities,
514
+ targets: [...session.targets.values()],
515
+ capture: captureCounts(session),
516
+ connectedAtMs: session.connectedAtMs,
517
+ updatedAtMs: session.updatedAtMs,
518
+ };
519
+ }
520
+ }
521
+
522
+ function createSession(sessionId) {
523
+ return {
524
+ sessionId,
525
+ appName: '',
526
+ connected: false,
527
+ socket: null,
528
+ url: '',
529
+ origin: '',
530
+ route: '',
531
+ capabilities: {},
532
+ targets: new Map(),
533
+ captures: {
534
+ logs: [],
535
+ network: [],
536
+ events: [],
537
+ },
538
+ stateEntries: new Map(),
539
+ domByTarget: new Map(),
540
+ connectedAtMs: Date.now(),
541
+ updatedAtMs: Date.now(),
542
+ };
543
+ }
544
+
545
+ function commandArgs(args) {
546
+ const omitted = new Set(['sessionId', 'targetId', 'name', 'action', 'webCommand', 'timeoutMs']);
547
+ const result = {};
548
+ for (const [key, value] of Object.entries(args)) {
549
+ if (!omitted.has(key)) result[key] = value;
550
+ }
551
+ return result;
552
+ }
553
+
554
+ function captureCounts(session) {
555
+ return {
556
+ logs: session.captures.logs.length,
557
+ network: session.captures.network.length,
558
+ state: session.stateEntries.size,
559
+ events: session.captures.events.length,
560
+ dom: session.domByTarget.size,
561
+ };
562
+ }
563
+
564
+ function captureFilter(args = {}) {
565
+ return {
566
+ sinceId: args.sinceId === undefined ? null : Number(args.sinceId),
567
+ sinceMs: args.sinceMs === undefined ? null : Number(args.sinceMs),
568
+ limit: Number.isInteger(Number(args.limit)) ? Math.max(1, Math.min(Number(args.limit), maxCaptureEntries)) : 200,
569
+ };
570
+ }
571
+
572
+ function captureMatches(item, filter) {
573
+ if (filter.sinceId !== null && Number(item.id) <= filter.sinceId) return false;
574
+ if (filter.sinceMs !== null && Number(item.timestampMs) < filter.sinceMs) return false;
575
+ return true;
576
+ }
577
+
578
+ function normalizeStream(value) {
579
+ const stream = String(value || '').trim();
580
+ if (stream === 'log') return 'logs';
581
+ if (stream === 'event') return 'events';
582
+ if (stream === 'logs' || stream === 'network' || stream === 'state' || stream === 'events') return stream;
583
+ return '';
584
+ }
585
+
586
+ function boundedPush(target, item, maxSize) {
587
+ while (target.length >= maxSize) target.shift();
588
+ target.push(item);
589
+ }
590
+
591
+ function setCors(response) {
592
+ response.setHeader('Access-Control-Allow-Origin', '*');
593
+ response.setHeader('Access-Control-Allow-Headers', 'content-type,x-ai-app-bridge-token');
594
+ response.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
595
+ }
596
+
597
+ function writeJson(response, payload, statusCode = 200) {
598
+ response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
599
+ response.end(JSON.stringify(payload));
600
+ }
601
+
602
+ function randomToken(bytes = 18) {
603
+ return crypto.randomBytes(bytes).toString('base64url');
604
+ }
605
+
606
+ function stringArg(value, fallback) {
607
+ const text = value === undefined || value === null ? '' : String(value);
608
+ return text.trim() ? text : fallback;
609
+ }
610
+
611
+ function numberArg(value, fallback) {
612
+ const number = Number(value);
613
+ return Number.isFinite(number) ? number : fallback;
614
+ }
615
+
616
+ module.exports = {
617
+ WebBridgeProvider,
618
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobileaidev/ai-app-bridge",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Desktop CLI and MCP server for AI App Bridge.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "bin/ai-app-bridge.js",
20
20
  "bin/mcp-server.js",
21
+ "bin/web-provider.js",
21
22
  "skills/ai-app-bridge-use",
22
23
  "README.md"
23
24
  ],