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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""VoidRemote configuration management."""
|
|
2
|
+
|
|
3
|
+
from voidremote.config.settings import (
|
|
4
|
+
AdbSettings,
|
|
5
|
+
AppSettings,
|
|
6
|
+
CACHE_DIR,
|
|
7
|
+
CONFIG_DIR,
|
|
8
|
+
CONFIG_FILE,
|
|
9
|
+
DATA_DIR,
|
|
10
|
+
LOG_DIR,
|
|
11
|
+
LOG_FILE,
|
|
12
|
+
LoggingSettings,
|
|
13
|
+
MirrorSettings,
|
|
14
|
+
TRUSTED_DEVICES_FILE,
|
|
15
|
+
UISettings,
|
|
16
|
+
ensure_dirs,
|
|
17
|
+
get_settings,
|
|
18
|
+
save_settings,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AdbSettings",
|
|
23
|
+
"AppSettings",
|
|
24
|
+
"CACHE_DIR",
|
|
25
|
+
"CONFIG_DIR",
|
|
26
|
+
"CONFIG_FILE",
|
|
27
|
+
"DATA_DIR",
|
|
28
|
+
"LOG_DIR",
|
|
29
|
+
"LOG_FILE",
|
|
30
|
+
"LoggingSettings",
|
|
31
|
+
"MirrorSettings",
|
|
32
|
+
"TRUSTED_DEVICES_FILE",
|
|
33
|
+
"UISettings",
|
|
34
|
+
"ensure_dirs",
|
|
35
|
+
"get_settings",
|
|
36
|
+
"save_settings",
|
|
37
|
+
]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Application settings and configuration management.
|
|
3
|
+
|
|
4
|
+
Uses pydantic-settings for type-safe, validated configuration with
|
|
5
|
+
environment variable support and JSON persistence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
import appdirs
|
|
16
|
+
from pydantic import Field, field_validator
|
|
17
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
APP_NAME = "VoidRemote"
|
|
22
|
+
APP_AUTHOR = "V0IDNETWORK"
|
|
23
|
+
|
|
24
|
+
# Platform-aware config directory
|
|
25
|
+
CONFIG_DIR = Path(appdirs.user_config_dir(APP_NAME, APP_AUTHOR))
|
|
26
|
+
DATA_DIR = Path(appdirs.user_data_dir(APP_NAME, APP_AUTHOR))
|
|
27
|
+
LOG_DIR = Path(appdirs.user_log_dir(APP_NAME, APP_AUTHOR))
|
|
28
|
+
CACHE_DIR = Path(appdirs.user_cache_dir(APP_NAME, APP_AUTHOR))
|
|
29
|
+
|
|
30
|
+
CONFIG_FILE = CONFIG_DIR / "settings.json"
|
|
31
|
+
TRUSTED_DEVICES_FILE = DATA_DIR / "trusted_devices.json"
|
|
32
|
+
LOG_FILE = LOG_DIR / "voidremote.log"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AdbSettings(BaseSettings):
|
|
36
|
+
"""ADB-specific configuration."""
|
|
37
|
+
|
|
38
|
+
model_config = SettingsConfigDict(env_prefix="VOIDREMOTE_ADB_")
|
|
39
|
+
|
|
40
|
+
path: str = Field(default="adb", description="Path to the ADB binary")
|
|
41
|
+
default_port: int = Field(default=5555, ge=1, le=65535)
|
|
42
|
+
connection_timeout: float = Field(default=10.0, gt=0)
|
|
43
|
+
command_timeout: float = Field(default=30.0, gt=0)
|
|
44
|
+
retry_count: int = Field(default=3, ge=0)
|
|
45
|
+
retry_delay: float = Field(default=1.0, ge=0)
|
|
46
|
+
server_port: int = Field(default=5037, ge=1, le=65535)
|
|
47
|
+
auto_start_server: bool = Field(default=True)
|
|
48
|
+
kill_server_on_exit: bool = Field(default=False)
|
|
49
|
+
|
|
50
|
+
@field_validator("path")
|
|
51
|
+
@classmethod
|
|
52
|
+
def validate_adb_path(cls, v: str) -> str:
|
|
53
|
+
"""Validate that adb path is non-empty."""
|
|
54
|
+
if not v.strip():
|
|
55
|
+
return "adb"
|
|
56
|
+
return v.strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class UISettings(BaseSettings):
|
|
60
|
+
"""UI / appearance configuration."""
|
|
61
|
+
|
|
62
|
+
model_config = SettingsConfigDict(env_prefix="VOIDREMOTE_UI_")
|
|
63
|
+
|
|
64
|
+
theme: str = Field(default="dark", pattern="^(dark|light|auto)$")
|
|
65
|
+
language: str = Field(default="en")
|
|
66
|
+
font_family: str = Field(default="Inter")
|
|
67
|
+
font_size: int = Field(default=13, ge=8, le=24)
|
|
68
|
+
window_width: int = Field(default=1280, ge=800)
|
|
69
|
+
window_height: int = Field(default=800, ge=600)
|
|
70
|
+
window_maximized: bool = Field(default=False)
|
|
71
|
+
sidebar_width: int = Field(default=220, ge=160, le=400)
|
|
72
|
+
show_statusbar: bool = Field(default=True)
|
|
73
|
+
show_toolbar: bool = Field(default=True)
|
|
74
|
+
animations_enabled: bool = Field(default=True)
|
|
75
|
+
notifications_enabled: bool = Field(default=True)
|
|
76
|
+
tray_icon_enabled: bool = Field(default=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MirrorSettings(BaseSettings):
|
|
80
|
+
"""Screen mirroring configuration."""
|
|
81
|
+
|
|
82
|
+
model_config = SettingsConfigDict(env_prefix="VOIDREMOTE_MIRROR_")
|
|
83
|
+
|
|
84
|
+
max_fps: int = Field(default=30, ge=1, le=120)
|
|
85
|
+
max_bitrate_mbps: int = Field(default=8, ge=1, le=64)
|
|
86
|
+
max_width: int = Field(default=1920, ge=320)
|
|
87
|
+
max_height: int = Field(default=1080, ge=240)
|
|
88
|
+
encoder: str = Field(default="h264")
|
|
89
|
+
orientation_lock: bool = Field(default=False)
|
|
90
|
+
show_touches: bool = Field(default=False)
|
|
91
|
+
stay_awake: bool = Field(default=True)
|
|
92
|
+
turn_screen_off: bool = Field(default=False)
|
|
93
|
+
always_on_top: bool = Field(default=False)
|
|
94
|
+
fullscreen: bool = Field(default=False)
|
|
95
|
+
record_path: Optional[str] = Field(default=None)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LoggingSettings(BaseSettings):
|
|
99
|
+
"""Logging configuration."""
|
|
100
|
+
|
|
101
|
+
model_config = SettingsConfigDict(env_prefix="VOIDREMOTE_LOG_")
|
|
102
|
+
|
|
103
|
+
level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$")
|
|
104
|
+
file_enabled: bool = Field(default=True)
|
|
105
|
+
file_path: Optional[str] = Field(default=None)
|
|
106
|
+
max_file_size_mb: int = Field(default=10, ge=1, le=100)
|
|
107
|
+
backup_count: int = Field(default=5, ge=0, le=20)
|
|
108
|
+
format: str = Field(
|
|
109
|
+
default="%(asctime)s | %(levelname)-8s | %(name)-30s | %(message)s"
|
|
110
|
+
)
|
|
111
|
+
console_enabled: bool = Field(default=True)
|
|
112
|
+
console_color: bool = Field(default=True)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class KeyboardShortcuts(BaseSettings):
|
|
116
|
+
"""Keyboard shortcut bindings."""
|
|
117
|
+
|
|
118
|
+
model_config = SettingsConfigDict(env_prefix="VOIDREMOTE_KEY_")
|
|
119
|
+
|
|
120
|
+
connect_device: str = Field(default="Ctrl+Shift+C")
|
|
121
|
+
disconnect_device: str = Field(default="Ctrl+Shift+D")
|
|
122
|
+
screenshot: str = Field(default="Ctrl+Shift+S")
|
|
123
|
+
start_mirror: str = Field(default="Ctrl+Shift+M")
|
|
124
|
+
open_shell: str = Field(default="Ctrl+Shift+T")
|
|
125
|
+
open_files: str = Field(default="Ctrl+Shift+F")
|
|
126
|
+
refresh_devices: str = Field(default="F5")
|
|
127
|
+
toggle_fullscreen: str = Field(default="F11")
|
|
128
|
+
quit: str = Field(default="Ctrl+Q")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class AppSettings(BaseSettings):
|
|
132
|
+
"""Root application settings."""
|
|
133
|
+
|
|
134
|
+
model_config = SettingsConfigDict(
|
|
135
|
+
env_prefix="VOIDREMOTE_",
|
|
136
|
+
env_nested_delimiter="__",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
app_name: str = Field(default="VoidRemote")
|
|
140
|
+
version: str = Field(default="1.0.0")
|
|
141
|
+
debug: bool = Field(default=False)
|
|
142
|
+
first_run: bool = Field(default=True)
|
|
143
|
+
check_updates: bool = Field(default=True)
|
|
144
|
+
update_channel: str = Field(default="stable", pattern="^(stable|beta|nightly)$")
|
|
145
|
+
telemetry_enabled: bool = Field(default=False)
|
|
146
|
+
|
|
147
|
+
adb: AdbSettings = Field(default_factory=AdbSettings)
|
|
148
|
+
ui: UISettings = Field(default_factory=UISettings)
|
|
149
|
+
mirror: MirrorSettings = Field(default_factory=MirrorSettings)
|
|
150
|
+
logging: LoggingSettings = Field(default_factory=LoggingSettings)
|
|
151
|
+
shortcuts: KeyboardShortcuts = Field(default_factory=KeyboardShortcuts)
|
|
152
|
+
|
|
153
|
+
def save(self, path: Optional[Path] = None) -> None:
|
|
154
|
+
"""Persist settings to JSON file."""
|
|
155
|
+
target = path or CONFIG_FILE
|
|
156
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
with open(target, "w", encoding="utf-8") as fh:
|
|
158
|
+
fh.write(self.model_dump_json(indent=2))
|
|
159
|
+
logger.debug("Settings saved to %s", target)
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def load(cls, path: Optional[Path] = None) -> "AppSettings":
|
|
163
|
+
"""Load settings from JSON file, falling back to defaults."""
|
|
164
|
+
target = path or CONFIG_FILE
|
|
165
|
+
if target.exists():
|
|
166
|
+
try:
|
|
167
|
+
data: dict[str, Any] = json.loads(target.read_text(encoding="utf-8"))
|
|
168
|
+
instance = cls(**data)
|
|
169
|
+
logger.debug("Settings loaded from %s", target)
|
|
170
|
+
return instance
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
logger.warning("Failed to load settings from %s: %s", target, exc)
|
|
173
|
+
return cls()
|
|
174
|
+
|
|
175
|
+
def reset_to_defaults(self) -> None:
|
|
176
|
+
"""Reset all settings to defaults."""
|
|
177
|
+
defaults = AppSettings()
|
|
178
|
+
for key, value in defaults.model_dump().items():
|
|
179
|
+
setattr(self, key, value)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Singleton instance - loaded lazily
|
|
183
|
+
_settings_instance: Optional[AppSettings] = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_settings() -> AppSettings:
|
|
187
|
+
"""Return the global settings singleton."""
|
|
188
|
+
global _settings_instance
|
|
189
|
+
if _settings_instance is None:
|
|
190
|
+
_settings_instance = AppSettings.load()
|
|
191
|
+
return _settings_instance
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def save_settings() -> None:
|
|
195
|
+
"""Save the global settings singleton."""
|
|
196
|
+
settings = get_settings()
|
|
197
|
+
settings.save()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def ensure_dirs() -> None:
|
|
201
|
+
"""Ensure all application directories exist."""
|
|
202
|
+
for directory in (CONFIG_DIR, DATA_DIR, LOG_DIR, CACHE_DIR):
|
|
203
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Application controller - dependency injection root.
|
|
3
|
+
|
|
4
|
+
Owns all service instances and provides a unified facade used by
|
|
5
|
+
both the CLI and the GUI. This prevents duplication and ensures
|
|
6
|
+
a single ADB client is shared across the application.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from voidremote.adb.client import AdbClient, AdbNotFoundError
|
|
16
|
+
from voidremote.config.settings import AppSettings, get_settings
|
|
17
|
+
from voidremote.models.device import Device, PairingInfo
|
|
18
|
+
from voidremote.services.device_service import DeviceService
|
|
19
|
+
from voidremote.services.input_service import InputService, KeyCode
|
|
20
|
+
from voidremote.services.monitor_service import MonitorService, SnapshotCallback
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AppController:
|
|
26
|
+
"""
|
|
27
|
+
Central application controller.
|
|
28
|
+
|
|
29
|
+
Instantiate once at startup, then pass to CLI commands or the GUI.
|
|
30
|
+
|
|
31
|
+
Example::
|
|
32
|
+
|
|
33
|
+
ctrl = AppController()
|
|
34
|
+
ctrl.initialize()
|
|
35
|
+
devices = ctrl.list_devices()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, settings: Optional[AppSettings] = None) -> None:
|
|
39
|
+
self._settings = settings or get_settings()
|
|
40
|
+
self._adb = AdbClient(
|
|
41
|
+
adb_path=self._settings.adb.path,
|
|
42
|
+
default_timeout=self._settings.adb.command_timeout,
|
|
43
|
+
server_port=self._settings.adb.server_port,
|
|
44
|
+
)
|
|
45
|
+
self._device_service = DeviceService(self._adb)
|
|
46
|
+
self._input_service = InputService(self._adb)
|
|
47
|
+
self._monitor_service = MonitorService(self._adb)
|
|
48
|
+
self._initialized = False
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Lifecycle
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def initialize(self) -> str:
|
|
55
|
+
"""
|
|
56
|
+
Initialize the controller — verify ADB, optionally start server.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
ADB version string.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
AdbNotFoundError: If ADB binary is not available.
|
|
63
|
+
"""
|
|
64
|
+
version = self._adb.verify_adb()
|
|
65
|
+
if self._settings.adb.auto_start_server:
|
|
66
|
+
try:
|
|
67
|
+
self._adb.start_server()
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
logger.warning("Failed to start ADB server: %s", exc)
|
|
70
|
+
self._initialized = True
|
|
71
|
+
logger.info("AppController initialized, ADB: %s", version)
|
|
72
|
+
return version
|
|
73
|
+
|
|
74
|
+
def shutdown(self) -> None:
|
|
75
|
+
"""Graceful shutdown — stop monitors, optionally kill ADB."""
|
|
76
|
+
self._monitor_service.stop_all()
|
|
77
|
+
if self._settings.adb.kill_server_on_exit:
|
|
78
|
+
try:
|
|
79
|
+
self._adb.kill_server()
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
logger.warning("Failed to kill ADB server on exit: %s", exc)
|
|
82
|
+
logger.info("AppController shut down")
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# Device management
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def list_devices(self, refresh: bool = True) -> list[Device]:
|
|
89
|
+
"""List connected devices, optionally refreshing first."""
|
|
90
|
+
if refresh:
|
|
91
|
+
return self._device_service.refresh_devices()
|
|
92
|
+
return self._device_service.list_devices()
|
|
93
|
+
|
|
94
|
+
def get_device(self, serial: str) -> Optional[Device]:
|
|
95
|
+
"""Get a specific device by serial."""
|
|
96
|
+
return self._device_service.get_device(serial)
|
|
97
|
+
|
|
98
|
+
def pair_device(self, host: str, port: int, code: str) -> bool:
|
|
99
|
+
"""Pair via wireless debugging."""
|
|
100
|
+
return self._device_service.pair_device(host, port, code)
|
|
101
|
+
|
|
102
|
+
def connect_device(
|
|
103
|
+
self, host: str, port: int = 5555, remember: bool = True
|
|
104
|
+
) -> Device:
|
|
105
|
+
"""Connect to a wireless device."""
|
|
106
|
+
return self._device_service.connect_device(host, port, remember)
|
|
107
|
+
|
|
108
|
+
def disconnect_device(self, serial: str) -> bool:
|
|
109
|
+
"""Disconnect a wireless device."""
|
|
110
|
+
return self._device_service.disconnect_device(serial)
|
|
111
|
+
|
|
112
|
+
def auto_reconnect(self) -> list[Device]:
|
|
113
|
+
"""Reconnect all trusted auto-connect devices."""
|
|
114
|
+
return self._device_service.auto_reconnect_trusted()
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Input
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def tap(self, serial: str, x: int, y: int) -> None:
|
|
121
|
+
self._input_service.tap(serial, x, y)
|
|
122
|
+
|
|
123
|
+
def swipe(
|
|
124
|
+
self,
|
|
125
|
+
serial: str,
|
|
126
|
+
x1: int,
|
|
127
|
+
y1: int,
|
|
128
|
+
x2: int,
|
|
129
|
+
y2: int,
|
|
130
|
+
duration_ms: int = 300,
|
|
131
|
+
) -> None:
|
|
132
|
+
self._input_service.swipe(serial, x1, y1, x2, y2, duration_ms)
|
|
133
|
+
|
|
134
|
+
def type_text(self, serial: str, text: str) -> None:
|
|
135
|
+
self._input_service.type_text(serial, text)
|
|
136
|
+
|
|
137
|
+
def key_event(self, serial: str, keycode: int) -> None:
|
|
138
|
+
self._input_service.key_event(serial, keycode)
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# File operations
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def push_file(self, serial: str, local: Path, remote: str) -> None:
|
|
145
|
+
"""Push a local file to the device."""
|
|
146
|
+
self._adb.push(serial, local, remote)
|
|
147
|
+
|
|
148
|
+
def pull_file(self, serial: str, remote: str, local: Path) -> None:
|
|
149
|
+
"""Pull a file from the device."""
|
|
150
|
+
self._adb.pull(serial, remote, local)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# APK management
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def install_apk(self, serial: str, apk_path: Path, replace: bool = True) -> None:
|
|
157
|
+
"""Install an APK."""
|
|
158
|
+
self._adb.install(serial, apk_path, replace=replace)
|
|
159
|
+
|
|
160
|
+
def uninstall_app(self, serial: str, package: str) -> None:
|
|
161
|
+
"""Uninstall an app package."""
|
|
162
|
+
self._adb.uninstall(serial, package)
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Screenshot & recording
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def take_screenshot(self, serial: str, output: Path) -> Path:
|
|
169
|
+
"""Capture a screenshot and save to output path."""
|
|
170
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
self._adb.screenshot(serial, output)
|
|
172
|
+
return output
|
|
173
|
+
|
|
174
|
+
def start_screen_record(
|
|
175
|
+
self, serial: str, remote_path: str = "/sdcard/void_rec.mp4"
|
|
176
|
+
) -> "subprocess.Popen[bytes]": # noqa: F821
|
|
177
|
+
return self._adb.screenrecord(serial, remote_path)
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# Shell
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def shell(self, serial: str, command: str) -> str:
|
|
184
|
+
"""Run an ADB shell command, return stdout."""
|
|
185
|
+
return self._adb.shell(serial, command).stdout
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Reboot
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def reboot(self, serial: str, mode: str = "") -> None:
|
|
192
|
+
"""Reboot device."""
|
|
193
|
+
self._adb.reboot(serial, mode)
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# Monitoring
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def start_monitoring(
|
|
200
|
+
self,
|
|
201
|
+
serial: str,
|
|
202
|
+
interval: float = 2.0,
|
|
203
|
+
callback: Optional[SnapshotCallback] = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Start real-time monitoring for a device."""
|
|
206
|
+
self._monitor_service.start(serial, interval, callback)
|
|
207
|
+
|
|
208
|
+
def stop_monitoring(self, serial: str) -> None:
|
|
209
|
+
"""Stop monitoring a device."""
|
|
210
|
+
self._monitor_service.stop(serial)
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
# Properties
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def settings(self) -> AppSettings:
|
|
218
|
+
return self._settings
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def is_initialized(self) -> bool:
|
|
222
|
+
return self._initialized
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""VoidRemote core package."""
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Automation engine — macro recording and playback.
|
|
3
|
+
|
|
4
|
+
Records sequences of input events and replays them on demand.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class MacroStep:
|
|
22
|
+
"""A single recorded automation step."""
|
|
23
|
+
|
|
24
|
+
action: str # tap | swipe | text | keyevent | sleep
|
|
25
|
+
params: dict # action-specific parameters
|
|
26
|
+
delay_after: float = 0.0 # seconds to wait after this step
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Macro:
|
|
31
|
+
"""A recorded sequence of automation steps."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
steps: list[MacroStep] = field(default_factory=list)
|
|
35
|
+
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
36
|
+
description: str = ""
|
|
37
|
+
repeat: int = 1
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict:
|
|
40
|
+
return {
|
|
41
|
+
"name": self.name,
|
|
42
|
+
"description": self.description,
|
|
43
|
+
"created_at": self.created_at,
|
|
44
|
+
"repeat": self.repeat,
|
|
45
|
+
"steps": [asdict(s) for s in self.steps],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, data: dict) -> "Macro":
|
|
50
|
+
steps = [MacroStep(**s) for s in data.get("steps", [])]
|
|
51
|
+
return cls(
|
|
52
|
+
name=data["name"],
|
|
53
|
+
description=data.get("description", ""),
|
|
54
|
+
created_at=data.get("created_at", ""),
|
|
55
|
+
repeat=data.get("repeat", 1),
|
|
56
|
+
steps=steps,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MacroRecorder:
|
|
61
|
+
"""Records user input actions into a Macro."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, name: str) -> None:
|
|
64
|
+
self._name = name
|
|
65
|
+
self._steps: list[MacroStep] = []
|
|
66
|
+
self._last_time: float = time.monotonic()
|
|
67
|
+
|
|
68
|
+
def _delay(self) -> float:
|
|
69
|
+
now = time.monotonic()
|
|
70
|
+
delay = now - self._last_time
|
|
71
|
+
self._last_time = now
|
|
72
|
+
return round(delay, 3)
|
|
73
|
+
|
|
74
|
+
def record_tap(self, x: int, y: int) -> None:
|
|
75
|
+
self._steps.append(MacroStep("tap", {"x": x, "y": y}, self._delay()))
|
|
76
|
+
|
|
77
|
+
def record_swipe(self, x1: int, y1: int, x2: int, y2: int, duration_ms: int = 300) -> None:
|
|
78
|
+
self._steps.append(MacroStep(
|
|
79
|
+
"swipe", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "duration_ms": duration_ms},
|
|
80
|
+
self._delay()
|
|
81
|
+
))
|
|
82
|
+
|
|
83
|
+
def record_text(self, text: str) -> None:
|
|
84
|
+
self._steps.append(MacroStep("text", {"text": text}, self._delay()))
|
|
85
|
+
|
|
86
|
+
def record_keyevent(self, keycode: int) -> None:
|
|
87
|
+
self._steps.append(MacroStep("keyevent", {"keycode": keycode}, self._delay()))
|
|
88
|
+
|
|
89
|
+
def record_sleep(self, seconds: float) -> None:
|
|
90
|
+
self._steps.append(MacroStep("sleep", {"seconds": seconds}, 0.0))
|
|
91
|
+
|
|
92
|
+
def finish(self) -> Macro:
|
|
93
|
+
return Macro(name=self._name, steps=self._steps)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class MacroPlayer:
|
|
97
|
+
"""Plays back a Macro on a target device."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, controller: object) -> None:
|
|
100
|
+
self._controller = controller
|
|
101
|
+
self._running = False
|
|
102
|
+
|
|
103
|
+
def play(self, macro: Macro, serial: str, speed: float = 1.0) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Execute a macro on the specified device.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
macro: The macro to play.
|
|
109
|
+
serial: ADB device serial.
|
|
110
|
+
speed: Playback speed multiplier (1.0 = recorded speed).
|
|
111
|
+
"""
|
|
112
|
+
self._running = True
|
|
113
|
+
logger.info("Playing macro '%s' on %s (repeat=%d)", macro.name, serial, macro.repeat)
|
|
114
|
+
|
|
115
|
+
for iteration in range(macro.repeat):
|
|
116
|
+
if not self._running:
|
|
117
|
+
break
|
|
118
|
+
logger.debug("Macro iteration %d/%d", iteration + 1, macro.repeat)
|
|
119
|
+
for step in macro.steps:
|
|
120
|
+
if not self._running:
|
|
121
|
+
break
|
|
122
|
+
if step.delay_after > 0:
|
|
123
|
+
time.sleep(step.delay_after / speed)
|
|
124
|
+
self._execute_step(step, serial)
|
|
125
|
+
|
|
126
|
+
self._running = False
|
|
127
|
+
logger.info("Macro '%s' completed", macro.name)
|
|
128
|
+
|
|
129
|
+
def stop(self) -> None:
|
|
130
|
+
self._running = False
|
|
131
|
+
|
|
132
|
+
def _execute_step(self, step: MacroStep, serial: str) -> None:
|
|
133
|
+
try:
|
|
134
|
+
if step.action == "tap":
|
|
135
|
+
self._controller.tap(serial, step.params["x"], step.params["y"])
|
|
136
|
+
elif step.action == "swipe":
|
|
137
|
+
self._controller.swipe(
|
|
138
|
+
serial,
|
|
139
|
+
step.params["x1"], step.params["y1"],
|
|
140
|
+
step.params["x2"], step.params["y2"],
|
|
141
|
+
step.params.get("duration_ms", 300),
|
|
142
|
+
)
|
|
143
|
+
elif step.action == "text":
|
|
144
|
+
self._controller.type_text(serial, step.params["text"])
|
|
145
|
+
elif step.action == "keyevent":
|
|
146
|
+
self._controller.key_event(serial, step.params["keycode"])
|
|
147
|
+
elif step.action == "sleep":
|
|
148
|
+
time.sleep(step.params.get("seconds", 0.5))
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
logger.warning("Macro step '%s' failed: %s", step.action, exc)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class MacroLibrary:
|
|
154
|
+
"""Persistent storage for saved macros."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, storage_dir: Path) -> None:
|
|
157
|
+
self._dir = storage_dir
|
|
158
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
|
|
160
|
+
def save(self, macro: Macro) -> Path:
|
|
161
|
+
safe_name = macro.name.replace(" ", "_").replace("/", "_")
|
|
162
|
+
path = self._dir / f"{safe_name}.json"
|
|
163
|
+
path.write_text(json.dumps(macro.to_dict(), indent=2), encoding="utf-8")
|
|
164
|
+
logger.debug("Saved macro: %s", path)
|
|
165
|
+
return path
|
|
166
|
+
|
|
167
|
+
def load(self, name: str) -> Macro:
|
|
168
|
+
safe_name = name.replace(" ", "_").replace("/", "_")
|
|
169
|
+
path = self._dir / f"{safe_name}.json"
|
|
170
|
+
if not path.exists():
|
|
171
|
+
raise FileNotFoundError(f"Macro not found: {name!r}")
|
|
172
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
173
|
+
return Macro.from_dict(data)
|
|
174
|
+
|
|
175
|
+
def list_macros(self) -> list[str]:
|
|
176
|
+
return [p.stem.replace("_", " ") for p in self._dir.glob("*.json")]
|
|
177
|
+
|
|
178
|
+
def delete(self, name: str) -> bool:
|
|
179
|
+
safe_name = name.replace(" ", "_").replace("/", "_")
|
|
180
|
+
path = self._dir / f"{safe_name}.json"
|
|
181
|
+
if path.exists():
|
|
182
|
+
path.unlink()
|
|
183
|
+
return True
|
|
184
|
+
return False
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""VoidRemote data models."""
|
|
2
|
+
|
|
3
|
+
from voidremote.models.device import (
|
|
4
|
+
BatteryInfo,
|
|
5
|
+
ConnectionConfig,
|
|
6
|
+
ConnectionType,
|
|
7
|
+
CpuInfo,
|
|
8
|
+
Device,
|
|
9
|
+
DeviceCapability,
|
|
10
|
+
DeviceInfo,
|
|
11
|
+
DeviceState,
|
|
12
|
+
NetworkInfo,
|
|
13
|
+
PairingInfo,
|
|
14
|
+
StorageInfo,
|
|
15
|
+
TrustedDevice,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BatteryInfo",
|
|
20
|
+
"ConnectionConfig",
|
|
21
|
+
"ConnectionType",
|
|
22
|
+
"CpuInfo",
|
|
23
|
+
"Device",
|
|
24
|
+
"DeviceCapability",
|
|
25
|
+
"DeviceInfo",
|
|
26
|
+
"DeviceState",
|
|
27
|
+
"NetworkInfo",
|
|
28
|
+
"PairingInfo",
|
|
29
|
+
"StorageInfo",
|
|
30
|
+
"TrustedDevice",
|
|
31
|
+
]
|