@ohah/react-native-mcp-server 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -0
- package/babel-plugin-app-registry.cjs +36 -0
- package/babel-plugin-inject-testid.cjs +115 -0
- package/babel-preset.cjs +26 -0
- package/dist/adb-utils-DreOsWp-.js +206 -0
- package/dist/adb-utils-R7jCnMjU.js +3 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +3829 -0
- package/dist/init-202kCwU5.js +330 -0
- package/dist/transformer-entry.d.ts +44 -0
- package/dist/transformer-entry.js +39036 -0
- package/package.json +87 -0
- package/runtime.js +2722 -0
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# @ohah/react-native-mcp-server
|
|
2
|
+
|
|
3
|
+
> [한국어 문서](./README_KO.md)
|
|
4
|
+
|
|
5
|
+
MCP (Model Context Protocol) server for React Native app automation and monitoring. Lets AI assistants (Cursor, Claude Desktop, GitHub Copilot CLI) inspect and control your React Native app over WebSocket.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Monitor app state, network requests, and console logs
|
|
10
|
+
- Tap, swipe, type text, take screenshots via idb (iOS) / adb (Android)
|
|
11
|
+
- Query the component tree, run scripts in the app context
|
|
12
|
+
- One-command setup with `init`
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
**Prerequisites:** Node.js 18+ or Bun. For tap/swipe/screenshots you need [idb](https://fbidb.io/) (iOS, macOS) and [adb](https://developer.android.com/tools/adb) (Android).
|
|
17
|
+
|
|
18
|
+
No global install required — use with npx:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx -y @ohah/react-native-mcp-server init
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This detects your project (React Native / Expo, package manager), asks for your MCP client (Cursor, Claude, etc.), and configures Babel + MCP config + .gitignore.
|
|
25
|
+
|
|
26
|
+
Or install globally:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g @ohah/react-native-mcp-server
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
1. **Setup** (once per project):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx -y @ohah/react-native-mcp-server init
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
2. **Start your app.** In development, the MCP runtime is included automatically (no extra env needed). Example:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx react-native start
|
|
44
|
+
# or for Expo: npx expo start
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
To enable MCP in release builds, run Metro with `REACT_NATIVE_MCP_ENABLED=true` or set it in your build config.
|
|
48
|
+
|
|
49
|
+
3. **Configure your MCP client** (e.g. Cursor → Settings → MCP). The init command creates the config; typical entry:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"react-native-mcp": {
|
|
55
|
+
"command": "npx",
|
|
56
|
+
"args": ["-y", "@ohah/react-native-mcp-server"]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
4. Open your app and Cursor (or Claude/Copilot); the server connects to the app automatically.
|
|
63
|
+
|
|
64
|
+
## Init options
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx -y @ohah/react-native-mcp-server init -y # Non-interactive (Cursor default)
|
|
68
|
+
npx -y @ohah/react-native-mcp-server init --client cursor # Cursor
|
|
69
|
+
npx -y @ohah/react-native-mcp-server init --client claude-code # Claude Code (CLI)
|
|
70
|
+
npx -y @ohah/react-native-mcp-server init --client claude-desktop
|
|
71
|
+
npx -y @ohah/react-native-mcp-server init --help
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Manual Babel setup
|
|
75
|
+
|
|
76
|
+
If you prefer not to use `init`, add the Babel preset yourself so the app can connect to the MCP server:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
// babel.config.js
|
|
80
|
+
module.exports = {
|
|
81
|
+
presets: [
|
|
82
|
+
'module:@react-native/babel-preset', // or your existing preset
|
|
83
|
+
'@ohah/react-native-mcp-server/babel-preset',
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then add the MCP server entry to your client config (e.g. `.cursor/mcp.json` or Claude config) as in the Quick Start step 3.
|
|
89
|
+
|
|
90
|
+
## Native tools (idb / adb)
|
|
91
|
+
|
|
92
|
+
For tap, swipe, screenshots, and similar tools you need:
|
|
93
|
+
|
|
94
|
+
| Platform | Tool | Install |
|
|
95
|
+
| ------------- | ---- | ------------------------------------------------------------------------------------------ |
|
|
96
|
+
| iOS Simulator | idb | `brew tap facebook/fb && brew install idb-companion` (macOS) |
|
|
97
|
+
| Android | adb | Install Android Studio (adb is included), or `brew install android-platform-tools` (macOS) |
|
|
98
|
+
|
|
99
|
+
Without these, other MCP tools (state, network, console, eval) still work.
|
|
100
|
+
|
|
101
|
+
## Documentation
|
|
102
|
+
|
|
103
|
+
- **Full docs:** [https://ohah.github.io/react-native-mcp/](https://ohah.github.io/react-native-mcp/)
|
|
104
|
+
- **Repository:** [github.com/ohah/react-native-mcp](https://github.com/ohah/react-native-mcp)
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/babel-plugin-app-registry.ts
|
|
3
|
+
const MCP_RUNTIME_ID = "__REACT_NATIVE_MCP__";
|
|
4
|
+
const RUNTIME_MODULE_ID = "@ohah/react-native-mcp-server/runtime";
|
|
5
|
+
function babel_plugin_app_registry_default(babel) {
|
|
6
|
+
const t = babel.types;
|
|
7
|
+
return {
|
|
8
|
+
name: "react-native-mcp-app-registry",
|
|
9
|
+
visitor: {
|
|
10
|
+
Program: { enter(_path, state) {
|
|
11
|
+
state.runtimeInjected = false;
|
|
12
|
+
} },
|
|
13
|
+
CallExpression(path, state) {
|
|
14
|
+
const node = path.node;
|
|
15
|
+
if ((path.hub?.file?.opts?.filename ?? "").includes("node_modules")) return;
|
|
16
|
+
if (!t.isCallExpression(node)) return;
|
|
17
|
+
if (!t.isMemberExpression(node.callee)) return;
|
|
18
|
+
const { object, property } = node.callee;
|
|
19
|
+
if (!t.isIdentifier(object) || !t.isIdentifier(property)) return;
|
|
20
|
+
if (object.name !== "AppRegistry" || property.name !== "registerComponent") return;
|
|
21
|
+
if (!state.runtimeInjected) {
|
|
22
|
+
const programPath = path.findParent((p) => p.isProgram?.());
|
|
23
|
+
if (programPath?.node?.body) {
|
|
24
|
+
programPath.node.body.unshift(t.expressionStatement(t.callExpression(t.identifier("require"), [t.stringLiteral(RUNTIME_MODULE_ID)])));
|
|
25
|
+
programPath.node.body.unshift(t.expressionStatement(t.assignmentExpression("=", t.memberExpression(t.identifier("global"), t.identifier("__REACT_NATIVE_MCP_ENABLED__")), t.booleanLiteral(true))));
|
|
26
|
+
state.runtimeInjected = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
path.replaceWith(t.callExpression(t.memberExpression(t.identifier(MCP_RUNTIME_ID), t.identifier("registerComponent")), node.arguments));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
module.exports = babel_plugin_app_registry_default;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/babel-plugin-inject-testid.ts
|
|
3
|
+
function getTestIdStringLiteral(t, attr) {
|
|
4
|
+
if (!attr.value) return null;
|
|
5
|
+
if (t.isStringLiteral(attr.value)) return attr.value.value;
|
|
6
|
+
if (t.isJSXExpressionContainer(attr.value) && t.isStringLiteral(attr.value.expression)) return attr.value.expression.value;
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
function getTagName(t, name) {
|
|
10
|
+
if (t.isJSXIdentifier(name)) return name.name;
|
|
11
|
+
if (t.isJSXNamespacedName(name)) return `${name.namespace.name}.${name.name.name}`;
|
|
12
|
+
const parts = [];
|
|
13
|
+
let cur = name;
|
|
14
|
+
while (true) {
|
|
15
|
+
if (t.isJSXIdentifier(cur.object)) parts.push(cur.object.name);
|
|
16
|
+
else parts.push(getTagName(t, cur.object));
|
|
17
|
+
if (t.isJSXIdentifier(cur.property)) {
|
|
18
|
+
parts.push(cur.property.name);
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
cur = cur.property;
|
|
22
|
+
}
|
|
23
|
+
return parts.join(".");
|
|
24
|
+
}
|
|
25
|
+
function babel_plugin_inject_testid_default(babel) {
|
|
26
|
+
const t = babel.types;
|
|
27
|
+
return {
|
|
28
|
+
name: "react-native-mcp-inject-testid",
|
|
29
|
+
visitor: {
|
|
30
|
+
Program: { enter(_path, state) {
|
|
31
|
+
state.stack = [];
|
|
32
|
+
} },
|
|
33
|
+
Function: {
|
|
34
|
+
enter(path, state) {
|
|
35
|
+
let name = t.isFunctionDeclaration(path.node) || t.isFunctionExpression(path.node) ? path.node.id?.name ?? null : null;
|
|
36
|
+
if (!name && path.parent && t.isVariableDeclarator(path.parent) && t.isIdentifier(path.parent.id)) name = path.parent.id.name;
|
|
37
|
+
if (!name && t.isIdentifier(path.node.params[0])) name = path.node.params[0].name;
|
|
38
|
+
state.stack.push({
|
|
39
|
+
componentName: name ?? "Anonymous",
|
|
40
|
+
jsxIndex: 0
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
exit(path, state) {
|
|
44
|
+
const scope = state.stack[state.stack.length - 1];
|
|
45
|
+
if (scope && scope.jsxIndex > 0 && scope.componentName !== "Anonymous" && /^[A-Z]/.test(scope.componentName)) {
|
|
46
|
+
const stmt = t.expressionStatement(t.assignmentExpression("=", t.memberExpression(t.identifier(scope.componentName), t.identifier("displayName")), t.stringLiteral(scope.componentName)));
|
|
47
|
+
let target = null;
|
|
48
|
+
if (t.isFunctionDeclaration(path.node)) target = path.parentPath && (t.isExportDefaultDeclaration(path.parent) || t.isExportNamedDeclaration(path.parent)) ? path.parentPath : path;
|
|
49
|
+
else if (path.parentPath && t.isVariableDeclarator(path.parent)) {
|
|
50
|
+
target = path.parentPath.parentPath;
|
|
51
|
+
if (target?.parentPath && (t.isExportDefaultDeclaration(target.parent) || t.isExportNamedDeclaration(target.parent))) target = target.parentPath;
|
|
52
|
+
}
|
|
53
|
+
target?.insertAfter(stmt);
|
|
54
|
+
}
|
|
55
|
+
state.stack.pop();
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
JSXOpeningElement(path, state) {
|
|
59
|
+
if ((path.hub?.file?.opts?.filename ?? "").includes("node_modules")) return;
|
|
60
|
+
const scope = state.stack[state.stack.length - 1];
|
|
61
|
+
if (scope === void 0) return;
|
|
62
|
+
const el = path.node;
|
|
63
|
+
const testIdAttr = el.attributes.find((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === "testID");
|
|
64
|
+
if (!!!testIdAttr) {
|
|
65
|
+
const tagName = getTagName(t, el.name);
|
|
66
|
+
const baseValue = `${scope.componentName}-${scope.jsxIndex}-${tagName}`;
|
|
67
|
+
const keyAttr = el.attributes.find((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === "key");
|
|
68
|
+
let keyExpr = null;
|
|
69
|
+
if (keyAttr?.value) {
|
|
70
|
+
if (t.isStringLiteral(keyAttr.value)) keyExpr = keyAttr.value;
|
|
71
|
+
else if (t.isJSXExpressionContainer(keyAttr.value) && !t.isJSXEmptyExpression(keyAttr.value.expression)) keyExpr = keyAttr.value.expression;
|
|
72
|
+
}
|
|
73
|
+
if (keyExpr) {
|
|
74
|
+
const tpl = t.templateLiteral([t.templateElement({
|
|
75
|
+
raw: baseValue + "-",
|
|
76
|
+
cooked: baseValue + "-"
|
|
77
|
+
}, false), t.templateElement({
|
|
78
|
+
raw: "",
|
|
79
|
+
cooked: ""
|
|
80
|
+
}, true)], [t.cloneNode(keyExpr)]);
|
|
81
|
+
el.attributes.push(t.jsxAttribute(t.jsxIdentifier("testID"), t.jsxExpressionContainer(tpl)));
|
|
82
|
+
} else el.attributes.push(t.jsxAttribute(t.jsxIdentifier("testID"), t.stringLiteral(baseValue)));
|
|
83
|
+
scope.jsxIndex += 1;
|
|
84
|
+
}
|
|
85
|
+
if (getTagName(t, el.name) === "WebView") {
|
|
86
|
+
const webViewTidAttr = testIdAttr ?? el.attributes.find((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === "testID");
|
|
87
|
+
const webViewTestIdValue = webViewTidAttr ? getTestIdStringLiteral(t, webViewTidAttr) : null;
|
|
88
|
+
if (webViewTestIdValue != null) {
|
|
89
|
+
const refAttr = el.attributes.find((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === "ref");
|
|
90
|
+
const mcpRegister = t.expressionStatement(t.logicalExpression("&&", t.optionalMemberExpression(t.identifier("__REACT_NATIVE_MCP__"), t.identifier("registerWebView"), false, true), t.callExpression(t.memberExpression(t.optionalMemberExpression(t.identifier("__REACT_NATIVE_MCP__"), t.identifier("registerWebView"), false, true), t.identifier("call"), false), [
|
|
91
|
+
t.identifier("__REACT_NATIVE_MCP__"),
|
|
92
|
+
t.identifier("r"),
|
|
93
|
+
t.stringLiteral(webViewTestIdValue)
|
|
94
|
+
])));
|
|
95
|
+
const mcpUnregister = t.expressionStatement(t.logicalExpression("&&", t.optionalMemberExpression(t.identifier("__REACT_NATIVE_MCP__"), t.identifier("unregisterWebView"), false, true), t.callExpression(t.memberExpression(t.optionalMemberExpression(t.identifier("__REACT_NATIVE_MCP__"), t.identifier("unregisterWebView"), false, true), t.identifier("call"), false), [t.identifier("__REACT_NATIVE_MCP__"), t.stringLiteral(webViewTestIdValue)])));
|
|
96
|
+
const bodyStatements = [t.ifStatement(t.identifier("r"), mcpRegister, mcpUnregister)];
|
|
97
|
+
const userRefExpr = refAttr?.value && t.isJSXExpressionContainer(refAttr.value) ? refAttr.value.expression : null;
|
|
98
|
+
if (userRefExpr != null && !t.isJSXEmptyExpression(userRefExpr)) bodyStatements.push(t.ifStatement(t.binaryExpression("!=", t.cloneNode(userRefExpr), t.nullLiteral()), t.blockStatement([t.ifStatement(t.binaryExpression("===", t.unaryExpression("typeof", t.cloneNode(userRefExpr)), t.stringLiteral("function")), t.expressionStatement(t.callExpression(t.cloneNode(userRefExpr), [t.identifier("r")])), t.expressionStatement(t.assignmentExpression("=", t.memberExpression(t.cloneNode(userRefExpr), t.identifier("current")), t.identifier("r"))))])));
|
|
99
|
+
const composedRef = t.arrowFunctionExpression([t.identifier("r")], t.blockStatement(bodyStatements));
|
|
100
|
+
if (refAttr) refAttr.value = t.jsxExpressionContainer(composedRef);
|
|
101
|
+
else el.attributes.push(t.jsxAttribute(t.jsxIdentifier("ref"), t.jsxExpressionContainer(composedRef)));
|
|
102
|
+
const onMessageAttr = el.attributes.find((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === "onMessage");
|
|
103
|
+
const userOnMessageExpr = onMessageAttr?.value && t.isJSXExpressionContainer(onMessageAttr.value) ? onMessageAttr.value.expression : null;
|
|
104
|
+
const mcpOnMessage = userOnMessageExpr != null && !t.isJSXEmptyExpression(userOnMessageExpr) ? t.callExpression(t.memberExpression(t.identifier("__REACT_NATIVE_MCP__"), t.identifier("createWebViewOnMessage"), false), [userOnMessageExpr]) : t.arrowFunctionExpression([t.identifier("e")], t.callExpression(t.memberExpression(t.identifier("__REACT_NATIVE_MCP__"), t.identifier("handleWebViewMessage"), false), [t.memberExpression(t.memberExpression(t.identifier("e"), t.identifier("nativeEvent")), t.identifier("data"))]));
|
|
105
|
+
if (onMessageAttr) onMessageAttr.value = t.jsxExpressionContainer(mcpOnMessage);
|
|
106
|
+
else el.attributes.push(t.jsxAttribute(t.jsxIdentifier("onMessage"), t.jsxExpressionContainer(mcpOnMessage)));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
//#endregion
|
|
115
|
+
module.exports = babel_plugin_inject_testid_default;
|
package/babel-preset.cjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Babel preset: AppRegistry 래핑 + testID 자동 주입
|
|
5
|
+
* babel.config.js에서 presets에 한 번만 넣으면 됨.
|
|
6
|
+
* - __DEV__ 빌드: 자동 활성화 (환경변수 불필요)
|
|
7
|
+
* - Release 빌드: REACT_NATIVE_MCP_ENABLED=true|1 일 때만 활성화
|
|
8
|
+
*/
|
|
9
|
+
function isMcpEnabled() {
|
|
10
|
+
// Metro는 DEV 빌드 시 NODE_ENV를 'development'로 설정한다
|
|
11
|
+
if (process.env.NODE_ENV !== 'production') return true;
|
|
12
|
+
const v = process.env.REACT_NATIVE_MCP_ENABLED;
|
|
13
|
+
return v === 'true' || v === '1';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = function () {
|
|
17
|
+
if (!isMcpEnabled()) {
|
|
18
|
+
return { plugins: [] };
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
plugins: [
|
|
22
|
+
require('./babel-plugin-app-registry.cjs'),
|
|
23
|
+
require('./babel-plugin-inject-testid.cjs'),
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
//#region src/tools/run-command.ts
|
|
4
|
+
/**
|
|
5
|
+
* 공유 CLI 명령 실행 유틸리티
|
|
6
|
+
* take-screenshot, idb 도구 등 호스트 CLI를 실행하는 모든 도구에서 사용.
|
|
7
|
+
*/
|
|
8
|
+
function runCommand(command, args, options) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const proc = spawn(command, args, { stdio: options?.stdin ? [
|
|
11
|
+
"pipe",
|
|
12
|
+
"pipe",
|
|
13
|
+
"pipe"
|
|
14
|
+
] : [
|
|
15
|
+
"ignore",
|
|
16
|
+
"pipe",
|
|
17
|
+
"pipe"
|
|
18
|
+
] });
|
|
19
|
+
const outChunks = [];
|
|
20
|
+
const errChunks = [];
|
|
21
|
+
proc.stdout?.on("data", (chunk) => outChunks.push(chunk));
|
|
22
|
+
proc.stderr?.on("data", (chunk) => errChunks.push(chunk));
|
|
23
|
+
const timeout = options?.timeoutMs != null ? setTimeout(() => {
|
|
24
|
+
proc.kill("SIGKILL");
|
|
25
|
+
reject(/* @__PURE__ */ new Error("Command timed out"));
|
|
26
|
+
}, options.timeoutMs) : void 0;
|
|
27
|
+
proc.on("close", (code) => {
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
if (code !== 0) {
|
|
30
|
+
const stderr = Buffer.concat(errChunks).toString("utf8").slice(0, 300);
|
|
31
|
+
reject(/* @__PURE__ */ new Error(`Command failed with code ${code}. ${stderr}`));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
resolve(Buffer.concat(outChunks));
|
|
35
|
+
});
|
|
36
|
+
proc.on("error", (err) => {
|
|
37
|
+
clearTimeout(timeout);
|
|
38
|
+
reject(err);
|
|
39
|
+
});
|
|
40
|
+
if (options?.stdin && proc.stdin) {
|
|
41
|
+
proc.stdin.write(options.stdin);
|
|
42
|
+
proc.stdin.end();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/tools/adb-utils.ts
|
|
49
|
+
/**
|
|
50
|
+
* adb (Android Debug Bridge) 공유 유틸리티
|
|
51
|
+
* 디바이스 시리얼 자동 해석, adb 설치 확인, 명령 실행 래퍼.
|
|
52
|
+
*/
|
|
53
|
+
let _adbAvailable = null;
|
|
54
|
+
async function checkAdbAvailable() {
|
|
55
|
+
if (_adbAvailable != null) return _adbAvailable;
|
|
56
|
+
try {
|
|
57
|
+
await runCommand("which", ["adb"], { timeoutMs: 3e3 });
|
|
58
|
+
_adbAvailable = true;
|
|
59
|
+
} catch {
|
|
60
|
+
_adbAvailable = false;
|
|
61
|
+
}
|
|
62
|
+
return _adbAvailable;
|
|
63
|
+
}
|
|
64
|
+
async function listAdbDevices() {
|
|
65
|
+
const lines = (await runCommand("adb", ["devices", "-l"], { timeoutMs: 1e4 })).toString("utf8").split("\n").filter((l) => l.trim().length > 0);
|
|
66
|
+
const devices = [];
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
if (line.startsWith("List of")) continue;
|
|
69
|
+
const match = line.match(/^(\S+)\s+(device|offline|unauthorized|no permissions)\b(.*)$/);
|
|
70
|
+
if (!match) continue;
|
|
71
|
+
const serial = match[1];
|
|
72
|
+
const state = match[2];
|
|
73
|
+
const rest = match[3] ?? "";
|
|
74
|
+
const device = {
|
|
75
|
+
serial,
|
|
76
|
+
state
|
|
77
|
+
};
|
|
78
|
+
const productMatch = rest.match(/product:(\S+)/);
|
|
79
|
+
if (productMatch) device.product = productMatch[1];
|
|
80
|
+
const modelMatch = rest.match(/model:(\S+)/);
|
|
81
|
+
if (modelMatch) device.model = modelMatch[1];
|
|
82
|
+
const deviceMatch = rest.match(/device:(\S+)/);
|
|
83
|
+
if (deviceMatch) device.device = deviceMatch[1];
|
|
84
|
+
const transportMatch = rest.match(/transport_id:(\S+)/);
|
|
85
|
+
if (transportMatch) device.transport_id = transportMatch[1];
|
|
86
|
+
devices.push(device);
|
|
87
|
+
}
|
|
88
|
+
return devices;
|
|
89
|
+
}
|
|
90
|
+
async function resolveSerial(serial) {
|
|
91
|
+
if (serial != null && serial !== "") return serial;
|
|
92
|
+
const online = (await listAdbDevices()).filter((d) => d.state === "device");
|
|
93
|
+
if (online.length === 0) throw new Error("No connected Android device found. Connect a device or start an emulator with: emulator -avd <avd_name>");
|
|
94
|
+
if (online.length > 1) {
|
|
95
|
+
const list = online.map((d) => ` ${d.serial}${d.model ? ` (${d.model})` : ""}`).join("\n");
|
|
96
|
+
throw new Error(`Multiple Android devices connected. Specify serial parameter.\n${list}\nUse list_devices(platform="android") to see all devices.`);
|
|
97
|
+
}
|
|
98
|
+
return online[0].serial;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 해당 Android 대상이 에뮬레이터(AVD)인지 여부.
|
|
102
|
+
* - adb shell getprop ro.kernel.qemu → "1" 이면 에뮬.
|
|
103
|
+
* - 시리얼이 "emulator-" 로 시작해도 에뮬로 간주 (getprop 실패 시 fallback).
|
|
104
|
+
*/
|
|
105
|
+
async function isAndroidEmulator(serial) {
|
|
106
|
+
try {
|
|
107
|
+
return (await runAdbCommand([
|
|
108
|
+
"shell",
|
|
109
|
+
"getprop",
|
|
110
|
+
"ro.kernel.qemu"
|
|
111
|
+
], serial, { timeoutMs: 3e3 })).trim() === "1";
|
|
112
|
+
} catch {
|
|
113
|
+
return serial.startsWith("emulator-");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function runAdbCommand(subcommand, serial, options) {
|
|
117
|
+
const args = [];
|
|
118
|
+
if (serial) args.push("-s", serial);
|
|
119
|
+
args.push(...subcommand);
|
|
120
|
+
return (await runCommand("adb", args, { timeoutMs: options?.timeoutMs ?? 1e4 })).toString("utf8").trim();
|
|
121
|
+
}
|
|
122
|
+
const _scaleBySerial = /* @__PURE__ */ new Map();
|
|
123
|
+
/**
|
|
124
|
+
* Android screen density scale (디바이스별 캐싱).
|
|
125
|
+
* density 160 = 1x, 320 = 2x, 480 = 3x 등.
|
|
126
|
+
* dp × scale = pixel.
|
|
127
|
+
*/
|
|
128
|
+
async function getAndroidScale(serial) {
|
|
129
|
+
const key = serial ?? "_default";
|
|
130
|
+
const cached = _scaleBySerial.get(key);
|
|
131
|
+
if (cached != null) return cached;
|
|
132
|
+
try {
|
|
133
|
+
const match = (await runCommand("adb", serial ? [
|
|
134
|
+
"-s",
|
|
135
|
+
serial,
|
|
136
|
+
"shell",
|
|
137
|
+
"wm",
|
|
138
|
+
"density"
|
|
139
|
+
] : [
|
|
140
|
+
"shell",
|
|
141
|
+
"wm",
|
|
142
|
+
"density"
|
|
143
|
+
], { timeoutMs: 5e3 })).toString().match(/(\d+)/);
|
|
144
|
+
if (!match) throw new Error("density not found in wm output");
|
|
145
|
+
const scale = parseInt(match[1], 10) / 160;
|
|
146
|
+
_scaleBySerial.set(key, scale);
|
|
147
|
+
return scale;
|
|
148
|
+
} catch {
|
|
149
|
+
return 2.75;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const _topInsetBySerial = /* @__PURE__ */ new Map();
|
|
153
|
+
/**
|
|
154
|
+
* Android 디바이스의 실제 top inset(px)을 `dumpsys window displays`에서 파싱.
|
|
155
|
+
* captionBar(태블릿 등)가 있으면 우선, 없으면 statusBars 사용.
|
|
156
|
+
* 파싱 실패 시 0 반환 (호출자가 fallback 처리).
|
|
157
|
+
*/
|
|
158
|
+
async function getAndroidTopInset(serial) {
|
|
159
|
+
const key = serial ?? "_default";
|
|
160
|
+
const cached = _topInsetBySerial.get(key);
|
|
161
|
+
if (cached != null) return cached;
|
|
162
|
+
try {
|
|
163
|
+
const text = await runAdbCommand([
|
|
164
|
+
"shell",
|
|
165
|
+
"dumpsys",
|
|
166
|
+
"window",
|
|
167
|
+
"displays"
|
|
168
|
+
], serial, { timeoutMs: 5e3 });
|
|
169
|
+
const captionMatch = text.match(/InsetsSource[^\n]*type=captionBar[^\n]*frame=\[\d+,\d+\]\[\d+,(\d+)\]/);
|
|
170
|
+
if (captionMatch) {
|
|
171
|
+
const px = parseInt(captionMatch[1], 10);
|
|
172
|
+
_topInsetBySerial.set(key, px);
|
|
173
|
+
return px;
|
|
174
|
+
}
|
|
175
|
+
const statusMatch = text.match(/InsetsSource[^\n]*type=statusBars[^\n]*frame=\[\d+,\d+\]\[\d+,(\d+)\]/);
|
|
176
|
+
if (statusMatch) {
|
|
177
|
+
const px = parseInt(statusMatch[1], 10);
|
|
178
|
+
_topInsetBySerial.set(key, px);
|
|
179
|
+
return px;
|
|
180
|
+
}
|
|
181
|
+
_topInsetBySerial.set(key, 0);
|
|
182
|
+
return 0;
|
|
183
|
+
} catch {
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function adbNotInstalledError() {
|
|
188
|
+
return {
|
|
189
|
+
isError: true,
|
|
190
|
+
content: [{
|
|
191
|
+
type: "text",
|
|
192
|
+
text: [
|
|
193
|
+
"adb (Android Debug Bridge) is not installed or not in PATH.",
|
|
194
|
+
"",
|
|
195
|
+
"Install Android SDK Platform-Tools:",
|
|
196
|
+
" macOS: brew install android-platform-tools",
|
|
197
|
+
" Or download from: https://developer.android.com/tools/releases/platform-tools",
|
|
198
|
+
"",
|
|
199
|
+
"Verify: adb devices"
|
|
200
|
+
].join("\n")
|
|
201
|
+
}]
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
//#endregion
|
|
206
|
+
export { isAndroidEmulator as a, runAdbCommand as c, getAndroidTopInset as i, runCommand as l, checkAdbAvailable as n, listAdbDevices as o, getAndroidScale as r, resolveSerial as s, adbNotInstalledError as t };
|