@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 +80 -48
- package/bin/web-provider.js +618 -0
- package/package.json +2 -1
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
|
|
8
|
-
const
|
|
9
|
-
const
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
626
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
],
|