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,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,5 @@
1
+ """VoidRemote application controllers."""
2
+
3
+ from voidremote.controllers.app_controller import AppController
4
+
5
+ __all__ = ["AppController"]
@@ -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
+ ]