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,198 @@
1
+ """
2
+ Real-time device monitoring service.
3
+
4
+ Polls CPU, RAM, battery, temperature, and network stats at regular
5
+ intervals and emits snapshots.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ import threading
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime
16
+ from typing import Callable, Optional
17
+
18
+ from voidremote.adb.client import AdbClient
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class DeviceSnapshot:
25
+ """Point-in-time device performance metrics."""
26
+
27
+ serial: str
28
+ timestamp: datetime = field(default_factory=datetime.now)
29
+ cpu_usage: float = 0.0
30
+ ram_used_mb: float = 0.0
31
+ ram_total_mb: float = 0.0
32
+ battery_level: int = 0
33
+ battery_temperature: float = 0.0
34
+ battery_is_charging: bool = False
35
+ storage_used_gb: float = 0.0
36
+ storage_total_gb: float = 0.0
37
+ fps: float = 0.0
38
+
39
+ @property
40
+ def ram_usage_percent(self) -> float:
41
+ if self.ram_total_mb == 0:
42
+ return 0.0
43
+ return (self.ram_used_mb / self.ram_total_mb) * 100.0
44
+
45
+ @property
46
+ def storage_usage_percent(self) -> float:
47
+ if self.storage_total_gb == 0:
48
+ return 0.0
49
+ return (self.storage_used_gb / self.storage_total_gb) * 100.0
50
+
51
+
52
+ SnapshotCallback = Callable[[DeviceSnapshot], None]
53
+
54
+
55
+ class MonitorService:
56
+ """
57
+ Polls device metrics at a configurable interval.
58
+
59
+ Usage::
60
+
61
+ monitor = MonitorService(adb_client)
62
+ monitor.start("serial123", interval=2.0, callback=on_snapshot)
63
+ # ... later ...
64
+ monitor.stop("serial123")
65
+ """
66
+
67
+ def __init__(self, adb: AdbClient) -> None:
68
+ self._adb = adb
69
+ self._threads: dict[str, threading.Thread] = {}
70
+ self._stop_events: dict[str, threading.Event] = {}
71
+ self._callbacks: dict[str, list[SnapshotCallback]] = {}
72
+
73
+ def start(
74
+ self,
75
+ serial: str,
76
+ interval: float = 2.0,
77
+ callback: Optional[SnapshotCallback] = None,
78
+ ) -> None:
79
+ """Start monitoring a device."""
80
+ if serial in self._threads and self._threads[serial].is_alive():
81
+ logger.warning("Already monitoring %s", serial)
82
+ return
83
+ stop_event = threading.Event()
84
+ self._stop_events[serial] = stop_event
85
+ self._callbacks[serial] = [callback] if callback else []
86
+
87
+ thread = threading.Thread(
88
+ target=self._poll_loop,
89
+ args=(serial, interval, stop_event),
90
+ name=f"monitor-{serial}",
91
+ daemon=True,
92
+ )
93
+ self._threads[serial] = thread
94
+ thread.start()
95
+ logger.info("Started monitoring %s (interval=%.1fs)", serial, interval)
96
+
97
+ def stop(self, serial: str) -> None:
98
+ """Stop monitoring a device."""
99
+ event = self._stop_events.get(serial)
100
+ if event:
101
+ event.set()
102
+ thread = self._threads.get(serial)
103
+ if thread and thread.is_alive():
104
+ thread.join(timeout=5.0)
105
+ self._threads.pop(serial, None)
106
+ self._stop_events.pop(serial, None)
107
+ self._callbacks.pop(serial, None)
108
+ logger.info("Stopped monitoring %s", serial)
109
+
110
+ def stop_all(self) -> None:
111
+ """Stop all monitoring threads."""
112
+ for serial in list(self._threads.keys()):
113
+ self.stop(serial)
114
+
115
+ def add_callback(self, serial: str, callback: SnapshotCallback) -> None:
116
+ """Register an additional callback for a monitored device."""
117
+ if serial not in self._callbacks:
118
+ self._callbacks[serial] = []
119
+ self._callbacks[serial].append(callback)
120
+
121
+ def _poll_loop(
122
+ self, serial: str, interval: float, stop_event: threading.Event
123
+ ) -> None:
124
+ """Main polling loop running in background thread."""
125
+ while not stop_event.is_set():
126
+ try:
127
+ snapshot = self._collect_snapshot(serial)
128
+ for cb in self._callbacks.get(serial, []):
129
+ try:
130
+ cb(snapshot)
131
+ except Exception as exc:
132
+ logger.warning("Monitor callback error: %s", exc)
133
+ except Exception as exc:
134
+ logger.debug("Snapshot collection failed for %s: %s", serial, exc)
135
+ stop_event.wait(interval)
136
+
137
+ def _collect_snapshot(self, serial: str) -> DeviceSnapshot:
138
+ """Collect all metrics for one snapshot."""
139
+ snapshot = DeviceSnapshot(serial=serial)
140
+
141
+ # CPU
142
+ try:
143
+ snapshot.cpu_usage = self._get_cpu_usage(serial)
144
+ except Exception:
145
+ pass
146
+
147
+ # RAM
148
+ try:
149
+ total, avail = self._get_ram(serial)
150
+ snapshot.ram_total_mb = total / (1024 * 1024)
151
+ snapshot.ram_used_mb = (total - avail) / (1024 * 1024)
152
+ except Exception:
153
+ pass
154
+
155
+ # Battery
156
+ try:
157
+ level, temp, charging = self._get_battery(serial)
158
+ snapshot.battery_level = level
159
+ snapshot.battery_temperature = temp
160
+ snapshot.battery_is_charging = charging
161
+ except Exception:
162
+ pass
163
+
164
+ return snapshot
165
+
166
+ def _get_cpu_usage(self, serial: str) -> float:
167
+ """Calculate CPU usage percentage from /proc/stat."""
168
+ def read_stat() -> tuple[int, int]:
169
+ out = self._adb.shell(serial, "cat /proc/stat").stdout
170
+ for line in out.splitlines():
171
+ if line.startswith("cpu "):
172
+ nums = [int(x) for x in line.split()[1:]]
173
+ total = sum(nums)
174
+ idle = nums[3] if len(nums) > 3 else 0
175
+ return total, idle
176
+ return 0, 0
177
+
178
+ t1, i1 = read_stat()
179
+ time.sleep(0.2)
180
+ t2, i2 = read_stat()
181
+ dt = t2 - t1
182
+ di = i2 - i1
183
+ if dt == 0:
184
+ return 0.0
185
+ return max(0.0, min(100.0, (1.0 - di / dt) * 100.0))
186
+
187
+ def _get_ram(self, serial: str) -> tuple[int, int]:
188
+ """Return (total_bytes, available_bytes) from /proc/meminfo."""
189
+ from voidremote.adb.device_parser import parse_meminfo
190
+ out = self._adb.shell(serial, "cat /proc/meminfo").stdout
191
+ return parse_meminfo(out)
192
+
193
+ def _get_battery(self, serial: str) -> tuple[int, float, bool]:
194
+ """Return (level, temperature_c, is_charging)."""
195
+ from voidremote.adb.device_parser import parse_battery
196
+ out = self._adb.shell(serial, "dumpsys battery").stdout
197
+ info = parse_battery(out)
198
+ return info.level, info.temperature_celsius, info.is_charging
@@ -0,0 +1 @@
1
+ """VoidRemote GUI package."""
voidremote/ui/app.py ADDED
@@ -0,0 +1,70 @@
1
+ """
2
+ VoidRemote GUI application entry point.
3
+
4
+ Bootstraps QApplication, applies theme, initializes controller,
5
+ and launches the main window.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ import logging
12
+ from typing import Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def main() -> None:
18
+ """Launch the VoidRemote GUI application."""
19
+ # Must import PySide6 before any other Qt symbols
20
+ from PySide6.QtWidgets import QApplication
21
+ from PySide6.QtCore import Qt
22
+ from PySide6.QtGui import QFont
23
+
24
+ from voidremote.config.settings import ensure_dirs, get_settings, LOG_FILE
25
+ from voidremote.utils.logging import setup_logging
26
+ from voidremote.controllers.app_controller import AppController
27
+ from voidremote.ui.theme import apply_dark_theme
28
+ from voidremote.ui.main_window import MainWindow
29
+
30
+ ensure_dirs()
31
+ settings = get_settings()
32
+
33
+ setup_logging(
34
+ level=settings.logging.level,
35
+ log_file=LOG_FILE if settings.logging.file_enabled else None,
36
+ console=settings.logging.console_enabled,
37
+ console_color=settings.logging.console_color,
38
+ )
39
+
40
+ # High-DPI support
41
+ QApplication.setHighDpiScaleFactorRoundingPolicy(
42
+ Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
43
+ )
44
+
45
+ app = QApplication(sys.argv)
46
+ app.setApplicationName("VoidRemote")
47
+ app.setApplicationDisplayName("VoidRemote")
48
+ app.setApplicationVersion("1.0.0")
49
+ app.setOrganizationName("V0IDNETWORK")
50
+ app.setOrganizationDomain("voidNetwork.ir")
51
+
52
+ # Apply dark theme
53
+ apply_dark_theme(app)
54
+
55
+ # Set default font
56
+ font = QFont(settings.ui.font_family, settings.ui.font_size)
57
+ app.setFont(font)
58
+
59
+ # Create controller (no initialize() yet — done lazily in MainWindow)
60
+ controller = AppController(settings)
61
+
62
+ window = MainWindow(controller)
63
+ window.show()
64
+
65
+ logger.info("VoidRemote GUI started")
66
+ sys.exit(app.exec())
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,6 @@
1
+ """VoidRemote UI dialogs."""
2
+
3
+ from voidremote.ui.dialogs.pair_dialog import PairDialog
4
+ from voidremote.ui.dialogs.connect_dialog import ConnectDialog
5
+
6
+ __all__ = ["ConnectDialog", "PairDialog"]
@@ -0,0 +1,149 @@
1
+ """Connect to device dialog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from PySide6.QtCore import Qt, QThread, Signal, Slot
8
+ from PySide6.QtWidgets import (
9
+ QDialog,
10
+ QDialogButtonBox,
11
+ QFormLayout,
12
+ QLabel,
13
+ QLineEdit,
14
+ QProgressBar,
15
+ QPushButton,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ QCheckBox,
19
+ )
20
+
21
+ from voidremote.ui.theme import Colors
22
+
23
+
24
+ class ConnectWorker(QThread):
25
+ success = Signal(object)
26
+ failure = Signal(str)
27
+
28
+ def __init__(self, controller: object, host: str, port: int, remember: bool, parent: Optional[object] = None) -> None:
29
+ super().__init__(parent)
30
+ self._controller = controller
31
+ self._host = host
32
+ self._port = port
33
+ self._remember = remember
34
+
35
+ def run(self) -> None:
36
+ try:
37
+ device = self._controller.connect_device(self._host, self._port, self._remember)
38
+ self.success.emit(device)
39
+ except Exception as exc:
40
+ self.failure.emit(str(exc))
41
+
42
+
43
+ class ConnectDialog(QDialog):
44
+ connected = Signal(object)
45
+
46
+ def __init__(self, controller: object, parent: Optional[QWidget] = None) -> None:
47
+ super().__init__(parent)
48
+ self._controller = controller
49
+ self._worker: Optional[ConnectWorker] = None
50
+ self.setWindowTitle("Connect to Device")
51
+ self.setMinimumWidth(400)
52
+ self.setModal(True)
53
+ self._build_ui()
54
+
55
+ def _build_ui(self) -> None:
56
+ layout = QVBoxLayout(self)
57
+ layout.setSpacing(16)
58
+ layout.setContentsMargins(24, 24, 24, 20)
59
+
60
+ title = QLabel("Connect via TCP/IP")
61
+ title.setStyleSheet(f"font-size: 18px; font-weight: 700; color: {Colors.TEXT_PRIMARY};")
62
+ layout.addWidget(title)
63
+
64
+ hint = QLabel(
65
+ "Enter the IP address and port of your Android device.\n"
66
+ "Default ADB port is 5555."
67
+ )
68
+ hint.setStyleSheet(
69
+ f"color: {Colors.TEXT_SECONDARY}; font-size: 12px; "
70
+ f"background: {Colors.BG_ELEVATED}; border-radius: 6px; padding: 10px;"
71
+ )
72
+ hint.setWordWrap(True)
73
+ layout.addWidget(hint)
74
+
75
+ form = QFormLayout()
76
+ form.setSpacing(10)
77
+ form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
78
+
79
+ self._host_edit = QLineEdit()
80
+ self._host_edit.setPlaceholderText("192.168.1.x")
81
+ form.addRow("IP Address:", self._host_edit)
82
+
83
+ self._port_edit = QLineEdit("5555")
84
+ self._port_edit.setPlaceholderText("5555")
85
+ form.addRow("Port:", self._port_edit)
86
+
87
+ layout.addLayout(form)
88
+
89
+ self._remember_check = QCheckBox("Remember this device")
90
+ self._remember_check.setChecked(True)
91
+ layout.addWidget(self._remember_check)
92
+
93
+ self._progress = QProgressBar()
94
+ self._progress.setRange(0, 0)
95
+ self._progress.hide()
96
+ layout.addWidget(self._progress)
97
+
98
+ self._status = QLabel()
99
+ self._status.setWordWrap(True)
100
+ self._status.hide()
101
+ layout.addWidget(self._status)
102
+
103
+ buttons = QDialogButtonBox()
104
+ self._connect_btn = QPushButton("Connect")
105
+ self._connect_btn.setObjectName("accent")
106
+ self._connect_btn.clicked.connect(self._start_connect)
107
+ buttons.addButton(self._connect_btn, QDialogButtonBox.ButtonRole.AcceptRole)
108
+ cancel = buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
109
+ cancel.clicked.connect(self.reject)
110
+ layout.addWidget(buttons)
111
+
112
+ def _start_connect(self) -> None:
113
+ host = self._host_edit.text().strip()
114
+ port_str = self._port_edit.text().strip()
115
+
116
+ if not host:
117
+ self._show_error("Enter an IP address.")
118
+ return
119
+ if not port_str.isdigit():
120
+ self._show_error("Port must be a number.")
121
+ return
122
+
123
+ self._connect_btn.setEnabled(False)
124
+ self._progress.show()
125
+ self._status.hide()
126
+
127
+ self._worker = ConnectWorker(
128
+ self._controller, host, int(port_str), self._remember_check.isChecked(), self
129
+ )
130
+ self._worker.success.connect(self._on_success)
131
+ self._worker.failure.connect(self._on_failure)
132
+ self._worker.start()
133
+
134
+ @Slot(object)
135
+ def _on_success(self, device: object) -> None:
136
+ self._progress.hide()
137
+ self.connected.emit(device)
138
+ self.accept()
139
+
140
+ @Slot(str)
141
+ def _on_failure(self, msg: str) -> None:
142
+ self._progress.hide()
143
+ self._connect_btn.setEnabled(True)
144
+ self._show_error(msg)
145
+
146
+ def _show_error(self, msg: str) -> None:
147
+ self._status.setText(f"✗ {msg}")
148
+ self._status.setStyleSheet(f"color: {Colors.ERROR};")
149
+ self._status.show()
@@ -0,0 +1,189 @@
1
+ """Wireless ADB pairing dialog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from PySide6.QtCore import Qt, QThread, Signal, Slot
8
+ from PySide6.QtWidgets import (
9
+ QDialog,
10
+ QDialogButtonBox,
11
+ QFormLayout,
12
+ QLabel,
13
+ QLineEdit,
14
+ QProgressBar,
15
+ QPushButton,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ )
19
+
20
+ from voidremote.ui.theme import Colors
21
+
22
+
23
+ class PairWorker(QThread):
24
+ """Background thread for ADB pairing."""
25
+
26
+ success = Signal(str)
27
+ failure = Signal(str)
28
+
29
+ def __init__(
30
+ self,
31
+ controller: object,
32
+ host: str,
33
+ port: int,
34
+ code: str,
35
+ parent: Optional[QObject] = None, # type: ignore[name-defined]
36
+ ) -> None:
37
+ super().__init__(parent)
38
+ self._controller = controller
39
+ self._host = host
40
+ self._port = port
41
+ self._code = code
42
+
43
+ def run(self) -> None:
44
+ try:
45
+ ok = self._controller.pair_device(self._host, self._port, self._code)
46
+ if ok:
47
+ self.success.emit(f"Paired with {self._host}:{self._port}")
48
+ else:
49
+ self.failure.emit("Pairing failed — wrong code or timeout")
50
+ except Exception as exc:
51
+ self.failure.emit(str(exc))
52
+
53
+
54
+ class PairDialog(QDialog):
55
+ """
56
+ Dialog for pairing an Android device via Wireless Debugging.
57
+
58
+ Shows setup instructions, input fields, and async pairing progress.
59
+ """
60
+
61
+ paired = Signal(str, int) # host, port
62
+
63
+ def __init__(self, controller: object, parent: Optional[QWidget] = None) -> None:
64
+ super().__init__(parent)
65
+ self._controller = controller
66
+ self._worker: Optional[PairWorker] = None
67
+ self.setWindowTitle("Pair New Device")
68
+ self.setMinimumWidth(440)
69
+ self.setModal(True)
70
+ self._build_ui()
71
+
72
+ def _build_ui(self) -> None:
73
+ layout = QVBoxLayout(self)
74
+ layout.setSpacing(16)
75
+ layout.setContentsMargins(24, 24, 24, 20)
76
+
77
+ # Title
78
+ title = QLabel("Pair via Wireless Debugging")
79
+ title.setStyleSheet(
80
+ f"font-size: 18px; font-weight: 700; color: {Colors.TEXT_PRIMARY};"
81
+ )
82
+ layout.addWidget(title)
83
+
84
+ # Instructions
85
+ instructions = QLabel(
86
+ "On your Android device:\n"
87
+ " 1. Go to Settings → Developer Options\n"
88
+ " 2. Enable Wireless Debugging\n"
89
+ " 3. Tap 'Pair device with pairing code'\n"
90
+ " 4. Enter the IP, port, and 6-digit code shown below"
91
+ )
92
+ instructions.setStyleSheet(
93
+ f"color: {Colors.TEXT_SECONDARY}; font-size: 12px; "
94
+ f"background: {Colors.BG_ELEVATED}; border-radius: 6px; padding: 12px;"
95
+ )
96
+ instructions.setWordWrap(True)
97
+ layout.addWidget(instructions)
98
+
99
+ # Form
100
+ form = QFormLayout()
101
+ form.setSpacing(10)
102
+ form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
103
+
104
+ self._host_edit = QLineEdit()
105
+ self._host_edit.setPlaceholderText("192.168.1.x")
106
+ form.addRow("Device IP:", self._host_edit)
107
+
108
+ self._port_edit = QLineEdit()
109
+ self._port_edit.setPlaceholderText("37001")
110
+ self._port_edit.setText("37001")
111
+ form.addRow("Pairing Port:", self._port_edit)
112
+
113
+ self._code_edit = QLineEdit()
114
+ self._code_edit.setPlaceholderText("123456")
115
+ self._code_edit.setMaxLength(6)
116
+ form.addRow("6-digit Code:", self._code_edit)
117
+
118
+ layout.addLayout(form)
119
+
120
+ # Progress
121
+ self._progress = QProgressBar()
122
+ self._progress.setRange(0, 0)
123
+ self._progress.hide()
124
+ layout.addWidget(self._progress)
125
+
126
+ # Status label
127
+ self._status = QLabel()
128
+ self._status.setWordWrap(True)
129
+ self._status.hide()
130
+ layout.addWidget(self._status)
131
+
132
+ # Buttons
133
+ self._buttons = QDialogButtonBox()
134
+ self._pair_btn = QPushButton("Pair Device")
135
+ self._pair_btn.setObjectName("accent")
136
+ self._pair_btn.clicked.connect(self._start_pairing)
137
+ self._buttons.addButton(self._pair_btn, QDialogButtonBox.ButtonRole.AcceptRole)
138
+
139
+ cancel_btn = self._buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
140
+ cancel_btn.clicked.connect(self.reject)
141
+
142
+ layout.addWidget(self._buttons)
143
+
144
+ def _start_pairing(self) -> None:
145
+ host = self._host_edit.text().strip()
146
+ port_str = self._port_edit.text().strip()
147
+ code = self._code_edit.text().strip()
148
+
149
+ if not host:
150
+ self._show_error("Enter the device IP address.")
151
+ return
152
+ if not port_str.isdigit():
153
+ self._show_error("Port must be a number.")
154
+ return
155
+ if len(code) != 6 or not code.isdigit():
156
+ self._show_error("Pairing code must be exactly 6 digits.")
157
+ return
158
+
159
+ port = int(port_str)
160
+ self._pair_btn.setEnabled(False)
161
+ self._progress.show()
162
+ self._status.hide()
163
+
164
+ self._worker = PairWorker(self._controller, host, port, code, self)
165
+ self._worker.success.connect(self._on_success)
166
+ self._worker.failure.connect(self._on_failure)
167
+ self._worker.start()
168
+
169
+ @Slot(str)
170
+ def _on_success(self, msg: str) -> None:
171
+ self._progress.hide()
172
+ self._status.setText(f"✓ {msg}")
173
+ self._status.setStyleSheet(f"color: {Colors.SUCCESS}; font-weight: 600;")
174
+ self._status.show()
175
+ host = self._host_edit.text().strip()
176
+ port = int(self._port_edit.text().strip())
177
+ self.paired.emit(host, port)
178
+ self.accept()
179
+
180
+ @Slot(str)
181
+ def _on_failure(self, msg: str) -> None:
182
+ self._progress.hide()
183
+ self._pair_btn.setEnabled(True)
184
+ self._show_error(msg)
185
+
186
+ def _show_error(self, msg: str) -> None:
187
+ self._status.setText(f"✗ {msg}")
188
+ self._status.setStyleSheet(f"color: {Colors.ERROR};")
189
+ self._status.show()