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
@@ -0,0 +1,90 @@
1
+ """Circular gauge widget for CPU/RAM/Battery monitoring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Optional
7
+
8
+ from PySide6.QtCore import Qt, QRectF
9
+ from PySide6.QtGui import QColor, QPainter, QPen, QFont, QBrush
10
+ from PySide6.QtWidgets import QWidget
11
+
12
+ from voidremote.ui.theme import Colors
13
+
14
+
15
+ class MetricGauge(QWidget):
16
+ """
17
+ Circular progress gauge for displaying a percentage metric.
18
+
19
+ Shows value, label, and color-coded arc.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ label: str = "",
25
+ color: str = Colors.ACCENT,
26
+ parent: Optional[QWidget] = None,
27
+ ) -> None:
28
+ super().__init__(parent)
29
+ self._label = label
30
+ self._color = color
31
+ self._value = 0.0
32
+ self._suffix = "%"
33
+ self.setMinimumSize(100, 100)
34
+
35
+ def set_value(self, value: float) -> None:
36
+ self._value = max(0.0, min(100.0, value))
37
+ self.update()
38
+
39
+ def set_label(self, label: str) -> None:
40
+ self._label = label
41
+ self.update()
42
+
43
+ def set_suffix(self, suffix: str) -> None:
44
+ self._suffix = suffix
45
+ self.update()
46
+
47
+ def paintEvent(self, event: object) -> None: # type: ignore[override]
48
+ painter = QPainter(self)
49
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
50
+
51
+ w = self.width()
52
+ h = self.height()
53
+ size = min(w, h)
54
+ margin = size * 0.1
55
+ arc_rect = QRectF(margin, margin, size - 2 * margin, size - 2 * margin)
56
+
57
+ # Background track
58
+ pen = QPen(QColor(Colors.BG_ELEVATED))
59
+ pen.setWidth(int(size * 0.08))
60
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
61
+ painter.setPen(pen)
62
+ painter.drawArc(arc_rect, 225 * 16, -270 * 16)
63
+
64
+ # Value arc
65
+ if self._value > 0:
66
+ val_color = QColor(self._color)
67
+ val_pen = QPen(val_color)
68
+ val_pen.setWidth(int(size * 0.08))
69
+ val_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
70
+ painter.setPen(val_pen)
71
+ span = int(-270 * 16 * self._value / 100.0)
72
+ painter.drawArc(arc_rect, 225 * 16, span)
73
+
74
+ # Center text — value
75
+ painter.setPen(QPen(QColor(Colors.TEXT_PRIMARY)))
76
+ value_font = QFont("Inter, Segoe UI", int(size * 0.2), QFont.Weight.Bold)
77
+ painter.setFont(value_font)
78
+ center_rect = QRectF(0, h * 0.3, w, h * 0.3)
79
+ painter.drawText(
80
+ center_rect,
81
+ Qt.AlignmentFlag.AlignCenter,
82
+ f"{self._value:.0f}{self._suffix}",
83
+ )
84
+
85
+ # Label below value
86
+ painter.setPen(QPen(QColor(Colors.TEXT_MUTED)))
87
+ label_font = QFont("Inter, Segoe UI", int(size * 0.1))
88
+ painter.setFont(label_font)
89
+ label_rect = QRectF(0, h * 0.58, w, h * 0.2)
90
+ painter.drawText(label_rect, Qt.AlignmentFlag.AlignCenter, self._label)
@@ -0,0 +1,28 @@
1
+ """VoidRemote utility modules."""
2
+
3
+ from voidremote.utils.logging import get_logger, setup_logging
4
+ from voidremote.utils.security import (
5
+ sanitize_shell_arg,
6
+ sanitize_shell_args,
7
+ validate_device_path,
8
+ validate_host,
9
+ validate_ip_address,
10
+ validate_local_path,
11
+ validate_package_name,
12
+ validate_pairing_code,
13
+ validate_port,
14
+ )
15
+
16
+ __all__ = [
17
+ "get_logger",
18
+ "setup_logging",
19
+ "sanitize_shell_arg",
20
+ "sanitize_shell_args",
21
+ "validate_device_path",
22
+ "validate_host",
23
+ "validate_ip_address",
24
+ "validate_local_path",
25
+ "validate_package_name",
26
+ "validate_pairing_code",
27
+ "validate_port",
28
+ ]
@@ -0,0 +1,120 @@
1
+ """
2
+ Structured logging setup for VoidRemote.
3
+
4
+ Configures rotating file logs and colored console output with
5
+ consistent formatting across the entire application.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import logging.handlers
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ # ANSI color codes for terminal output
17
+ COLORS = {
18
+ "RESET": "\033[0m",
19
+ "BOLD": "\033[1m",
20
+ "DIM": "\033[2m",
21
+ "RED": "\033[91m",
22
+ "GREEN": "\033[92m",
23
+ "YELLOW": "\033[93m",
24
+ "BLUE": "\033[94m",
25
+ "MAGENTA": "\033[95m",
26
+ "CYAN": "\033[96m",
27
+ "WHITE": "\033[97m",
28
+ "GREY": "\033[90m",
29
+ }
30
+
31
+ LEVEL_COLORS: dict[int, str] = {
32
+ logging.DEBUG: COLORS["CYAN"],
33
+ logging.INFO: COLORS["GREEN"],
34
+ logging.WARNING: COLORS["YELLOW"],
35
+ logging.ERROR: COLORS["RED"],
36
+ logging.CRITICAL: COLORS["MAGENTA"] + COLORS["BOLD"],
37
+ }
38
+
39
+
40
+ class ColoredFormatter(logging.Formatter):
41
+ """Log formatter that adds ANSI color to console output."""
42
+
43
+ def __init__(self, fmt: Optional[str] = None, use_color: bool = True) -> None:
44
+ super().__init__(fmt)
45
+ self.use_color = use_color and sys.stdout.isatty()
46
+
47
+ def format(self, record: logging.LogRecord) -> str:
48
+ original_levelname = record.levelname
49
+ if self.use_color:
50
+ color = LEVEL_COLORS.get(record.levelno, COLORS["RESET"])
51
+ record.levelname = f"{color}{record.levelname:<8}{COLORS['RESET']}"
52
+ record.name = f"{COLORS['BLUE']}{record.name}{COLORS['RESET']}"
53
+ result = super().format(record)
54
+ record.levelname = original_levelname
55
+ return result
56
+
57
+
58
+ def setup_logging(
59
+ level: str = "INFO",
60
+ log_file: Optional[Path] = None,
61
+ max_bytes: int = 10 * 1024 * 1024,
62
+ backup_count: int = 5,
63
+ console: bool = True,
64
+ console_color: bool = True,
65
+ fmt: Optional[str] = None,
66
+ ) -> None:
67
+ """
68
+ Configure application-wide logging.
69
+
70
+ Args:
71
+ level: Log level string (DEBUG, INFO, WARNING, ERROR, CRITICAL).
72
+ log_file: Optional path to write rotating log file.
73
+ max_bytes: Max size of each log file before rotation.
74
+ backup_count: Number of backup log files to keep.
75
+ console: Whether to output logs to stderr.
76
+ console_color: Whether to colorize console output.
77
+ fmt: Custom log format string.
78
+ """
79
+ root = logging.getLogger()
80
+ root.setLevel(getattr(logging, level.upper(), logging.INFO))
81
+
82
+ # Remove existing handlers to avoid duplicates on re-init
83
+ root.handlers.clear()
84
+
85
+ log_format = fmt or "%(asctime)s | %(levelname)-8s | %(name)-30s | %(message)s"
86
+ date_format = "%Y-%m-%d %H:%M:%S"
87
+
88
+ if console:
89
+ console_handler = logging.StreamHandler(sys.stderr)
90
+ console_handler.setFormatter(
91
+ ColoredFormatter(fmt=log_format, use_color=console_color)
92
+ )
93
+ root.addHandler(console_handler)
94
+
95
+ if log_file is not None:
96
+ log_file.parent.mkdir(parents=True, exist_ok=True)
97
+ file_handler = logging.handlers.RotatingFileHandler(
98
+ log_file,
99
+ maxBytes=max_bytes,
100
+ backupCount=backup_count,
101
+ encoding="utf-8",
102
+ )
103
+ file_handler.setFormatter(
104
+ logging.Formatter(fmt=log_format, datefmt=date_format)
105
+ )
106
+ root.addHandler(file_handler)
107
+
108
+ # Silence noisy third-party loggers
109
+ logging.getLogger("PIL").setLevel(logging.WARNING)
110
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
111
+ logging.getLogger("adb_shell").setLevel(logging.WARNING)
112
+
113
+ logging.getLogger(__name__).debug(
114
+ "Logging configured: level=%s, file=%s", level, log_file
115
+ )
116
+
117
+
118
+ def get_logger(name: str) -> logging.Logger:
119
+ """Return a logger for the given module name."""
120
+ return logging.getLogger(name)
@@ -0,0 +1,216 @@
1
+ """
2
+ Security utilities for VoidRemote.
3
+
4
+ Input validation, command injection prevention, and safe operations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import re
11
+ import shlex
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+
16
+ # Characters that must not appear in ADB shell arguments
17
+ _SHELL_INJECTION_PATTERN = re.compile(r"[;&|`$<>\\]")
18
+ _SAFE_PACKAGE_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9._]{1,255}$")
19
+ _SAFE_FILENAME_PATTERN = re.compile(r'^[a-zA-Z0-9_\-. ()]+$')
20
+ _PORT_RE = re.compile(r"^\d{1,5}$")
21
+
22
+
23
+ def validate_ip_address(address: str) -> str:
24
+ """
25
+ Validate that a string is a valid IPv4 or IPv6 address.
26
+
27
+ Args:
28
+ address: IP address string to validate.
29
+
30
+ Returns:
31
+ The validated address string.
32
+
33
+ Raises:
34
+ ValueError: If the address is not valid.
35
+ """
36
+ try:
37
+ parsed = ipaddress.ip_address(address)
38
+ return str(parsed)
39
+ except ValueError as exc:
40
+ raise ValueError(f"Invalid IP address: {address!r}") from exc
41
+
42
+
43
+ def validate_host(host: str) -> str:
44
+ """
45
+ Validate a host (IP or hostname) for use in ADB connections.
46
+
47
+ Args:
48
+ host: Hostname or IP address.
49
+
50
+ Returns:
51
+ Stripped, validated host string.
52
+
53
+ Raises:
54
+ ValueError: If host is empty or contains invalid characters.
55
+ """
56
+ host = host.strip()
57
+ if not host:
58
+ raise ValueError("Host cannot be empty")
59
+ if len(host) > 253:
60
+ raise ValueError("Hostname too long")
61
+ # Try IP address first
62
+ try:
63
+ return validate_ip_address(host)
64
+ except ValueError:
65
+ pass
66
+ # Validate hostname (RFC 1123)
67
+ hostname_re = re.compile(
68
+ r"^(?:[a-zA-Z0-9]"
69
+ r"(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
70
+ r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)?$"
71
+ )
72
+ if not hostname_re.match(host):
73
+ raise ValueError(f"Invalid hostname: {host!r}")
74
+ return host
75
+
76
+
77
+ def validate_port(port: int | str) -> int:
78
+ """
79
+ Validate a network port number.
80
+
81
+ Args:
82
+ port: Port as integer or string.
83
+
84
+ Returns:
85
+ Validated port as integer.
86
+
87
+ Raises:
88
+ ValueError: If port is out of range.
89
+ """
90
+ try:
91
+ p = int(port)
92
+ except (TypeError, ValueError) as exc:
93
+ raise ValueError(f"Port must be an integer, got: {port!r}") from exc
94
+ if not (1 <= p <= 65535):
95
+ raise ValueError(f"Port must be 1-65535, got: {p}")
96
+ return p
97
+
98
+
99
+ def validate_pairing_code(code: str) -> str:
100
+ """
101
+ Validate an ADB wireless pairing code.
102
+
103
+ Pairing codes are typically 6 digits.
104
+
105
+ Args:
106
+ code: Pairing code string.
107
+
108
+ Returns:
109
+ Validated code (stripped).
110
+
111
+ Raises:
112
+ ValueError: If code format is invalid.
113
+ """
114
+ code = code.strip()
115
+ if not code:
116
+ raise ValueError("Pairing code cannot be empty")
117
+ if not re.match(r"^\d{6}$", code):
118
+ raise ValueError("Pairing code must be exactly 6 digits")
119
+ return code
120
+
121
+
122
+ def sanitize_shell_arg(arg: str) -> str:
123
+ """
124
+ Sanitize a single argument intended for ADB shell execution.
125
+
126
+ Raises ValueError if the argument contains injection characters.
127
+
128
+ Args:
129
+ arg: Shell argument to sanitize.
130
+
131
+ Returns:
132
+ Shell-quoted safe string.
133
+
134
+ Raises:
135
+ ValueError: If arg contains shell metacharacters.
136
+ """
137
+ if _SHELL_INJECTION_PATTERN.search(arg):
138
+ raise ValueError(
139
+ f"Shell argument contains forbidden characters: {arg!r}"
140
+ )
141
+ return shlex.quote(arg)
142
+
143
+
144
+ def sanitize_shell_args(args: list[str]) -> list[str]:
145
+ """Sanitize a list of shell arguments."""
146
+ return [sanitize_shell_arg(a) for a in args]
147
+
148
+
149
+ def validate_package_name(package: str) -> str:
150
+ """
151
+ Validate an Android package name.
152
+
153
+ Args:
154
+ package: Android package name (e.g. com.example.app).
155
+
156
+ Returns:
157
+ Validated package name.
158
+
159
+ Raises:
160
+ ValueError: If package name is invalid.
161
+ """
162
+ package = package.strip()
163
+ if not _SAFE_PACKAGE_PATTERN.match(package):
164
+ raise ValueError(f"Invalid Android package name: {package!r}")
165
+ return package
166
+
167
+
168
+ def validate_device_path(path: str) -> str:
169
+ """
170
+ Validate a path on the Android device.
171
+
172
+ Ensures it's an absolute path without null bytes or parent traversal tricks.
173
+
174
+ Args:
175
+ path: Absolute device path.
176
+
177
+ Returns:
178
+ Validated path string.
179
+
180
+ Raises:
181
+ ValueError: If path is unsafe.
182
+ """
183
+ if not path.startswith("/"):
184
+ raise ValueError(f"Device path must be absolute, got: {path!r}")
185
+ if "\x00" in path:
186
+ raise ValueError("Device path contains null byte")
187
+ # Normalize to remove // or trailing /
188
+ p = Path(path)
189
+ normalized = str(p)
190
+ if ".." in normalized.split("/"):
191
+ raise ValueError(f"Device path contains parent traversal: {path!r}")
192
+ return normalized
193
+
194
+
195
+ def validate_local_path(path: Path) -> Path:
196
+ """
197
+ Validate a local file system path.
198
+
199
+ Args:
200
+ path: Local path to validate.
201
+
202
+ Returns:
203
+ Resolved path.
204
+
205
+ Raises:
206
+ ValueError: If path is unsafe or doesn't exist for reading.
207
+ """
208
+ resolved = path.resolve()
209
+ if "\x00" in str(resolved):
210
+ raise ValueError("Path contains null byte")
211
+ return resolved
212
+
213
+
214
+ def is_safe_filename(name: str) -> bool:
215
+ """Return True if name is safe for use as a file/directory name."""
216
+ return bool(_SAFE_FILENAME_PATTERN.match(name)) and ".." not in name