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.
Files changed (46) hide show
  1. voidremote/__init__.py +31 -0
  2. voidremote/adb/__init__.py +28 -0
  3. voidremote/adb/client.py +385 -0
  4. voidremote/adb/device_parser.py +286 -0
  5. voidremote/cli/__init__.py +5 -0
  6. voidremote/cli/main.py +987 -0
  7. voidremote/config/__init__.py +37 -0
  8. voidremote/config/settings.py +203 -0
  9. voidremote/controllers/__init__.py +5 -0
  10. voidremote/controllers/app_controller.py +222 -0
  11. voidremote/core/__init__.py +1 -0
  12. voidremote/core/automation.py +184 -0
  13. voidremote/models/__init__.py +31 -0
  14. voidremote/models/device.py +263 -0
  15. voidremote/network/__init__.py +1 -0
  16. voidremote/network/discovery.py +116 -0
  17. voidremote/services/__init__.py +13 -0
  18. voidremote/services/device_service.py +340 -0
  19. voidremote/services/input_service.py +181 -0
  20. voidremote/services/monitor_service.py +198 -0
  21. voidremote/ui/__init__.py +1 -0
  22. voidremote/ui/app.py +70 -0
  23. voidremote/ui/dialogs/__init__.py +6 -0
  24. voidremote/ui/dialogs/connect_dialog.py +149 -0
  25. voidremote/ui/dialogs/pair_dialog.py +189 -0
  26. voidremote/ui/main_window.py +371 -0
  27. voidremote/ui/theme.py +529 -0
  28. voidremote/ui/views/__init__.py +15 -0
  29. voidremote/ui/views/dashboard_view.py +233 -0
  30. voidremote/ui/views/files_view.py +305 -0
  31. voidremote/ui/views/monitor_view.py +236 -0
  32. voidremote/ui/views/settings_view.py +257 -0
  33. voidremote/ui/views/shell_view.py +234 -0
  34. voidremote/ui/widgets/__init__.py +14 -0
  35. voidremote/ui/widgets/device_card.py +259 -0
  36. voidremote/ui/widgets/log_view.py +159 -0
  37. voidremote/ui/widgets/metric_gauge.py +90 -0
  38. voidremote/utils/__init__.py +28 -0
  39. voidremote/utils/logging.py +120 -0
  40. voidremote/utils/security.py +216 -0
  41. voidremote-1.0.0.dist-info/METADATA +578 -0
  42. voidremote-1.0.0.dist-info/RECORD +46 -0
  43. voidremote-1.0.0.dist-info/WHEEL +5 -0
  44. voidremote-1.0.0.dist-info/entry_points.txt +5 -0
  45. voidremote-1.0.0.dist-info/licenses/LICENSE +21 -0
  46. 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__}")