uiautomator2-mcp-server 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
- u2mcp/.gitignore +1 -1
- u2mcp/__init__.py +1 -2
- u2mcp/__main__.py +193 -82
- u2mcp/background.py +19 -0
- u2mcp/health.py +67 -0
- u2mcp/helpers.py +222 -0
- u2mcp/mcp.py +172 -61
- u2mcp/middlewares.py +40 -0
- u2mcp/tools/__init__.py +8 -3
- u2mcp/tools/action.py +143 -169
- u2mcp/tools/app.py +232 -231
- u2mcp/tools/clipboard.py +35 -0
- u2mcp/tools/device.py +307 -293
- u2mcp/tools/element.py +267 -0
- u2mcp/tools/input.py +47 -0
- u2mcp/tools/misc.py +17 -0
- u2mcp/tools/scrcpy.py +142 -0
- u2mcp/{_version.py → version.py} +34 -34
- uiautomator2_mcp_server-0.2.0.dist-info/METADATA +738 -0
- uiautomator2_mcp_server-0.2.0.dist-info/RECORD +25 -0
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/WHEEL +1 -1
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/entry_points.txt +1 -0
- uiautomator2_mcp_server-0.2.0.dist-info/licenses/LICENSE +190 -0
- uiautomator2_mcp_server-0.1.2.dist-info/METADATA +0 -113
- uiautomator2_mcp_server-0.1.2.dist-info/RECORD +0 -16
- uiautomator2_mcp_server-0.1.2.dist-info/licenses/LICENSE +0 -620
- {uiautomator2_mcp_server-0.1.2.dist-info → uiautomator2_mcp_server-0.2.0.dist-info}/top_level.txt +0 -0
u2mcp/tools/element.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from base64 import b64encode
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from anyio import to_thread
|
|
8
|
+
from PIL.Image import Image
|
|
9
|
+
|
|
10
|
+
from ..mcp import mcp
|
|
11
|
+
from .device import get_device
|
|
12
|
+
|
|
13
|
+
__all__ = (
|
|
14
|
+
"activity_wait",
|
|
15
|
+
"element_wait",
|
|
16
|
+
"element_wait_gone",
|
|
17
|
+
"element_click",
|
|
18
|
+
"element_click_nowait",
|
|
19
|
+
"element_click_until_gone",
|
|
20
|
+
"element_long_click",
|
|
21
|
+
"element_screenshot",
|
|
22
|
+
"element_get_text",
|
|
23
|
+
"element_set_text",
|
|
24
|
+
"element_bounds",
|
|
25
|
+
"element_swipe",
|
|
26
|
+
"element_scroll",
|
|
27
|
+
"element_scroll_to",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@mcp.tool("activity_wait", tags={"element:wait"})
|
|
32
|
+
async def activity_wait(serial: str, activity: str, timeout: float = 20.0) -> bool:
|
|
33
|
+
"""wait activity
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
serial (str): Android device serialno
|
|
37
|
+
activity (str): name of activity
|
|
38
|
+
timeout (float): max wait time
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
bool of activity
|
|
42
|
+
"""
|
|
43
|
+
async with get_device(serial) as device:
|
|
44
|
+
# timeout: float here is actually no problem
|
|
45
|
+
return await to_thread.run_sync(device.wait_activity, activity, timeout) # type: ignore[arg-type]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@mcp.tool("element_wait", tags={"element:wait"})
|
|
49
|
+
async def element_wait(serial: str, xpath: str, timeout: float | None = None) -> bool:
|
|
50
|
+
"""
|
|
51
|
+
wait until element found
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
serial (str): Android device serialno
|
|
55
|
+
xpath (str): element xpath
|
|
56
|
+
timeout (Optional float): seconds wait element show up
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
bool: if element found
|
|
60
|
+
"""
|
|
61
|
+
async with get_device(serial) as device:
|
|
62
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).wait(timeout))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@mcp.tool("element_wait_gone", tags={"element:wait"})
|
|
66
|
+
async def element_wait_gone(serial: str, xpath: str, timeout: float | None = None) -> bool:
|
|
67
|
+
"""
|
|
68
|
+
wait until element gone
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
serial (str): Android device serialno
|
|
72
|
+
xpath (str): element xpath
|
|
73
|
+
timeout (Optional float): seconds wait element show up
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
bool: True if gone else False
|
|
77
|
+
"""
|
|
78
|
+
async with get_device(serial) as device:
|
|
79
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).wait_gone(timeout))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@mcp.tool("element_click", tags={"element:interact"})
|
|
83
|
+
async def element_click(serial: str, xpath: str, timeout: float | None = None) -> bool:
|
|
84
|
+
"""
|
|
85
|
+
find element and perform click
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
serial (str): Android device serialno
|
|
89
|
+
xpath (str): element xpath
|
|
90
|
+
timeout (Optional float): seconds wait element show up
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
bool: True if click success else False
|
|
94
|
+
"""
|
|
95
|
+
async with get_device(serial) as device:
|
|
96
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).click_exists(timeout))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@mcp.tool("element_click_nowait", tags={"element:interact"})
|
|
100
|
+
async def element_click_nowait(serial: str, xpath: str):
|
|
101
|
+
"""
|
|
102
|
+
find element and perform click
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
serial (str): Android device serialno
|
|
106
|
+
xpath (str): element xpath
|
|
107
|
+
"""
|
|
108
|
+
async with get_device(serial) as device:
|
|
109
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).click_nowait())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@mcp.tool("element_click_until_gone", tags={"element:interact"})
|
|
113
|
+
async def element_click_until_gone(serial: str, xpath: str, maxretry=10, interval=1.0) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
find element and click until element is gone
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
serial (str): Android device serialno
|
|
119
|
+
xpath (str): element xpath
|
|
120
|
+
maxretry (int): max click times
|
|
121
|
+
interval (float): sleep time between clicks
|
|
122
|
+
|
|
123
|
+
Return:
|
|
124
|
+
bool: if element is gone
|
|
125
|
+
"""
|
|
126
|
+
async with get_device(serial) as device:
|
|
127
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).click_gone(maxretry, interval))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@mcp.tool("element_long_click", tags={"element:interact"})
|
|
131
|
+
async def element_long_click(serial: str, xpath: str):
|
|
132
|
+
"""
|
|
133
|
+
find element and perform long click
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
serial (str): Android device serialno
|
|
137
|
+
xpath (str): element xpath
|
|
138
|
+
"""
|
|
139
|
+
async with get_device(serial) as device:
|
|
140
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).long_click())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@mcp.tool("element_screenshot", tags={"element:capture"})
|
|
144
|
+
async def element_screenshot(serial: str, xpath: str) -> dict[str, Any]:
|
|
145
|
+
"""
|
|
146
|
+
find element and take screenshot
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
serial (str): Android device serialno
|
|
150
|
+
xpath (str): element xpath
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
dict[str,Any]: Screenshot image JPEG data with the following keys:
|
|
154
|
+
- image (str): Base64 encoded image data in data URL format (data:image/jpeg;base64,...)
|
|
155
|
+
- size (tuple[int,int]): Image dimensions as (width, height)
|
|
156
|
+
"""
|
|
157
|
+
async with get_device(serial) as device:
|
|
158
|
+
im = await to_thread.run_sync(lambda: device.xpath(xpath).screenshot())
|
|
159
|
+
if not isinstance(im, Image):
|
|
160
|
+
raise RuntimeError("Invalid image")
|
|
161
|
+
|
|
162
|
+
with BytesIO() as fp:
|
|
163
|
+
im.save(fp, "jpeg")
|
|
164
|
+
im_data = fp.getvalue()
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
"width": im.width,
|
|
168
|
+
"height": im.height,
|
|
169
|
+
"image": "data:image/jpeg;base64," + b64encode(im_data).decode(),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@mcp.tool("element_get_text", tags={"element:query"})
|
|
174
|
+
async def element_get_text(serial: str, xpath: str) -> str | None:
|
|
175
|
+
"""
|
|
176
|
+
find and get element text
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
serial (str): Android device serialno
|
|
180
|
+
xpath (str): element xpath
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
str: string of node text
|
|
184
|
+
None: if element has no text attribute
|
|
185
|
+
"""
|
|
186
|
+
async with get_device(serial) as device:
|
|
187
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).get_text())
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@mcp.tool("element_set_text", tags={"element:modify"})
|
|
191
|
+
async def element_set_text(serial: str, xpath: str, text: str) -> None:
|
|
192
|
+
"""
|
|
193
|
+
find and set element text
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
serial (str): Android device serialno
|
|
197
|
+
xpath (str): element xpath
|
|
198
|
+
text (str): string of node text
|
|
199
|
+
"""
|
|
200
|
+
async with get_device(serial) as device:
|
|
201
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).set_text(text))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@mcp.tool("element_bounds", tags={"element:query"})
|
|
205
|
+
async def element_bounds(serial: str, xpath: str) -> tuple[int, int, int, int]:
|
|
206
|
+
"""
|
|
207
|
+
find an element and get bounds
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
serial (str): Android device serialno
|
|
211
|
+
xpath (str): element xpath
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
tuple[int]: tuple of (left, top, right, bottom)
|
|
215
|
+
"""
|
|
216
|
+
async with get_device(serial) as device:
|
|
217
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).bounds)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@mcp.tool("element_swipe", tags={"element:gesture"})
|
|
221
|
+
async def element_swipe(serial: str, xpath: str, direction: str, scale: float = 0.6):
|
|
222
|
+
"""
|
|
223
|
+
find an element and swipe
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
serial (str): Android device serialno
|
|
227
|
+
xpath (str): element xpath
|
|
228
|
+
direction: one of ["left", "right", "up", "down"]
|
|
229
|
+
scale: percent of swipe, range (0, 1.0)
|
|
230
|
+
"""
|
|
231
|
+
async with get_device(serial) as device:
|
|
232
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).swipe(direction, scale))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@mcp.tool("element_scroll", tags={"element:gesture"})
|
|
236
|
+
async def element_scroll(serial: str, xpath: str, direction: str = "forward") -> bool:
|
|
237
|
+
"""
|
|
238
|
+
find an element and scroll
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
serial (str): Android device serialno
|
|
242
|
+
xpath (str): element xpath
|
|
243
|
+
direction (str): scroll direction, one of ["forward", "backward"]
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
bool: if can be scroll again
|
|
247
|
+
"""
|
|
248
|
+
async with get_device(serial) as device:
|
|
249
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).swipe(direction))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@mcp.tool("element_scroll_to", tags={"element:gesture"})
|
|
253
|
+
async def element_scroll_to(serial: str, xpath: str, direction: str = "forward", max_swipes: int = 10):
|
|
254
|
+
"""
|
|
255
|
+
find an element and scroll to
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
serial (str): Android device serialno
|
|
259
|
+
xpath (str): element xpath
|
|
260
|
+
direction (str): scroll direction, one of ["forward", "backward"]
|
|
261
|
+
max_swipes (int): max swipe times
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
bool: if can be scroll again
|
|
265
|
+
"""
|
|
266
|
+
async with get_device(serial) as device:
|
|
267
|
+
return await to_thread.run_sync(lambda: device.xpath(xpath).scroll_to(direction, max_swipes))
|
u2mcp/tools/input.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from anyio import to_thread
|
|
4
|
+
|
|
5
|
+
from ..mcp import mcp
|
|
6
|
+
from .device import get_device
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"send_text",
|
|
10
|
+
"clear_text",
|
|
11
|
+
"hide_keyboard",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@mcp.tool("send_text", tags={"input:text"})
|
|
16
|
+
async def send_text(serial: str, text: str, clear: bool = False):
|
|
17
|
+
"""Send text to the current input field
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
serial (str): Android device serialno
|
|
21
|
+
text (str): input text
|
|
22
|
+
clear: clear text before input
|
|
23
|
+
"""
|
|
24
|
+
async with get_device(serial) as device:
|
|
25
|
+
await to_thread.run_sync(device.send_keys, text, clear)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@mcp.tool("clear_text", tags={"input:text"})
|
|
29
|
+
async def clear_text(serial: str):
|
|
30
|
+
"""Clear text in the current input field
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
serial (str): Android device serialno
|
|
34
|
+
"""
|
|
35
|
+
async with get_device(serial) as device:
|
|
36
|
+
await to_thread.run_sync(device.clear_text)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@mcp.tool("hide_keyboard", tags={"input:keyboard"})
|
|
40
|
+
async def hide_keyboard(serial: str):
|
|
41
|
+
"""Hide keyboard
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
serial (str): Android device serialno
|
|
45
|
+
"""
|
|
46
|
+
async with get_device(serial) as device:
|
|
47
|
+
await to_thread.run_sync(device.hide_keyboard)
|
u2mcp/tools/misc.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import anyio
|
|
4
|
+
|
|
5
|
+
from ..mcp import mcp
|
|
6
|
+
|
|
7
|
+
__all__ = ("delay",)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp.tool("delay", tags={"util:delay"})
|
|
11
|
+
async def delay(seconds: float):
|
|
12
|
+
"""Delay for a specific amount of time
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
seconds(float): Delay duration in seconds
|
|
16
|
+
"""
|
|
17
|
+
await anyio.sleep(seconds)
|
u2mcp/tools/scrcpy.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from anyio import create_task_group, move_on_after, open_process
|
|
6
|
+
from anyio.abc import AnyByteReceiveStream, Process
|
|
7
|
+
from anyio.streams.text import TextReceiveStream
|
|
8
|
+
from fastmcp.server.dependencies import get_context
|
|
9
|
+
from fastmcp.utilities.logging import get_logger
|
|
10
|
+
|
|
11
|
+
from ..background import get_background_task_group
|
|
12
|
+
from ..mcp import mcp
|
|
13
|
+
|
|
14
|
+
__all__ = ("start_scrcpy", "stop_scrcpy")
|
|
15
|
+
|
|
16
|
+
# Keep track of background scrcpy processes
|
|
17
|
+
_background_processes: dict[int, Process] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@mcp.tool("start_scrcpy", tags={"screen:mirror"})
|
|
21
|
+
async def start_scrcpy(serial: str = "", timeout: float = 5.0) -> int:
|
|
22
|
+
"""Startup scrcpy in background and returns process id.
|
|
23
|
+
|
|
24
|
+
scrcpy is an application mirrors Android devices (video and audio) connected via USB or TCP/IP and allows control using the computer's keyboard and mouse.
|
|
25
|
+
|
|
26
|
+
The scrcpy process will run in the background after successful startup.
|
|
27
|
+
Use stop_scrcpy to terminate the process.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
serial (str): Android device serialno. If empty string, connects to the unique device if only one device is connected.
|
|
31
|
+
timeout (float): Seconds to wait for process to confirm startup.
|
|
32
|
+
If process is still running after this time, startup is considered successful.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
int: process id (pid)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
logger = get_logger(f"{__name__}.start_scrcpy")
|
|
39
|
+
ctx = get_context()
|
|
40
|
+
|
|
41
|
+
scrcpy_path = os.environ.get("SCRCPY", "scrcpy.exe" if os.name == "nt" else "scrcpy")
|
|
42
|
+
command = [scrcpy_path]
|
|
43
|
+
if serial := serial.strip():
|
|
44
|
+
command.extend(["--serial", serial])
|
|
45
|
+
|
|
46
|
+
logger.info("start scrcpy: %s", command)
|
|
47
|
+
|
|
48
|
+
process = await open_process(command)
|
|
49
|
+
pid = process.pid
|
|
50
|
+
|
|
51
|
+
# Stream monitoring function
|
|
52
|
+
async def receive(name: str, stream: AnyByteReceiveStream):
|
|
53
|
+
logger.info("Starting to monitor %s for pid=%s", name, pid)
|
|
54
|
+
async for line in TextReceiveStream(stream):
|
|
55
|
+
logger.debug("Received %s line: %s", name, line.strip())
|
|
56
|
+
match name:
|
|
57
|
+
case "stdout":
|
|
58
|
+
await ctx.info(line)
|
|
59
|
+
case "stderr":
|
|
60
|
+
await ctx.error(line)
|
|
61
|
+
case _:
|
|
62
|
+
raise ValueError(f"Unknown stream name: {name}")
|
|
63
|
+
logger.info("Finished monitoring %s for pid=%s", name, pid)
|
|
64
|
+
|
|
65
|
+
# Monitor streams and auto-cleanup on process exit
|
|
66
|
+
async def monitor_streams():
|
|
67
|
+
if process.stdout is None:
|
|
68
|
+
raise RuntimeError("stdout is None")
|
|
69
|
+
if process.stderr is None:
|
|
70
|
+
raise RuntimeError("stderr is None")
|
|
71
|
+
try:
|
|
72
|
+
async with create_task_group() as inner_tg:
|
|
73
|
+
for name, handle in zip(("stdout", "stderr"), (process.stdout, process.stderr)):
|
|
74
|
+
inner_tg.start_soon(receive, name, handle)
|
|
75
|
+
finally:
|
|
76
|
+
# Cleanup only if we still own the process (i.e., not manually stopped)
|
|
77
|
+
if _background_processes.pop(pid, None) is process:
|
|
78
|
+
logger.info("scrcpy process exited naturally, cleaning up (pid=%s)", pid)
|
|
79
|
+
await process.aclose()
|
|
80
|
+
else:
|
|
81
|
+
logger.info("scrcpy process was manually stopped, skipping cleanup (pid=%s)", pid)
|
|
82
|
+
|
|
83
|
+
# Start monitoring immediately (before startup wait) to capture startup logs
|
|
84
|
+
tg = get_background_task_group()
|
|
85
|
+
if tg is None:
|
|
86
|
+
raise RuntimeError("Monitor task group not initialized - server not started?")
|
|
87
|
+
tg.start_soon(monitor_streams)
|
|
88
|
+
|
|
89
|
+
# Store process
|
|
90
|
+
_background_processes[pid] = process
|
|
91
|
+
|
|
92
|
+
# Startup phase: wait for process exit with timeout
|
|
93
|
+
with move_on_after(timeout) as timeout_scope:
|
|
94
|
+
await process.wait()
|
|
95
|
+
|
|
96
|
+
if timeout_scope.cancel_called:
|
|
97
|
+
# Timeout reached, process is still running - success!
|
|
98
|
+
logger.info("scrcpy started successfully in background (pid=%s)", pid)
|
|
99
|
+
else:
|
|
100
|
+
# Process exited before timeout - failure
|
|
101
|
+
_background_processes.pop(pid, None)
|
|
102
|
+
if process.returncode == 0:
|
|
103
|
+
raise RuntimeError(f"scrcpy closed during startup (pid={pid}, return code 0)")
|
|
104
|
+
else:
|
|
105
|
+
raise RuntimeError(f"scrcpy exited during startup wait with code: {process.returncode} (pid={pid})")
|
|
106
|
+
|
|
107
|
+
return pid
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@mcp.tool("stop_scrcpy", tags={"screen:mirror"})
|
|
111
|
+
async def stop_scrcpy(pid: int, timeout: float = 5.0) -> None:
|
|
112
|
+
"""Stop a running scrcpy process by pid.
|
|
113
|
+
|
|
114
|
+
scrcpy is an application mirrors Android devices (video and audio) connected via USB or TCP/IP and allows control using the computer's keyboard and mouse.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
pid (int): Process id of the scrcpy process to stop
|
|
118
|
+
timeout (float): Seconds to wait for process to terminate.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
None
|
|
122
|
+
"""
|
|
123
|
+
logger = get_logger(f"{__name__}.stop_scrcpy")
|
|
124
|
+
|
|
125
|
+
if pid not in _background_processes:
|
|
126
|
+
raise ValueError(f"No scrcpy process found with pid: {pid}")
|
|
127
|
+
|
|
128
|
+
process = _background_processes.pop(pid)
|
|
129
|
+
try:
|
|
130
|
+
process.kill()
|
|
131
|
+
# Wait for process to exit with timeout
|
|
132
|
+
with move_on_after(timeout) as timeout_scope:
|
|
133
|
+
await process.wait()
|
|
134
|
+
finally:
|
|
135
|
+
# Always close the process when manually stopping
|
|
136
|
+
# The monitor task will detect we popped the process and skip cleanup
|
|
137
|
+
await process.aclose()
|
|
138
|
+
|
|
139
|
+
if timeout_scope.cancel_called:
|
|
140
|
+
logger.warning("scrcpy process did not exit within %ss (pid=%s)", timeout, pid)
|
|
141
|
+
else:
|
|
142
|
+
logger.info("scrcpy process stopped (pid=%s)", pid)
|
u2mcp/{_version.py → version.py}
RENAMED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
# file generated by setuptools-scm
|
|
2
|
-
# don't change, don't track in version control
|
|
3
|
-
|
|
4
|
-
__all__ = [
|
|
5
|
-
"__version__",
|
|
6
|
-
"__version_tuple__",
|
|
7
|
-
"version",
|
|
8
|
-
"version_tuple",
|
|
9
|
-
"__commit_id__",
|
|
10
|
-
"commit_id",
|
|
11
|
-
]
|
|
12
|
-
|
|
13
|
-
TYPE_CHECKING = False
|
|
14
|
-
if TYPE_CHECKING:
|
|
15
|
-
from typing import Tuple
|
|
16
|
-
from typing import Union
|
|
17
|
-
|
|
18
|
-
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
-
COMMIT_ID = Union[str, None]
|
|
20
|
-
else:
|
|
21
|
-
VERSION_TUPLE = object
|
|
22
|
-
COMMIT_ID = object
|
|
23
|
-
|
|
24
|
-
version: str
|
|
25
|
-
__version__: str
|
|
26
|
-
__version_tuple__: VERSION_TUPLE
|
|
27
|
-
version_tuple: VERSION_TUPLE
|
|
28
|
-
commit_id: COMMIT_ID
|
|
29
|
-
__commit_id__: COMMIT_ID
|
|
30
|
-
|
|
31
|
-
__version__ = version = '0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
33
|
-
|
|
34
|
-
__commit_id__ = commit_id = None
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.2.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|