@mcp-b/chrome-devtools-mcp 0.12.0-beta.0
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/LICENSE +202 -0
- package/README.md +554 -0
- package/build/src/DevToolsConnectionAdapter.js +69 -0
- package/build/src/DevtoolsUtils.js +206 -0
- package/build/src/McpContext.js +499 -0
- package/build/src/McpResponse.js +396 -0
- package/build/src/Mutex.js +37 -0
- package/build/src/PageCollector.js +283 -0
- package/build/src/WaitForHelper.js +139 -0
- package/build/src/browser.js +134 -0
- package/build/src/cli.js +213 -0
- package/build/src/formatters/consoleFormatter.js +121 -0
- package/build/src/formatters/networkFormatter.js +77 -0
- package/build/src/formatters/snapshotFormatter.js +73 -0
- package/build/src/index.js +21 -0
- package/build/src/issue-descriptions.js +39 -0
- package/build/src/logger.js +27 -0
- package/build/src/main.js +130 -0
- package/build/src/polyfill.js +7 -0
- package/build/src/third_party/index.js +16 -0
- package/build/src/tools/ToolDefinition.js +20 -0
- package/build/src/tools/categories.js +24 -0
- package/build/src/tools/console.js +85 -0
- package/build/src/tools/emulation.js +87 -0
- package/build/src/tools/input.js +268 -0
- package/build/src/tools/network.js +106 -0
- package/build/src/tools/pages.js +237 -0
- package/build/src/tools/performance.js +147 -0
- package/build/src/tools/screenshot.js +84 -0
- package/build/src/tools/script.js +71 -0
- package/build/src/tools/snapshot.js +52 -0
- package/build/src/tools/tools.js +31 -0
- package/build/src/tools/webmcp.js +233 -0
- package/build/src/trace-processing/parse.js +84 -0
- package/build/src/transports/WebMCPBridgeScript.js +196 -0
- package/build/src/transports/WebMCPClientTransport.js +276 -0
- package/build/src/transports/index.js +7 -0
- package/build/src/utils/keyboard.js +296 -0
- package/build/src/utils/pagination.js +49 -0
- package/build/src/utils/types.js +6 -0
- package/package.json +87 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { WEB_MCP_BRIDGE_SCRIPT, CHECK_WEBMCP_AVAILABLE_SCRIPT } from './WebMCPBridgeScript.js';
|
|
7
|
+
/**
|
|
8
|
+
* MCP Client Transport that connects to a WebMCP server running in a browser tab.
|
|
9
|
+
*
|
|
10
|
+
* This transport uses Chrome DevTools Protocol (CDP) to inject a bridge script
|
|
11
|
+
* into the page, which then ferries MCP messages between this transport and
|
|
12
|
+
* the page's TabServerTransport via window.postMessage.
|
|
13
|
+
*
|
|
14
|
+
* Architecture:
|
|
15
|
+
* ```
|
|
16
|
+
* WebMCPClientTransport (Node.js)
|
|
17
|
+
* │
|
|
18
|
+
* │ CDP (Runtime.evaluate / Runtime.bindingCalled)
|
|
19
|
+
* ▼
|
|
20
|
+
* Bridge Script (injected into page)
|
|
21
|
+
* │
|
|
22
|
+
* │ window.postMessage
|
|
23
|
+
* ▼
|
|
24
|
+
* TabServerTransport (@mcp-b/global)
|
|
25
|
+
* │
|
|
26
|
+
* ▼
|
|
27
|
+
* MCP Server (tools, resources, prompts)
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
33
|
+
* import { WebMCPClientTransport } from './transports/WebMCPClientTransport.js';
|
|
34
|
+
*
|
|
35
|
+
* const transport = new WebMCPClientTransport({ page });
|
|
36
|
+
* const client = new Client({ name: 'my-client', version: '1.0.0' });
|
|
37
|
+
*
|
|
38
|
+
* await client.connect(transport);
|
|
39
|
+
* const { tools } = await client.listTools();
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export class WebMCPClientTransport {
|
|
43
|
+
_page;
|
|
44
|
+
_cdpSession = null;
|
|
45
|
+
_started = false;
|
|
46
|
+
_closed = false;
|
|
47
|
+
_readyTimeout;
|
|
48
|
+
_requireWebMCP;
|
|
49
|
+
_serverReady = false;
|
|
50
|
+
_serverReadyPromise;
|
|
51
|
+
_serverReadyResolve;
|
|
52
|
+
_serverReadyReject;
|
|
53
|
+
// Transport callbacks
|
|
54
|
+
onclose;
|
|
55
|
+
onerror;
|
|
56
|
+
onmessage;
|
|
57
|
+
constructor(options) {
|
|
58
|
+
this._page = options.page;
|
|
59
|
+
this._readyTimeout = options.readyTimeout ?? 10000;
|
|
60
|
+
this._requireWebMCP = options.requireWebMCP ?? true;
|
|
61
|
+
// Set up server ready promise
|
|
62
|
+
this._serverReadyPromise = new Promise((resolve, reject) => {
|
|
63
|
+
this._serverReadyResolve = resolve;
|
|
64
|
+
this._serverReadyReject = reject;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Check if WebMCP is available on the page
|
|
69
|
+
*/
|
|
70
|
+
async checkWebMCPAvailable() {
|
|
71
|
+
try {
|
|
72
|
+
const result = await this._page.evaluate(CHECK_WEBMCP_AVAILABLE_SCRIPT);
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return { available: false };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Start the transport and establish connection to the WebMCP server.
|
|
81
|
+
*
|
|
82
|
+
* This method:
|
|
83
|
+
* 1. Creates a CDP session
|
|
84
|
+
* 2. Sets up the binding for receiving messages from the page
|
|
85
|
+
* 3. Injects the bridge script
|
|
86
|
+
* 4. Initiates the server-ready handshake
|
|
87
|
+
* 5. Waits for the server to be ready (with timeout)
|
|
88
|
+
*/
|
|
89
|
+
async start() {
|
|
90
|
+
if (this._started) {
|
|
91
|
+
throw new Error('WebMCPClientTransport already started');
|
|
92
|
+
}
|
|
93
|
+
if (this._closed) {
|
|
94
|
+
throw new Error('WebMCPClientTransport has been closed');
|
|
95
|
+
}
|
|
96
|
+
// Check if WebMCP is available
|
|
97
|
+
if (this._requireWebMCP) {
|
|
98
|
+
const check = await this.checkWebMCPAvailable();
|
|
99
|
+
if (!check.available) {
|
|
100
|
+
throw new Error('WebMCP not detected on this page. ' +
|
|
101
|
+
'Ensure @mcp-b/global is loaded and initialized.');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Create CDP session for this page
|
|
105
|
+
this._cdpSession = await this._page.createCDPSession();
|
|
106
|
+
// Enable Runtime domain for bindings and evaluation
|
|
107
|
+
await this._cdpSession.send('Runtime.enable');
|
|
108
|
+
// Set up binding for receiving messages from the bridge
|
|
109
|
+
// When the bridge calls window.__mcpBridgeToClient(msg), we receive it here
|
|
110
|
+
await this._cdpSession.send('Runtime.addBinding', {
|
|
111
|
+
name: '__mcpBridgeToClient',
|
|
112
|
+
});
|
|
113
|
+
// Listen for binding calls (messages from bridge → this transport)
|
|
114
|
+
this._cdpSession.on('Runtime.bindingCalled', event => {
|
|
115
|
+
if (event.name !== '__mcpBridgeToClient')
|
|
116
|
+
return;
|
|
117
|
+
try {
|
|
118
|
+
const payload = JSON.parse(event.payload);
|
|
119
|
+
this._handlePayload(payload);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
this.onerror?.(new Error(`Failed to parse message from bridge: ${err}`));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
// Listen for page navigation to detect when bridge is lost
|
|
126
|
+
this._page.on('framenavigated', frame => {
|
|
127
|
+
if (frame === this._page.mainFrame()) {
|
|
128
|
+
// Main frame navigated, bridge is gone
|
|
129
|
+
this._handleNavigation();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
// Inject the bridge script
|
|
133
|
+
const result = await this._page.evaluate(WEB_MCP_BRIDGE_SCRIPT);
|
|
134
|
+
if (!result.success && !result.alreadyInjected) {
|
|
135
|
+
throw new Error('Failed to inject WebMCP bridge script');
|
|
136
|
+
}
|
|
137
|
+
this._started = true;
|
|
138
|
+
// Initiate server-ready handshake
|
|
139
|
+
await this._page.evaluate(() => {
|
|
140
|
+
window.__mcpBridge?.checkReady();
|
|
141
|
+
});
|
|
142
|
+
// Wait for server ready with timeout
|
|
143
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
reject(new Error(`WebMCP server did not respond within ${this._readyTimeout}ms. ` +
|
|
146
|
+
'Ensure TabServerTransport is running on the page.'));
|
|
147
|
+
}, this._readyTimeout);
|
|
148
|
+
// Clear timeout if promise resolves
|
|
149
|
+
this._serverReadyPromise.then(() => clearTimeout(timer)).catch(() => clearTimeout(timer));
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
await Promise.race([this._serverReadyPromise, timeoutPromise]);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
await this.close();
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Handle a payload received from the bridge
|
|
161
|
+
*/
|
|
162
|
+
_handlePayload(payload) {
|
|
163
|
+
// Handle special string payloads (handshake signals)
|
|
164
|
+
if (typeof payload === 'string') {
|
|
165
|
+
if (payload === 'mcp-server-ready') {
|
|
166
|
+
if (!this._serverReady) {
|
|
167
|
+
this._serverReady = true;
|
|
168
|
+
this._serverReadyResolve();
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (payload === 'mcp-server-stopped') {
|
|
173
|
+
this._handleServerStopped();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Unknown string payload - might be an error
|
|
177
|
+
this.onerror?.(new Error(`Unexpected string payload: ${payload}`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// Handle JSON-RPC messages
|
|
181
|
+
if (typeof payload === 'object' && payload !== null) {
|
|
182
|
+
// Resolve server ready on first real message too
|
|
183
|
+
if (!this._serverReady) {
|
|
184
|
+
this._serverReady = true;
|
|
185
|
+
this._serverReadyResolve();
|
|
186
|
+
}
|
|
187
|
+
this.onmessage?.(payload);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Handle page navigation - bridge is lost
|
|
192
|
+
*/
|
|
193
|
+
_handleNavigation() {
|
|
194
|
+
if (this._closed)
|
|
195
|
+
return;
|
|
196
|
+
this._serverReady = false;
|
|
197
|
+
this._serverReadyReject(new Error('Page navigated, connection lost'));
|
|
198
|
+
// Reset the promise for potential reconnection
|
|
199
|
+
this._serverReadyPromise = new Promise((resolve, reject) => {
|
|
200
|
+
this._serverReadyResolve = resolve;
|
|
201
|
+
this._serverReadyReject = reject;
|
|
202
|
+
});
|
|
203
|
+
this.onerror?.(new Error('Page navigated, WebMCP connection lost'));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Handle server stopped signal
|
|
207
|
+
*/
|
|
208
|
+
_handleServerStopped() {
|
|
209
|
+
this._serverReady = false;
|
|
210
|
+
this.onerror?.(new Error('WebMCP server stopped'));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Send a JSON-RPC message to the WebMCP server
|
|
214
|
+
*/
|
|
215
|
+
async send(message) {
|
|
216
|
+
if (!this._started) {
|
|
217
|
+
throw new Error('WebMCPClientTransport not started');
|
|
218
|
+
}
|
|
219
|
+
if (this._closed) {
|
|
220
|
+
throw new Error('WebMCPClientTransport has been closed');
|
|
221
|
+
}
|
|
222
|
+
// Wait for server to be ready before sending
|
|
223
|
+
await this._serverReadyPromise;
|
|
224
|
+
// Send via CDP → bridge → postMessage → TabServer
|
|
225
|
+
const messageJson = JSON.stringify(message);
|
|
226
|
+
try {
|
|
227
|
+
await this._page.evaluate((msg) => {
|
|
228
|
+
const bridge = window.__mcpBridge;
|
|
229
|
+
if (!bridge) {
|
|
230
|
+
throw new Error('WebMCP bridge not found');
|
|
231
|
+
}
|
|
232
|
+
const sent = bridge.toServer(msg);
|
|
233
|
+
if (!sent) {
|
|
234
|
+
throw new Error('Bridge failed to send message');
|
|
235
|
+
}
|
|
236
|
+
}, messageJson);
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
const error = new Error(`Failed to send message: ${err}`);
|
|
240
|
+
this.onerror?.(error);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Close the transport and clean up resources
|
|
246
|
+
*/
|
|
247
|
+
async close() {
|
|
248
|
+
if (this._closed)
|
|
249
|
+
return;
|
|
250
|
+
this._closed = true;
|
|
251
|
+
this._started = false;
|
|
252
|
+
this._serverReady = false;
|
|
253
|
+
// Dispose the bridge script if possible
|
|
254
|
+
try {
|
|
255
|
+
await this._page.evaluate(() => {
|
|
256
|
+
window.__mcpBridge?.dispose();
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Ignore errors during cleanup
|
|
261
|
+
}
|
|
262
|
+
// Detach CDP session
|
|
263
|
+
if (this._cdpSession) {
|
|
264
|
+
try {
|
|
265
|
+
await this._cdpSession.detach();
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Ignore detach errors
|
|
269
|
+
}
|
|
270
|
+
this._cdpSession = null;
|
|
271
|
+
}
|
|
272
|
+
// Reject any pending server ready promise
|
|
273
|
+
this._serverReadyReject(new Error('Transport closed'));
|
|
274
|
+
this.onclose?.();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
// See the KeyInput type for the list of supported keys.
|
|
7
|
+
const validKeys = new Set([
|
|
8
|
+
'0',
|
|
9
|
+
'1',
|
|
10
|
+
'2',
|
|
11
|
+
'3',
|
|
12
|
+
'4',
|
|
13
|
+
'5',
|
|
14
|
+
'6',
|
|
15
|
+
'7',
|
|
16
|
+
'8',
|
|
17
|
+
'9',
|
|
18
|
+
'Power',
|
|
19
|
+
'Eject',
|
|
20
|
+
'Abort',
|
|
21
|
+
'Help',
|
|
22
|
+
'Backspace',
|
|
23
|
+
'Tab',
|
|
24
|
+
'Numpad5',
|
|
25
|
+
'NumpadEnter',
|
|
26
|
+
'Enter',
|
|
27
|
+
'\r',
|
|
28
|
+
'\n',
|
|
29
|
+
'ShiftLeft',
|
|
30
|
+
'ShiftRight',
|
|
31
|
+
'ControlLeft',
|
|
32
|
+
'ControlRight',
|
|
33
|
+
'AltLeft',
|
|
34
|
+
'AltRight',
|
|
35
|
+
'Pause',
|
|
36
|
+
'CapsLock',
|
|
37
|
+
'Escape',
|
|
38
|
+
'Convert',
|
|
39
|
+
'NonConvert',
|
|
40
|
+
'Space',
|
|
41
|
+
'Numpad9',
|
|
42
|
+
'PageUp',
|
|
43
|
+
'Numpad3',
|
|
44
|
+
'PageDown',
|
|
45
|
+
'End',
|
|
46
|
+
'Numpad1',
|
|
47
|
+
'Home',
|
|
48
|
+
'Numpad7',
|
|
49
|
+
'ArrowLeft',
|
|
50
|
+
'Numpad4',
|
|
51
|
+
'Numpad8',
|
|
52
|
+
'ArrowUp',
|
|
53
|
+
'ArrowRight',
|
|
54
|
+
'Numpad6',
|
|
55
|
+
'Numpad2',
|
|
56
|
+
'ArrowDown',
|
|
57
|
+
'Select',
|
|
58
|
+
'Open',
|
|
59
|
+
'PrintScreen',
|
|
60
|
+
'Insert',
|
|
61
|
+
'Numpad0',
|
|
62
|
+
'Delete',
|
|
63
|
+
'NumpadDecimal',
|
|
64
|
+
'Digit0',
|
|
65
|
+
'Digit1',
|
|
66
|
+
'Digit2',
|
|
67
|
+
'Digit3',
|
|
68
|
+
'Digit4',
|
|
69
|
+
'Digit5',
|
|
70
|
+
'Digit6',
|
|
71
|
+
'Digit7',
|
|
72
|
+
'Digit8',
|
|
73
|
+
'Digit9',
|
|
74
|
+
'KeyA',
|
|
75
|
+
'KeyB',
|
|
76
|
+
'KeyC',
|
|
77
|
+
'KeyD',
|
|
78
|
+
'KeyE',
|
|
79
|
+
'KeyF',
|
|
80
|
+
'KeyG',
|
|
81
|
+
'KeyH',
|
|
82
|
+
'KeyI',
|
|
83
|
+
'KeyJ',
|
|
84
|
+
'KeyK',
|
|
85
|
+
'KeyL',
|
|
86
|
+
'KeyM',
|
|
87
|
+
'KeyN',
|
|
88
|
+
'KeyO',
|
|
89
|
+
'KeyP',
|
|
90
|
+
'KeyQ',
|
|
91
|
+
'KeyR',
|
|
92
|
+
'KeyS',
|
|
93
|
+
'KeyT',
|
|
94
|
+
'KeyU',
|
|
95
|
+
'KeyV',
|
|
96
|
+
'KeyW',
|
|
97
|
+
'KeyX',
|
|
98
|
+
'KeyY',
|
|
99
|
+
'KeyZ',
|
|
100
|
+
'MetaLeft',
|
|
101
|
+
'MetaRight',
|
|
102
|
+
'ContextMenu',
|
|
103
|
+
'NumpadMultiply',
|
|
104
|
+
'NumpadAdd',
|
|
105
|
+
'NumpadSubtract',
|
|
106
|
+
'NumpadDivide',
|
|
107
|
+
'F1',
|
|
108
|
+
'F2',
|
|
109
|
+
'F3',
|
|
110
|
+
'F4',
|
|
111
|
+
'F5',
|
|
112
|
+
'F6',
|
|
113
|
+
'F7',
|
|
114
|
+
'F8',
|
|
115
|
+
'F9',
|
|
116
|
+
'F10',
|
|
117
|
+
'F11',
|
|
118
|
+
'F12',
|
|
119
|
+
'F13',
|
|
120
|
+
'F14',
|
|
121
|
+
'F15',
|
|
122
|
+
'F16',
|
|
123
|
+
'F17',
|
|
124
|
+
'F18',
|
|
125
|
+
'F19',
|
|
126
|
+
'F20',
|
|
127
|
+
'F21',
|
|
128
|
+
'F22',
|
|
129
|
+
'F23',
|
|
130
|
+
'F24',
|
|
131
|
+
'NumLock',
|
|
132
|
+
'ScrollLock',
|
|
133
|
+
'AudioVolumeMute',
|
|
134
|
+
'AudioVolumeDown',
|
|
135
|
+
'AudioVolumeUp',
|
|
136
|
+
'MediaTrackNext',
|
|
137
|
+
'MediaTrackPrevious',
|
|
138
|
+
'MediaStop',
|
|
139
|
+
'MediaPlayPause',
|
|
140
|
+
'Semicolon',
|
|
141
|
+
'Equal',
|
|
142
|
+
'NumpadEqual',
|
|
143
|
+
'Comma',
|
|
144
|
+
'Minus',
|
|
145
|
+
'Period',
|
|
146
|
+
'Slash',
|
|
147
|
+
'Backquote',
|
|
148
|
+
'BracketLeft',
|
|
149
|
+
'Backslash',
|
|
150
|
+
'BracketRight',
|
|
151
|
+
'Quote',
|
|
152
|
+
'AltGraph',
|
|
153
|
+
'Props',
|
|
154
|
+
'Cancel',
|
|
155
|
+
'Clear',
|
|
156
|
+
'Shift',
|
|
157
|
+
'Control',
|
|
158
|
+
'Alt',
|
|
159
|
+
'Accept',
|
|
160
|
+
'ModeChange',
|
|
161
|
+
' ',
|
|
162
|
+
'Print',
|
|
163
|
+
'Execute',
|
|
164
|
+
'\u0000',
|
|
165
|
+
'a',
|
|
166
|
+
'b',
|
|
167
|
+
'c',
|
|
168
|
+
'd',
|
|
169
|
+
'e',
|
|
170
|
+
'f',
|
|
171
|
+
'g',
|
|
172
|
+
'h',
|
|
173
|
+
'i',
|
|
174
|
+
'j',
|
|
175
|
+
'k',
|
|
176
|
+
'l',
|
|
177
|
+
'm',
|
|
178
|
+
'n',
|
|
179
|
+
'o',
|
|
180
|
+
'p',
|
|
181
|
+
'q',
|
|
182
|
+
'r',
|
|
183
|
+
's',
|
|
184
|
+
't',
|
|
185
|
+
'u',
|
|
186
|
+
'v',
|
|
187
|
+
'w',
|
|
188
|
+
'x',
|
|
189
|
+
'y',
|
|
190
|
+
'z',
|
|
191
|
+
'Meta',
|
|
192
|
+
'*',
|
|
193
|
+
'+',
|
|
194
|
+
'-',
|
|
195
|
+
'/',
|
|
196
|
+
';',
|
|
197
|
+
'=',
|
|
198
|
+
',',
|
|
199
|
+
'.',
|
|
200
|
+
'`',
|
|
201
|
+
'[',
|
|
202
|
+
'\\',
|
|
203
|
+
']',
|
|
204
|
+
"'",
|
|
205
|
+
'Attn',
|
|
206
|
+
'CrSel',
|
|
207
|
+
'ExSel',
|
|
208
|
+
'EraseEof',
|
|
209
|
+
'Play',
|
|
210
|
+
'ZoomOut',
|
|
211
|
+
')',
|
|
212
|
+
'!',
|
|
213
|
+
'@',
|
|
214
|
+
'#',
|
|
215
|
+
'$',
|
|
216
|
+
'%',
|
|
217
|
+
'^',
|
|
218
|
+
'&',
|
|
219
|
+
'(',
|
|
220
|
+
'A',
|
|
221
|
+
'B',
|
|
222
|
+
'C',
|
|
223
|
+
'D',
|
|
224
|
+
'E',
|
|
225
|
+
'F',
|
|
226
|
+
'G',
|
|
227
|
+
'H',
|
|
228
|
+
'I',
|
|
229
|
+
'J',
|
|
230
|
+
'K',
|
|
231
|
+
'L',
|
|
232
|
+
'M',
|
|
233
|
+
'N',
|
|
234
|
+
'O',
|
|
235
|
+
'P',
|
|
236
|
+
'Q',
|
|
237
|
+
'R',
|
|
238
|
+
'S',
|
|
239
|
+
'T',
|
|
240
|
+
'U',
|
|
241
|
+
'V',
|
|
242
|
+
'W',
|
|
243
|
+
'X',
|
|
244
|
+
'Y',
|
|
245
|
+
'Z',
|
|
246
|
+
':',
|
|
247
|
+
'<',
|
|
248
|
+
'_',
|
|
249
|
+
'>',
|
|
250
|
+
'?',
|
|
251
|
+
'~',
|
|
252
|
+
'{',
|
|
253
|
+
'|',
|
|
254
|
+
'}',
|
|
255
|
+
'"',
|
|
256
|
+
'SoftLeft',
|
|
257
|
+
'SoftRight',
|
|
258
|
+
'Camera',
|
|
259
|
+
'Call',
|
|
260
|
+
'EndCall',
|
|
261
|
+
'VolumeDown',
|
|
262
|
+
'VolumeUp',
|
|
263
|
+
]);
|
|
264
|
+
function throwIfInvalidKey(key) {
|
|
265
|
+
if (validKeys.has(key)) {
|
|
266
|
+
return key;
|
|
267
|
+
}
|
|
268
|
+
throw new Error(`${key} is invalid. Valid keys are: ${Array.from(validKeys.values()).join(',')}.`);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Returns the primary key, followed by modifiers in original order.
|
|
272
|
+
*/
|
|
273
|
+
export function parseKey(keyInput) {
|
|
274
|
+
let key = '';
|
|
275
|
+
const result = [];
|
|
276
|
+
for (const ch of keyInput) {
|
|
277
|
+
// Handle cases like Shift++.
|
|
278
|
+
if (ch === '+' && key) {
|
|
279
|
+
result.push(throwIfInvalidKey(key));
|
|
280
|
+
key = '';
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
key += ch;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (key) {
|
|
287
|
+
result.push(throwIfInvalidKey(key));
|
|
288
|
+
}
|
|
289
|
+
if (result.length === 0) {
|
|
290
|
+
throw new Error(`Key ${keyInput} could not be parsed.`);
|
|
291
|
+
}
|
|
292
|
+
if (new Set(result).size !== result.length) {
|
|
293
|
+
throw new Error(`Key ${keyInput} contains duplicate keys.`);
|
|
294
|
+
}
|
|
295
|
+
return [result.at(-1), ...result.slice(0, -1)];
|
|
296
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
7
|
+
export function paginate(items, options) {
|
|
8
|
+
const total = items.length;
|
|
9
|
+
if (!options || noPaginationOptions(options)) {
|
|
10
|
+
return {
|
|
11
|
+
items,
|
|
12
|
+
currentPage: 0,
|
|
13
|
+
totalPages: 1,
|
|
14
|
+
hasNextPage: false,
|
|
15
|
+
hasPreviousPage: false,
|
|
16
|
+
startIndex: 0,
|
|
17
|
+
endIndex: total,
|
|
18
|
+
invalidPage: false,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
22
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
23
|
+
const { currentPage, invalidPage } = resolvePageIndex(options.pageIdx, totalPages);
|
|
24
|
+
const startIndex = currentPage * pageSize;
|
|
25
|
+
const pageItems = items.slice(startIndex, startIndex + pageSize);
|
|
26
|
+
const endIndex = startIndex + pageItems.length;
|
|
27
|
+
return {
|
|
28
|
+
items: pageItems,
|
|
29
|
+
currentPage,
|
|
30
|
+
totalPages,
|
|
31
|
+
hasNextPage: currentPage < totalPages - 1,
|
|
32
|
+
hasPreviousPage: currentPage > 0,
|
|
33
|
+
startIndex,
|
|
34
|
+
endIndex,
|
|
35
|
+
invalidPage,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function noPaginationOptions(options) {
|
|
39
|
+
return options.pageSize === undefined && options.pageIdx === undefined;
|
|
40
|
+
}
|
|
41
|
+
function resolvePageIndex(pageIdx, totalPages) {
|
|
42
|
+
if (pageIdx === undefined) {
|
|
43
|
+
return { currentPage: 0, invalidPage: false };
|
|
44
|
+
}
|
|
45
|
+
if (pageIdx < 0 || pageIdx >= totalPages) {
|
|
46
|
+
return { currentPage: 0, invalidPage: true };
|
|
47
|
+
}
|
|
48
|
+
return { currentPage: pageIdx, invalidPage: false };
|
|
49
|
+
}
|