voidremote 1.0.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.
- voidremote/__init__.py +31 -0
- voidremote/adb/__init__.py +28 -0
- voidremote/adb/client.py +385 -0
- voidremote/adb/device_parser.py +286 -0
- voidremote/cli/__init__.py +5 -0
- voidremote/cli/main.py +987 -0
- voidremote/config/__init__.py +37 -0
- voidremote/config/settings.py +203 -0
- voidremote/controllers/__init__.py +5 -0
- voidremote/controllers/app_controller.py +222 -0
- voidremote/core/__init__.py +1 -0
- voidremote/core/automation.py +184 -0
- voidremote/models/__init__.py +31 -0
- voidremote/models/device.py +263 -0
- voidremote/network/__init__.py +1 -0
- voidremote/network/discovery.py +116 -0
- voidremote/services/__init__.py +13 -0
- voidremote/services/device_service.py +340 -0
- voidremote/services/input_service.py +181 -0
- voidremote/services/monitor_service.py +198 -0
- voidremote/ui/__init__.py +1 -0
- voidremote/ui/app.py +70 -0
- voidremote/ui/dialogs/__init__.py +6 -0
- voidremote/ui/dialogs/connect_dialog.py +149 -0
- voidremote/ui/dialogs/pair_dialog.py +189 -0
- voidremote/ui/main_window.py +371 -0
- voidremote/ui/theme.py +529 -0
- voidremote/ui/views/__init__.py +15 -0
- voidremote/ui/views/dashboard_view.py +233 -0
- voidremote/ui/views/files_view.py +305 -0
- voidremote/ui/views/monitor_view.py +236 -0
- voidremote/ui/views/settings_view.py +257 -0
- voidremote/ui/views/shell_view.py +234 -0
- voidremote/ui/widgets/__init__.py +14 -0
- voidremote/ui/widgets/device_card.py +259 -0
- voidremote/ui/widgets/log_view.py +159 -0
- voidremote/ui/widgets/metric_gauge.py +90 -0
- voidremote/utils/__init__.py +28 -0
- voidremote/utils/logging.py +120 -0
- voidremote/utils/security.py +216 -0
- voidremote-1.0.0.dist-info/METADATA +578 -0
- voidremote-1.0.0.dist-info/RECORD +46 -0
- voidremote-1.0.0.dist-info/WHEEL +5 -0
- voidremote-1.0.0.dist-info/entry_points.txt +5 -0
- voidremote-1.0.0.dist-info/licenses/LICENSE +21 -0
- voidremote-1.0.0.dist-info/top_level.txt +1 -0
voidremote/cli/main.py
ADDED
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VoidRemote CLI - Enterprise-grade command-line interface.
|
|
3
|
+
|
|
4
|
+
Provides full control over Android devices via ADB from the terminal,
|
|
5
|
+
with colored output, JSON mode, verbose/debug flags, and rich formatting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
from rich import box
|
|
22
|
+
|
|
23
|
+
from voidremote import __version__
|
|
24
|
+
from voidremote.adb.client import AdbError, AdbNotFoundError
|
|
25
|
+
from voidremote.controllers.app_controller import AppController
|
|
26
|
+
from voidremote.models.device import Device
|
|
27
|
+
from voidremote.utils.security import validate_host, validate_port, validate_pairing_code
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
err_console = Console(stderr=True, style="bold red")
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# -------------------------------------------------------------------------
|
|
36
|
+
# Click context object
|
|
37
|
+
# -------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
class CliContext:
|
|
40
|
+
"""Shared CLI context passed to all sub-commands."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, verbose: bool, debug: bool, json_output: bool) -> None:
|
|
43
|
+
self.verbose = verbose
|
|
44
|
+
self.debug = debug
|
|
45
|
+
self.json_output = json_output
|
|
46
|
+
self._controller: Optional[AppController] = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def controller(self) -> AppController:
|
|
50
|
+
if self._controller is None:
|
|
51
|
+
from voidremote.config.settings import get_settings
|
|
52
|
+
self._controller = AppController(get_settings())
|
|
53
|
+
return self._controller
|
|
54
|
+
|
|
55
|
+
def init_controller(self) -> None:
|
|
56
|
+
"""Initialize the controller (verify ADB, start server)."""
|
|
57
|
+
try:
|
|
58
|
+
self.controller.initialize()
|
|
59
|
+
except AdbNotFoundError as exc:
|
|
60
|
+
err_console.print(f"[red]✗[/red] {exc}")
|
|
61
|
+
err_console.print(
|
|
62
|
+
"Install ADB: https://developer.android.com/tools/releases/platform-tools"
|
|
63
|
+
)
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
err_console.print(f"[red]✗ Initialization error:[/red] {exc}")
|
|
67
|
+
if self.debug:
|
|
68
|
+
raise
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
def print_json(self, data: object) -> None:
|
|
72
|
+
console.print(json.dumps(data, indent=2, default=str))
|
|
73
|
+
|
|
74
|
+
def success(self, msg: str) -> None:
|
|
75
|
+
if not self.json_output:
|
|
76
|
+
console.print(f"[green]✓[/green] {msg}")
|
|
77
|
+
|
|
78
|
+
def error(self, msg: str) -> None:
|
|
79
|
+
if not self.json_output:
|
|
80
|
+
err_console.print(f"[red]✗[/red] {msg}")
|
|
81
|
+
else:
|
|
82
|
+
console.print(json.dumps({"error": msg}))
|
|
83
|
+
|
|
84
|
+
def info(self, msg: str) -> None:
|
|
85
|
+
if not self.json_output and self.verbose:
|
|
86
|
+
console.print(f"[blue]ℹ[/blue] {msg}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# -------------------------------------------------------------------------
|
|
90
|
+
# Root group
|
|
91
|
+
# -------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
@click.group(invoke_without_command=True, context_settings={"help_option_names": ["-h", "--help"]})
|
|
94
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output.")
|
|
95
|
+
@click.option("--debug", is_flag=True, help="Enable debug logging.")
|
|
96
|
+
@click.option("--json", "json_output", is_flag=True, help="Output as JSON.")
|
|
97
|
+
@click.version_option(__version__, "-V", "--version", prog_name="VoidRemote")
|
|
98
|
+
@click.pass_context
|
|
99
|
+
def cli(ctx: click.Context, verbose: bool, debug: bool, json_output: bool) -> None:
|
|
100
|
+
"""
|
|
101
|
+
\b
|
|
102
|
+
██╗ ██╗ ██████╗ ██╗██████╗
|
|
103
|
+
██║ ██║██╔═══██╗██║██╔══██╗
|
|
104
|
+
██║ ██║██║ ██║██║██║ ██║
|
|
105
|
+
╚██╗ ██╔╝██║ ██║██║██║ ██║
|
|
106
|
+
╚████╔╝ ╚██████╔╝██║██████╔╝
|
|
107
|
+
╚═══╝ ╚═════╝ ╚═╝╚═════╝
|
|
108
|
+
|
|
109
|
+
VoidRemote — Wireless Android Remote Controller over ADB
|
|
110
|
+
by V0IDNETWORK | http://voidNetwork.ir/
|
|
111
|
+
"""
|
|
112
|
+
from voidremote.utils.logging import setup_logging
|
|
113
|
+
from voidremote.config.settings import get_settings, LOG_FILE
|
|
114
|
+
|
|
115
|
+
settings = get_settings()
|
|
116
|
+
level = "DEBUG" if debug else ("INFO" if verbose else settings.logging.level)
|
|
117
|
+
setup_logging(
|
|
118
|
+
level=level,
|
|
119
|
+
log_file=LOG_FILE if settings.logging.file_enabled else None,
|
|
120
|
+
console=(not json_output),
|
|
121
|
+
console_color=settings.logging.console_color,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
ctx.obj = CliContext(verbose=verbose, debug=debug, json_output=json_output)
|
|
125
|
+
ctx.ensure_object(CliContext)
|
|
126
|
+
|
|
127
|
+
if ctx.invoked_subcommand is None:
|
|
128
|
+
console.print(ctx.get_help())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# -------------------------------------------------------------------------
|
|
132
|
+
# devices
|
|
133
|
+
# -------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
@cli.command("devices")
|
|
136
|
+
@click.option("--refresh/--no-refresh", default=True, help="Refresh device list.")
|
|
137
|
+
@click.pass_obj
|
|
138
|
+
def cmd_devices(obj: CliContext, refresh: bool) -> None:
|
|
139
|
+
"""List all connected ADB devices."""
|
|
140
|
+
obj.init_controller()
|
|
141
|
+
devices = obj.controller.list_devices(refresh=refresh)
|
|
142
|
+
|
|
143
|
+
if obj.json_output:
|
|
144
|
+
data = [
|
|
145
|
+
{
|
|
146
|
+
"serial": d.serial,
|
|
147
|
+
"model": d.info.display_name,
|
|
148
|
+
"state": d.state.value,
|
|
149
|
+
"type": d.connection_type.value,
|
|
150
|
+
"android": d.info.android_version,
|
|
151
|
+
"battery": d.info.battery.level,
|
|
152
|
+
}
|
|
153
|
+
for d in devices
|
|
154
|
+
]
|
|
155
|
+
obj.print_json(data)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if not devices:
|
|
159
|
+
console.print("[yellow]No devices connected.[/yellow]")
|
|
160
|
+
console.print("Tip: Enable Wireless Debugging on your Android device,")
|
|
161
|
+
console.print(" then run: [bold]voidremote pair[/bold]")
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
table = Table(
|
|
165
|
+
box=box.ROUNDED,
|
|
166
|
+
show_header=True,
|
|
167
|
+
header_style="bold cyan",
|
|
168
|
+
title=f"[bold]Connected Devices ({len(devices)})[/bold]",
|
|
169
|
+
title_style="bold white",
|
|
170
|
+
)
|
|
171
|
+
table.add_column("Serial / Address", style="dim", min_width=20)
|
|
172
|
+
table.add_column("Model", min_width=22)
|
|
173
|
+
table.add_column("Android", justify="center")
|
|
174
|
+
table.add_column("Type", justify="center")
|
|
175
|
+
table.add_column("State", justify="center")
|
|
176
|
+
table.add_column("Battery", justify="right")
|
|
177
|
+
|
|
178
|
+
for dev in devices:
|
|
179
|
+
state_color = {
|
|
180
|
+
"online": "green",
|
|
181
|
+
"offline": "red",
|
|
182
|
+
"unauthorized": "yellow",
|
|
183
|
+
}.get(dev.state.value, "white")
|
|
184
|
+
|
|
185
|
+
batt = dev.info.battery.level
|
|
186
|
+
batt_color = "green" if batt > 50 else ("yellow" if batt > 20 else "red")
|
|
187
|
+
|
|
188
|
+
table.add_row(
|
|
189
|
+
dev.serial,
|
|
190
|
+
dev.info.display_name,
|
|
191
|
+
dev.info.android_version,
|
|
192
|
+
dev.connection_type.value,
|
|
193
|
+
f"[{state_color}]{dev.state.value}[/{state_color}]",
|
|
194
|
+
f"[{batt_color}]{batt}%[/{batt_color}]" if batt else "N/A",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
console.print(table)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# -------------------------------------------------------------------------
|
|
201
|
+
# pair
|
|
202
|
+
# -------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
@cli.command("pair")
|
|
205
|
+
@click.argument("host", required=False)
|
|
206
|
+
@click.argument("port", type=int, required=False)
|
|
207
|
+
@click.argument("code", required=False)
|
|
208
|
+
@click.pass_obj
|
|
209
|
+
def cmd_pair(
|
|
210
|
+
obj: CliContext,
|
|
211
|
+
host: Optional[str],
|
|
212
|
+
port: Optional[int],
|
|
213
|
+
code: Optional[str],
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Pair an Android device via Wireless Debugging.
|
|
216
|
+
|
|
217
|
+
\b
|
|
218
|
+
Usage:
|
|
219
|
+
voidremote pair # interactive mode
|
|
220
|
+
voidremote pair 192.168.1.10 37001 123456
|
|
221
|
+
"""
|
|
222
|
+
obj.init_controller()
|
|
223
|
+
|
|
224
|
+
if host is None:
|
|
225
|
+
console.print(Panel(
|
|
226
|
+
"[bold cyan]Wireless Debugging Pairing[/bold cyan]\n\n"
|
|
227
|
+
"On your Android device:\n"
|
|
228
|
+
" Settings → Developer Options → Wireless Debugging → Pair device with code",
|
|
229
|
+
title="Setup Instructions",
|
|
230
|
+
border_style="cyan",
|
|
231
|
+
))
|
|
232
|
+
host = click.prompt(" Device IP address")
|
|
233
|
+
port = click.prompt(" Pairing port (shown on screen)", type=int)
|
|
234
|
+
code = click.prompt(" 6-digit pairing code")
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
validated_host = validate_host(host)
|
|
238
|
+
validated_port = validate_port(port or 37001)
|
|
239
|
+
validated_code = validate_pairing_code(code or "")
|
|
240
|
+
except ValueError as exc:
|
|
241
|
+
obj.error(str(exc))
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
success = obj.controller.pair_device(validated_host, validated_port, validated_code)
|
|
246
|
+
if success:
|
|
247
|
+
obj.success(f"Paired with {validated_host}")
|
|
248
|
+
if obj.json_output:
|
|
249
|
+
obj.print_json({"paired": True, "host": validated_host, "port": validated_port})
|
|
250
|
+
else:
|
|
251
|
+
obj.error("Pairing failed — check IP, port, and code")
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
except AdbError as exc:
|
|
254
|
+
obj.error(f"ADB error: {exc}")
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# -------------------------------------------------------------------------
|
|
259
|
+
# connect
|
|
260
|
+
# -------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
@cli.command("connect")
|
|
263
|
+
@click.argument("host")
|
|
264
|
+
@click.argument("port", type=int, default=5555)
|
|
265
|
+
@click.option("--no-remember", is_flag=True, help="Do not save as trusted device.")
|
|
266
|
+
@click.pass_obj
|
|
267
|
+
def cmd_connect(obj: CliContext, host: str, port: int, no_remember: bool) -> None:
|
|
268
|
+
"""Connect to a device over TCP/IP.
|
|
269
|
+
|
|
270
|
+
\b
|
|
271
|
+
Usage:
|
|
272
|
+
voidremote connect 192.168.1.10
|
|
273
|
+
voidremote connect 192.168.1.10 5555
|
|
274
|
+
"""
|
|
275
|
+
obj.init_controller()
|
|
276
|
+
try:
|
|
277
|
+
device = obj.controller.connect_device(host, port, remember=not no_remember)
|
|
278
|
+
if obj.json_output:
|
|
279
|
+
obj.print_json({"connected": True, "serial": device.serial, "model": device.info.display_name})
|
|
280
|
+
else:
|
|
281
|
+
obj.success(f"Connected: {device.info.display_name} ({device.serial})")
|
|
282
|
+
except (AdbError, ValueError) as exc:
|
|
283
|
+
obj.error(str(exc))
|
|
284
|
+
sys.exit(1)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# -------------------------------------------------------------------------
|
|
288
|
+
# disconnect
|
|
289
|
+
# -------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
@cli.command("disconnect")
|
|
292
|
+
@click.argument("serial", required=False)
|
|
293
|
+
@click.pass_obj
|
|
294
|
+
def cmd_disconnect(obj: CliContext, serial: Optional[str]) -> None:
|
|
295
|
+
"""Disconnect a wireless device.
|
|
296
|
+
|
|
297
|
+
Without SERIAL, disconnects all wireless devices.
|
|
298
|
+
"""
|
|
299
|
+
obj.init_controller()
|
|
300
|
+
if serial:
|
|
301
|
+
ok = obj.controller.disconnect_device(serial)
|
|
302
|
+
if ok:
|
|
303
|
+
obj.success(f"Disconnected: {serial}")
|
|
304
|
+
else:
|
|
305
|
+
obj.error(f"Could not disconnect {serial}")
|
|
306
|
+
else:
|
|
307
|
+
obj.controller._adb.disconnect_all()
|
|
308
|
+
obj.success("Disconnected all wireless devices")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# -------------------------------------------------------------------------
|
|
312
|
+
# info
|
|
313
|
+
# -------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
@cli.command("info")
|
|
316
|
+
@click.argument("serial")
|
|
317
|
+
@click.pass_obj
|
|
318
|
+
def cmd_info(obj: CliContext, serial: str) -> None:
|
|
319
|
+
"""Show detailed information about a device."""
|
|
320
|
+
obj.init_controller()
|
|
321
|
+
devices = obj.controller.list_devices(refresh=True)
|
|
322
|
+
device = next((d for d in devices if d.serial == serial), None)
|
|
323
|
+
if device is None:
|
|
324
|
+
obj.error(f"Device not found: {serial}")
|
|
325
|
+
sys.exit(1)
|
|
326
|
+
|
|
327
|
+
if obj.json_output:
|
|
328
|
+
obj.print_json(device.model_dump(mode="json"))
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
info = device.info
|
|
332
|
+
console.print(Panel(
|
|
333
|
+
f"[bold white]{info.display_name}[/bold white]\n"
|
|
334
|
+
f" Serial: [cyan]{device.serial}[/cyan]\n"
|
|
335
|
+
f" Model: {info.model}\n"
|
|
336
|
+
f" Manufacturer: {info.manufacturer}\n"
|
|
337
|
+
f" Android: {info.android_version} (SDK {info.sdk_version})\n"
|
|
338
|
+
f" Resolution: {info.screen_resolution} @ {info.screen_density}dpi\n"
|
|
339
|
+
f" CPU ABI: {info.cpu.abi}\n"
|
|
340
|
+
f" RAM: {info.ram_total_gb:.1f} GB total\n"
|
|
341
|
+
f" Battery: {info.battery.level}% ({'charging' if info.battery.is_charging else 'discharging'}) {info.battery.temperature_celsius:.1f}°C\n"
|
|
342
|
+
f" IP Address: {info.network.ip_address or 'N/A'}\n"
|
|
343
|
+
f" Storage: {info.storage.used_gb:.1f}/{info.storage.total_gb:.1f} GB",
|
|
344
|
+
title=f"[bold cyan]Device Info[/bold cyan]",
|
|
345
|
+
border_style="cyan",
|
|
346
|
+
))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# -------------------------------------------------------------------------
|
|
350
|
+
# screenshot
|
|
351
|
+
# -------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
@cli.command("screenshot")
|
|
354
|
+
@click.argument("serial")
|
|
355
|
+
@click.argument("output", type=click.Path(), default="screenshot.png")
|
|
356
|
+
@click.pass_obj
|
|
357
|
+
def cmd_screenshot(obj: CliContext, serial: str, output: str) -> None:
|
|
358
|
+
"""Capture a screenshot from the device.
|
|
359
|
+
|
|
360
|
+
\b
|
|
361
|
+
Usage:
|
|
362
|
+
voidremote screenshot <serial>
|
|
363
|
+
voidremote screenshot <serial> /path/to/output.png
|
|
364
|
+
"""
|
|
365
|
+
obj.init_controller()
|
|
366
|
+
out_path = Path(output)
|
|
367
|
+
try:
|
|
368
|
+
obj.controller.take_screenshot(serial, out_path)
|
|
369
|
+
if obj.json_output:
|
|
370
|
+
obj.print_json({"path": str(out_path.resolve())})
|
|
371
|
+
else:
|
|
372
|
+
obj.success(f"Screenshot saved: {out_path.resolve()}")
|
|
373
|
+
except Exception as exc:
|
|
374
|
+
obj.error(str(exc))
|
|
375
|
+
sys.exit(1)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# -------------------------------------------------------------------------
|
|
379
|
+
# shell
|
|
380
|
+
# -------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
@cli.command("shell")
|
|
383
|
+
@click.argument("serial")
|
|
384
|
+
@click.argument("command", nargs=-1)
|
|
385
|
+
@click.pass_obj
|
|
386
|
+
def cmd_shell(obj: CliContext, serial: str, command: tuple[str, ...]) -> None:
|
|
387
|
+
"""Execute a shell command on the device.
|
|
388
|
+
|
|
389
|
+
\b
|
|
390
|
+
Usage:
|
|
391
|
+
voidremote shell <serial> ls /sdcard
|
|
392
|
+
voidremote shell <serial> getprop ro.build.version.release
|
|
393
|
+
"""
|
|
394
|
+
obj.init_controller()
|
|
395
|
+
if not command:
|
|
396
|
+
obj.error("No command specified")
|
|
397
|
+
sys.exit(1)
|
|
398
|
+
cmd_str = " ".join(command)
|
|
399
|
+
try:
|
|
400
|
+
output = obj.controller.shell(serial, cmd_str)
|
|
401
|
+
if obj.json_output:
|
|
402
|
+
obj.print_json({"output": output})
|
|
403
|
+
else:
|
|
404
|
+
console.print(output)
|
|
405
|
+
except AdbError as exc:
|
|
406
|
+
obj.error(str(exc))
|
|
407
|
+
sys.exit(1)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# -------------------------------------------------------------------------
|
|
411
|
+
# tap
|
|
412
|
+
# -------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
@cli.command("tap")
|
|
415
|
+
@click.argument("serial")
|
|
416
|
+
@click.argument("x", type=int)
|
|
417
|
+
@click.argument("y", type=int)
|
|
418
|
+
@click.pass_obj
|
|
419
|
+
def cmd_tap(obj: CliContext, serial: str, x: int, y: int) -> None:
|
|
420
|
+
"""Tap at screen coordinates."""
|
|
421
|
+
obj.init_controller()
|
|
422
|
+
try:
|
|
423
|
+
obj.controller.tap(serial, x, y)
|
|
424
|
+
obj.success(f"Tapped at ({x}, {y})")
|
|
425
|
+
except AdbError as exc:
|
|
426
|
+
obj.error(str(exc))
|
|
427
|
+
sys.exit(1)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# -------------------------------------------------------------------------
|
|
431
|
+
# swipe
|
|
432
|
+
# -------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
@cli.command("swipe")
|
|
435
|
+
@click.argument("serial")
|
|
436
|
+
@click.argument("x1", type=int)
|
|
437
|
+
@click.argument("y1", type=int)
|
|
438
|
+
@click.argument("x2", type=int)
|
|
439
|
+
@click.argument("y2", type=int)
|
|
440
|
+
@click.option("--duration", "-d", type=int, default=300, help="Duration in ms.")
|
|
441
|
+
@click.pass_obj
|
|
442
|
+
def cmd_swipe(
|
|
443
|
+
obj: CliContext, serial: str, x1: int, y1: int, x2: int, y2: int, duration: int
|
|
444
|
+
) -> None:
|
|
445
|
+
"""Swipe from (x1,y1) to (x2,y2)."""
|
|
446
|
+
obj.init_controller()
|
|
447
|
+
try:
|
|
448
|
+
obj.controller.swipe(serial, x1, y1, x2, y2, duration)
|
|
449
|
+
obj.success(f"Swiped ({x1},{y1}) → ({x2},{y2})")
|
|
450
|
+
except AdbError as exc:
|
|
451
|
+
obj.error(str(exc))
|
|
452
|
+
sys.exit(1)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# -------------------------------------------------------------------------
|
|
456
|
+
# text
|
|
457
|
+
# -------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
@cli.command("text")
|
|
460
|
+
@click.argument("serial")
|
|
461
|
+
@click.argument("text")
|
|
462
|
+
@click.pass_obj
|
|
463
|
+
def cmd_text(obj: CliContext, serial: str, text: str) -> None:
|
|
464
|
+
"""Type text on the device."""
|
|
465
|
+
obj.init_controller()
|
|
466
|
+
try:
|
|
467
|
+
obj.controller.type_text(serial, text)
|
|
468
|
+
obj.success(f"Typed: {text!r}")
|
|
469
|
+
except AdbError as exc:
|
|
470
|
+
obj.error(str(exc))
|
|
471
|
+
sys.exit(1)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# -------------------------------------------------------------------------
|
|
475
|
+
# keyevent
|
|
476
|
+
# -------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
@cli.command("keyevent")
|
|
479
|
+
@click.argument("serial")
|
|
480
|
+
@click.argument("keycode", type=int)
|
|
481
|
+
@click.pass_obj
|
|
482
|
+
def cmd_keyevent(obj: CliContext, serial: str, keycode: int) -> None:
|
|
483
|
+
"""Send a hardware key event (by keycode number)."""
|
|
484
|
+
obj.init_controller()
|
|
485
|
+
try:
|
|
486
|
+
obj.controller.key_event(serial, keycode)
|
|
487
|
+
obj.success(f"Key event: {keycode}")
|
|
488
|
+
except AdbError as exc:
|
|
489
|
+
obj.error(str(exc))
|
|
490
|
+
sys.exit(1)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# -------------------------------------------------------------------------
|
|
494
|
+
# install
|
|
495
|
+
# -------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
@cli.command("install")
|
|
498
|
+
@click.argument("serial")
|
|
499
|
+
@click.argument("apk", type=click.Path(exists=True))
|
|
500
|
+
@click.option("--replace/--no-replace", default=True, help="Replace existing app.")
|
|
501
|
+
@click.pass_obj
|
|
502
|
+
def cmd_install(obj: CliContext, serial: str, apk: str, replace: bool) -> None:
|
|
503
|
+
"""Install an APK on the device."""
|
|
504
|
+
obj.init_controller()
|
|
505
|
+
apk_path = Path(apk)
|
|
506
|
+
try:
|
|
507
|
+
with console.status(f"Installing {apk_path.name}…"):
|
|
508
|
+
obj.controller.install_apk(serial, apk_path, replace=replace)
|
|
509
|
+
obj.success(f"Installed: {apk_path.name}")
|
|
510
|
+
except Exception as exc:
|
|
511
|
+
obj.error(str(exc))
|
|
512
|
+
sys.exit(1)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# -------------------------------------------------------------------------
|
|
516
|
+
# uninstall
|
|
517
|
+
# -------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
@cli.command("uninstall")
|
|
520
|
+
@click.argument("serial")
|
|
521
|
+
@click.argument("package")
|
|
522
|
+
@click.pass_obj
|
|
523
|
+
def cmd_uninstall(obj: CliContext, serial: str, package: str) -> None:
|
|
524
|
+
"""Uninstall an app package from the device."""
|
|
525
|
+
obj.init_controller()
|
|
526
|
+
try:
|
|
527
|
+
obj.controller.uninstall_app(serial, package)
|
|
528
|
+
obj.success(f"Uninstalled: {package}")
|
|
529
|
+
except Exception as exc:
|
|
530
|
+
obj.error(str(exc))
|
|
531
|
+
sys.exit(1)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# -------------------------------------------------------------------------
|
|
535
|
+
# push
|
|
536
|
+
# -------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
@cli.command("push")
|
|
539
|
+
@click.argument("serial")
|
|
540
|
+
@click.argument("local", type=click.Path(exists=True))
|
|
541
|
+
@click.argument("remote")
|
|
542
|
+
@click.pass_obj
|
|
543
|
+
def cmd_push(obj: CliContext, serial: str, local: str, remote: str) -> None:
|
|
544
|
+
"""Push a file to the device."""
|
|
545
|
+
obj.init_controller()
|
|
546
|
+
try:
|
|
547
|
+
with console.status("Pushing file…"):
|
|
548
|
+
obj.controller.push_file(serial, Path(local), remote)
|
|
549
|
+
obj.success(f"Pushed: {local} → {remote}")
|
|
550
|
+
except Exception as exc:
|
|
551
|
+
obj.error(str(exc))
|
|
552
|
+
sys.exit(1)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# -------------------------------------------------------------------------
|
|
556
|
+
# pull
|
|
557
|
+
# -------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
@cli.command("pull")
|
|
560
|
+
@click.argument("serial")
|
|
561
|
+
@click.argument("remote")
|
|
562
|
+
@click.argument("local", type=click.Path(), default=".")
|
|
563
|
+
@click.pass_obj
|
|
564
|
+
def cmd_pull(obj: CliContext, serial: str, remote: str, local: str) -> None:
|
|
565
|
+
"""Pull a file from the device."""
|
|
566
|
+
obj.init_controller()
|
|
567
|
+
try:
|
|
568
|
+
with console.status("Pulling file…"):
|
|
569
|
+
obj.controller.pull_file(serial, remote, Path(local))
|
|
570
|
+
obj.success(f"Pulled: {remote} → {local}")
|
|
571
|
+
except Exception as exc:
|
|
572
|
+
obj.error(str(exc))
|
|
573
|
+
sys.exit(1)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# -------------------------------------------------------------------------
|
|
577
|
+
# reboot
|
|
578
|
+
# -------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
@cli.command("reboot")
|
|
581
|
+
@click.argument("serial")
|
|
582
|
+
@click.option(
|
|
583
|
+
"--mode",
|
|
584
|
+
type=click.Choice(["", "bootloader", "recovery"]),
|
|
585
|
+
default="",
|
|
586
|
+
help="Reboot mode.",
|
|
587
|
+
)
|
|
588
|
+
@click.pass_obj
|
|
589
|
+
def cmd_reboot(obj: CliContext, serial: str, mode: str) -> None:
|
|
590
|
+
"""Reboot the device."""
|
|
591
|
+
obj.init_controller()
|
|
592
|
+
try:
|
|
593
|
+
obj.controller.reboot(serial, mode)
|
|
594
|
+
mode_str = f" into {mode}" if mode else ""
|
|
595
|
+
obj.success(f"Device rebooting{mode_str}")
|
|
596
|
+
except AdbError as exc:
|
|
597
|
+
obj.error(str(exc))
|
|
598
|
+
sys.exit(1)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# -------------------------------------------------------------------------
|
|
602
|
+
# battery
|
|
603
|
+
# -------------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
@cli.command("battery")
|
|
606
|
+
@click.argument("serial")
|
|
607
|
+
@click.pass_obj
|
|
608
|
+
def cmd_battery(obj: CliContext, serial: str) -> None:
|
|
609
|
+
"""Show battery status."""
|
|
610
|
+
obj.init_controller()
|
|
611
|
+
try:
|
|
612
|
+
output = obj.controller.shell(serial, "dumpsys battery")
|
|
613
|
+
from voidremote.adb.device_parser import parse_battery
|
|
614
|
+
info = parse_battery(output)
|
|
615
|
+
|
|
616
|
+
if obj.json_output:
|
|
617
|
+
obj.print_json({
|
|
618
|
+
"level": info.level,
|
|
619
|
+
"charging": info.is_charging,
|
|
620
|
+
"temperature_c": info.temperature_celsius,
|
|
621
|
+
"voltage_v": info.voltage_volts,
|
|
622
|
+
"health": info.health,
|
|
623
|
+
"technology": info.technology,
|
|
624
|
+
})
|
|
625
|
+
else:
|
|
626
|
+
batt_color = "green" if info.level > 50 else ("yellow" if info.level > 20 else "red")
|
|
627
|
+
console.print(Panel(
|
|
628
|
+
f" Level: [{batt_color}]{info.level}%[/{batt_color}]\n"
|
|
629
|
+
f" Charging: {'[green]Yes[/green]' if info.is_charging else '[red]No[/red]'}\n"
|
|
630
|
+
f" Temperature: {info.temperature_celsius:.1f}°C\n"
|
|
631
|
+
f" Voltage: {info.voltage_volts:.2f}V\n"
|
|
632
|
+
f" Health: {info.health}\n"
|
|
633
|
+
f" Technology: {info.technology}",
|
|
634
|
+
title="[bold cyan]Battery[/bold cyan]",
|
|
635
|
+
border_style="cyan",
|
|
636
|
+
))
|
|
637
|
+
except Exception as exc:
|
|
638
|
+
obj.error(str(exc))
|
|
639
|
+
sys.exit(1)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# -------------------------------------------------------------------------
|
|
643
|
+
# wifi
|
|
644
|
+
# -------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
@cli.command("wifi")
|
|
647
|
+
@click.argument("serial")
|
|
648
|
+
@click.pass_obj
|
|
649
|
+
def cmd_wifi(obj: CliContext, serial: str) -> None:
|
|
650
|
+
"""Show WiFi information."""
|
|
651
|
+
obj.init_controller()
|
|
652
|
+
try:
|
|
653
|
+
ip_info = obj.controller.shell(serial, "ip addr show wlan0")
|
|
654
|
+
ssid_info = obj.controller.shell(serial, "dumpsys wifi | grep 'mWifiInfo'")
|
|
655
|
+
|
|
656
|
+
from voidremote.adb.device_parser import parse_ip_address
|
|
657
|
+
ip = parse_ip_address(ip_info)
|
|
658
|
+
|
|
659
|
+
if obj.json_output:
|
|
660
|
+
obj.print_json({"ip": ip, "wlan_info": ip_info[:500]})
|
|
661
|
+
else:
|
|
662
|
+
console.print(f"[cyan]IP Address:[/cyan] {ip or 'N/A'}")
|
|
663
|
+
console.print(f"[cyan]WLAN Info:[/cyan]\n{ip_info[:300]}")
|
|
664
|
+
except Exception as exc:
|
|
665
|
+
obj.error(str(exc))
|
|
666
|
+
sys.exit(1)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# -------------------------------------------------------------------------
|
|
670
|
+
# doctor
|
|
671
|
+
# -------------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
@cli.command("doctor")
|
|
674
|
+
@click.pass_obj
|
|
675
|
+
def cmd_doctor(obj: CliContext) -> None:
|
|
676
|
+
"""Check system setup and diagnose common issues."""
|
|
677
|
+
import shutil
|
|
678
|
+
|
|
679
|
+
console.print(Panel("[bold]VoidRemote Doctor[/bold]", border_style="cyan"))
|
|
680
|
+
|
|
681
|
+
checks: list[tuple[str, bool, str]] = []
|
|
682
|
+
|
|
683
|
+
# ADB binary
|
|
684
|
+
adb_path = shutil.which("adb")
|
|
685
|
+
checks.append((
|
|
686
|
+
"ADB binary",
|
|
687
|
+
adb_path is not None,
|
|
688
|
+
adb_path or "Not found — install Android Platform Tools",
|
|
689
|
+
))
|
|
690
|
+
|
|
691
|
+
# Python version
|
|
692
|
+
import sys
|
|
693
|
+
py_ok = sys.version_info >= (3, 12)
|
|
694
|
+
checks.append((
|
|
695
|
+
"Python version",
|
|
696
|
+
py_ok,
|
|
697
|
+
f"{sys.version.split()[0]} ({'OK' if py_ok else 'Python 3.12+ required'})",
|
|
698
|
+
))
|
|
699
|
+
|
|
700
|
+
# PySide6
|
|
701
|
+
try:
|
|
702
|
+
import PySide6
|
|
703
|
+
checks.append(("PySide6 (GUI)", True, PySide6.__version__))
|
|
704
|
+
except ImportError:
|
|
705
|
+
checks.append(("PySide6 (GUI)", False, "Not installed — pip install PySide6"))
|
|
706
|
+
|
|
707
|
+
# Rich
|
|
708
|
+
try:
|
|
709
|
+
import rich
|
|
710
|
+
rich_version = 1.1
|
|
711
|
+
checks.append(("Rich (CLI)", True, rich_version))
|
|
712
|
+
except ImportError:
|
|
713
|
+
checks.append(("Rich (CLI)", False, "Not installed"))
|
|
714
|
+
|
|
715
|
+
# ADB server
|
|
716
|
+
if adb_path:
|
|
717
|
+
try:
|
|
718
|
+
obj.init_controller()
|
|
719
|
+
version = obj.controller._adb.verify_adb()
|
|
720
|
+
checks.append(("ADB server", True, version))
|
|
721
|
+
except Exception as exc:
|
|
722
|
+
checks.append(("ADB server", False, str(exc)))
|
|
723
|
+
|
|
724
|
+
for name, ok, detail in checks:
|
|
725
|
+
icon = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
|
726
|
+
console.print(f" {icon} {name:<22} {detail}")
|
|
727
|
+
|
|
728
|
+
all_ok = all(ok for _, ok, _ in checks)
|
|
729
|
+
if all_ok:
|
|
730
|
+
console.print("\n[green bold]All checks passed! VoidRemote is ready.[/green bold]")
|
|
731
|
+
else:
|
|
732
|
+
console.print("\n[yellow]Some checks failed. Fix the issues above and re-run.[/yellow]")
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# -------------------------------------------------------------------------
|
|
736
|
+
# version
|
|
737
|
+
# -------------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
@cli.command("version")
|
|
740
|
+
@click.pass_obj
|
|
741
|
+
def cmd_version(obj: CliContext) -> None:
|
|
742
|
+
"""Show version information."""
|
|
743
|
+
from voidremote import __author__, __url__
|
|
744
|
+
if obj.json_output:
|
|
745
|
+
obj.print_json({"version": __version__, "author": __author__, "url": __url__})
|
|
746
|
+
else:
|
|
747
|
+
console.print(
|
|
748
|
+
f"[bold cyan]VoidRemote[/bold cyan] v{__version__}\n"
|
|
749
|
+
f"by {__author__} | {__url__}"
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
# -------------------------------------------------------------------------
|
|
754
|
+
# logs
|
|
755
|
+
# -------------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
@cli.command("logs")
|
|
758
|
+
@click.option("--lines", "-n", type=int, default=50, help="Number of lines to show.")
|
|
759
|
+
@click.option("--follow", "-f", is_flag=True, help="Follow log output.")
|
|
760
|
+
@click.pass_obj
|
|
761
|
+
def cmd_logs(obj: CliContext, lines: int, follow: bool) -> None:
|
|
762
|
+
"""Show application logs."""
|
|
763
|
+
from voidremote.config.settings import LOG_FILE
|
|
764
|
+
|
|
765
|
+
if not LOG_FILE.exists():
|
|
766
|
+
console.print("[yellow]No log file found.[/yellow]")
|
|
767
|
+
return
|
|
768
|
+
|
|
769
|
+
log_lines = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
770
|
+
for line in log_lines[-lines:]:
|
|
771
|
+
level = "INFO"
|
|
772
|
+
if "ERROR" in line:
|
|
773
|
+
level = "ERROR"
|
|
774
|
+
elif "WARNING" in line:
|
|
775
|
+
level = "WARNING"
|
|
776
|
+
elif "DEBUG" in line:
|
|
777
|
+
level = "DEBUG"
|
|
778
|
+
|
|
779
|
+
color = {"ERROR": "red", "WARNING": "yellow", "DEBUG": "dim"}.get(level, "white")
|
|
780
|
+
console.print(f"[{color}]{line}[/{color}]")
|
|
781
|
+
|
|
782
|
+
if follow:
|
|
783
|
+
import time
|
|
784
|
+
try:
|
|
785
|
+
while True:
|
|
786
|
+
new_lines = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
787
|
+
for line in new_lines[len(log_lines):]:
|
|
788
|
+
console.print(line)
|
|
789
|
+
log_lines = new_lines
|
|
790
|
+
time.sleep(0.5)
|
|
791
|
+
except KeyboardInterrupt:
|
|
792
|
+
pass
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# -------------------------------------------------------------------------
|
|
796
|
+
# config
|
|
797
|
+
# -------------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
@cli.command("config")
|
|
800
|
+
@click.option("--show", is_flag=True, help="Show current configuration.")
|
|
801
|
+
@click.option("--reset", is_flag=True, help="Reset to defaults.")
|
|
802
|
+
@click.pass_obj
|
|
803
|
+
def cmd_config(obj: CliContext, show: bool, reset: bool) -> None:
|
|
804
|
+
"""Manage VoidRemote configuration."""
|
|
805
|
+
from voidremote.config.settings import get_settings, save_settings, CONFIG_FILE
|
|
806
|
+
|
|
807
|
+
settings = get_settings()
|
|
808
|
+
|
|
809
|
+
if reset:
|
|
810
|
+
settings.reset_to_defaults()
|
|
811
|
+
settings.save()
|
|
812
|
+
obj.success("Configuration reset to defaults")
|
|
813
|
+
return
|
|
814
|
+
|
|
815
|
+
if show or not reset:
|
|
816
|
+
if obj.json_output:
|
|
817
|
+
obj.print_json(json.loads(settings.model_dump_json()))
|
|
818
|
+
else:
|
|
819
|
+
console.print(Panel(
|
|
820
|
+
f" Config file: [cyan]{CONFIG_FILE}[/cyan]\n"
|
|
821
|
+
f" ADB path: {settings.adb.path}\n"
|
|
822
|
+
f" Theme: {settings.ui.theme}\n"
|
|
823
|
+
f" Log level: {settings.logging.level}\n"
|
|
824
|
+
f" Debug: {settings.debug}\n"
|
|
825
|
+
f" Mirror FPS: {settings.mirror.max_fps}",
|
|
826
|
+
title="[bold cyan]Configuration[/bold cyan]",
|
|
827
|
+
border_style="cyan",
|
|
828
|
+
))
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# -------------------------------------------------------------------------
|
|
832
|
+
# Entry point
|
|
833
|
+
# -------------------------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
def main() -> None:
|
|
836
|
+
"""CLI entry point."""
|
|
837
|
+
cli(standalone_mode=True)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
if __name__ == "__main__":
|
|
841
|
+
main()
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
# -------------------------------------------------------------------------
|
|
845
|
+
# screenrecord
|
|
846
|
+
# -------------------------------------------------------------------------
|
|
847
|
+
|
|
848
|
+
@cli.command("screenrecord")
|
|
849
|
+
@click.argument("serial")
|
|
850
|
+
@click.argument("output", default="screenrecord.mp4")
|
|
851
|
+
@click.option("--time", "-t", "time_limit", type=int, default=180, help="Max duration in seconds.")
|
|
852
|
+
@click.option("--bitrate", "-b", type=int, default=8000000, help="Bitrate in bps.")
|
|
853
|
+
@click.pass_obj
|
|
854
|
+
def cmd_screenrecord(obj: CliContext, serial: str, output: str, time_limit: int, bitrate: int) -> None:
|
|
855
|
+
"""Record the device screen to a video file.
|
|
856
|
+
|
|
857
|
+
\b
|
|
858
|
+
Usage:
|
|
859
|
+
voidremote screenrecord <serial>
|
|
860
|
+
voidremote screenrecord <serial> output.mp4 --time 60
|
|
861
|
+
"""
|
|
862
|
+
obj.init_controller()
|
|
863
|
+
import time as _time
|
|
864
|
+
remote_path = "/sdcard/void_screenrecord.mp4"
|
|
865
|
+
local_path = Path(output)
|
|
866
|
+
|
|
867
|
+
if not obj.json_output:
|
|
868
|
+
console.print(f"[cyan]Recording screen…[/cyan] Press [bold]Ctrl+C[/bold] to stop.")
|
|
869
|
+
try:
|
|
870
|
+
proc = obj.controller.start_screen_record(serial, remote_path)
|
|
871
|
+
try:
|
|
872
|
+
proc.wait(timeout=time_limit)
|
|
873
|
+
except Exception:
|
|
874
|
+
proc.terminate()
|
|
875
|
+
obj.controller.pull_file(serial, remote_path, local_path)
|
|
876
|
+
obj.controller.shell(serial, f"rm {remote_path}")
|
|
877
|
+
if obj.json_output:
|
|
878
|
+
obj.print_json({"path": str(local_path.resolve())})
|
|
879
|
+
else:
|
|
880
|
+
obj.success(f"Saved: {local_path.resolve()}")
|
|
881
|
+
except KeyboardInterrupt:
|
|
882
|
+
try:
|
|
883
|
+
proc.terminate() # type: ignore[possibly-undefined]
|
|
884
|
+
except Exception:
|
|
885
|
+
pass
|
|
886
|
+
obj.controller.pull_file(serial, remote_path, local_path)
|
|
887
|
+
obj.controller.shell(serial, f"rm {remote_path}")
|
|
888
|
+
obj.success(f"Recording stopped. Saved: {local_path.resolve()}")
|
|
889
|
+
except Exception as exc:
|
|
890
|
+
obj.error(str(exc))
|
|
891
|
+
sys.exit(1)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
# -------------------------------------------------------------------------
|
|
895
|
+
# clipboard
|
|
896
|
+
# -------------------------------------------------------------------------
|
|
897
|
+
|
|
898
|
+
@cli.command("clipboard")
|
|
899
|
+
@click.argument("serial")
|
|
900
|
+
@click.argument("text")
|
|
901
|
+
@click.pass_obj
|
|
902
|
+
def cmd_clipboard(obj: CliContext, serial: str, text: str) -> None:
|
|
903
|
+
"""Set clipboard content on the device and paste it."""
|
|
904
|
+
obj.init_controller()
|
|
905
|
+
try:
|
|
906
|
+
obj.controller.shell(serial, f"am broadcast -a clipper.set -e text '{text}'")
|
|
907
|
+
obj.controller.key_event(serial, 279) # KEYCODE_PASTE
|
|
908
|
+
if obj.json_output:
|
|
909
|
+
obj.print_json({"pasted": True})
|
|
910
|
+
else:
|
|
911
|
+
obj.success(f"Clipboard set and pasted: {text!r}")
|
|
912
|
+
except Exception as exc:
|
|
913
|
+
obj.error(str(exc))
|
|
914
|
+
sys.exit(1)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
# -------------------------------------------------------------------------
|
|
918
|
+
# mirror (launches scrcpy if available, else advises)
|
|
919
|
+
# -------------------------------------------------------------------------
|
|
920
|
+
|
|
921
|
+
@cli.command("mirror")
|
|
922
|
+
@click.argument("serial")
|
|
923
|
+
@click.option("--fps", type=int, default=30, help="Maximum FPS.")
|
|
924
|
+
@click.option("--bitrate", "-b", default="8M", help="Bitrate (e.g. 8M).")
|
|
925
|
+
@click.option("--fullscreen", "-f", is_flag=True, help="Launch fullscreen.")
|
|
926
|
+
@click.pass_obj
|
|
927
|
+
def cmd_mirror(obj: CliContext, serial: str, fps: int, bitrate: str, fullscreen: bool) -> None:
|
|
928
|
+
"""Mirror the device screen (requires scrcpy or VoidRemote GUI).
|
|
929
|
+
|
|
930
|
+
\b
|
|
931
|
+
Usage:
|
|
932
|
+
voidremote mirror <serial>
|
|
933
|
+
voidremote mirror <serial> --fps 60 --bitrate 16M
|
|
934
|
+
"""
|
|
935
|
+
import shutil
|
|
936
|
+
scrcpy = shutil.which("scrcpy")
|
|
937
|
+
if scrcpy:
|
|
938
|
+
cmd_parts = [scrcpy, "-s", serial, f"--max-fps={fps}", f"--video-bit-rate={bitrate}"]
|
|
939
|
+
if fullscreen:
|
|
940
|
+
cmd_parts.append("--fullscreen")
|
|
941
|
+
if not obj.json_output:
|
|
942
|
+
console.print(f"[cyan]Launching scrcpy…[/cyan]")
|
|
943
|
+
import subprocess
|
|
944
|
+
subprocess.run(cmd_parts)
|
|
945
|
+
else:
|
|
946
|
+
if obj.json_output:
|
|
947
|
+
obj.print_json({"error": "scrcpy not found", "hint": "Install scrcpy or use the VoidRemote GUI"})
|
|
948
|
+
else:
|
|
949
|
+
console.print(
|
|
950
|
+
"[yellow]scrcpy not found.[/yellow]\n"
|
|
951
|
+
"Install scrcpy for CLI mirroring: https://github.com/Genymobile/scrcpy\n"
|
|
952
|
+
"Or launch the VoidRemote GUI: [bold]voidremote-gui[/bold]"
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
# -------------------------------------------------------------------------
|
|
957
|
+
# update
|
|
958
|
+
# -------------------------------------------------------------------------
|
|
959
|
+
|
|
960
|
+
@cli.command("update")
|
|
961
|
+
@click.option("--check-only", is_flag=True, help="Only check, do not install.")
|
|
962
|
+
@click.pass_obj
|
|
963
|
+
def cmd_update(obj: CliContext, check_only: bool) -> None:
|
|
964
|
+
"""Check for VoidRemote updates."""
|
|
965
|
+
from voidremote import __version__
|
|
966
|
+
try:
|
|
967
|
+
import requests as _req
|
|
968
|
+
resp = _req.get(
|
|
969
|
+
"https://api.github.com/repos/V0IDNETWORK/VoidRemote/releases/latest",
|
|
970
|
+
timeout=5,
|
|
971
|
+
)
|
|
972
|
+
resp.raise_for_status()
|
|
973
|
+
latest = resp.json().get("tag_name", "").lstrip("v")
|
|
974
|
+
if obj.json_output:
|
|
975
|
+
obj.print_json({"current": __version__, "latest": latest, "update_available": latest != __version__})
|
|
976
|
+
else:
|
|
977
|
+
if latest and latest != __version__:
|
|
978
|
+
console.print(f"[yellow]Update available:[/yellow] v{__version__} → v{latest}")
|
|
979
|
+
console.print("Run: [bold]pip install --upgrade voidremote[/bold]")
|
|
980
|
+
else:
|
|
981
|
+
console.print(f"[green]✓ Up to date:[/green] VoidRemote v{__version__}")
|
|
982
|
+
except Exception as exc:
|
|
983
|
+
if obj.json_output:
|
|
984
|
+
obj.print_json({"current": __version__, "error": str(exc)})
|
|
985
|
+
else:
|
|
986
|
+
console.print(f"[dim]Could not check for updates: {exc}[/dim]")
|
|
987
|
+
console.print(f"Current version: VoidRemote v{__version__}")
|