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,234 @@
|
|
|
1
|
+
"""Embedded ADB shell terminal view."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import Qt, QThread, Signal, Slot
|
|
9
|
+
from PySide6.QtGui import QFont, QKeyEvent, QTextCharFormat, QColor, QTextCursor
|
|
10
|
+
from PySide6.QtWidgets import (
|
|
11
|
+
QComboBox,
|
|
12
|
+
QHBoxLayout,
|
|
13
|
+
QLabel,
|
|
14
|
+
QLineEdit,
|
|
15
|
+
QPlainTextEdit,
|
|
16
|
+
QPushButton,
|
|
17
|
+
QVBoxLayout,
|
|
18
|
+
QWidget,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from voidremote.ui.theme import Colors
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ShellWorker(QThread):
|
|
27
|
+
output = Signal(str, bool) # text, is_error
|
|
28
|
+
|
|
29
|
+
def __init__(self, controller: object, serial: str, command: str, parent: Optional[object] = None) -> None:
|
|
30
|
+
super().__init__(parent)
|
|
31
|
+
self._controller = controller
|
|
32
|
+
self._serial = serial
|
|
33
|
+
self._command = command
|
|
34
|
+
|
|
35
|
+
def run(self) -> None:
|
|
36
|
+
try:
|
|
37
|
+
result = self._controller.shell(self._serial, self._command)
|
|
38
|
+
self.output.emit(result, False)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
self.output.emit(str(exc), True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HistoryLineEdit(QLineEdit):
|
|
44
|
+
"""Line edit with command history navigation."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
|
47
|
+
super().__init__(parent)
|
|
48
|
+
self._history: list[str] = []
|
|
49
|
+
self._history_idx = -1
|
|
50
|
+
|
|
51
|
+
def add_to_history(self, cmd: str) -> None:
|
|
52
|
+
if cmd and (not self._history or self._history[-1] != cmd):
|
|
53
|
+
self._history.append(cmd)
|
|
54
|
+
self._history_idx = -1
|
|
55
|
+
|
|
56
|
+
def keyPressEvent(self, event: QKeyEvent) -> None:
|
|
57
|
+
if event.key() == Qt.Key.Key_Up:
|
|
58
|
+
if self._history:
|
|
59
|
+
self._history_idx = min(self._history_idx + 1, len(self._history) - 1)
|
|
60
|
+
self.setText(self._history[-(self._history_idx + 1)])
|
|
61
|
+
return
|
|
62
|
+
if event.key() == Qt.Key.Key_Down:
|
|
63
|
+
if self._history_idx > 0:
|
|
64
|
+
self._history_idx -= 1
|
|
65
|
+
self.setText(self._history[-(self._history_idx + 1)])
|
|
66
|
+
elif self._history_idx == 0:
|
|
67
|
+
self._history_idx = -1
|
|
68
|
+
self.clear()
|
|
69
|
+
return
|
|
70
|
+
super().keyPressEvent(event)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ShellView(QWidget):
|
|
74
|
+
"""
|
|
75
|
+
ADB shell terminal emulator embedded in the GUI.
|
|
76
|
+
|
|
77
|
+
Features:
|
|
78
|
+
- Device selector dropdown
|
|
79
|
+
- Command history (up/down arrows)
|
|
80
|
+
- Colored output (errors in red)
|
|
81
|
+
- Clear and copy buttons
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, controller: object, parent: Optional[QWidget] = None) -> None:
|
|
85
|
+
super().__init__(parent)
|
|
86
|
+
self._controller = controller
|
|
87
|
+
self._serial: Optional[str] = None
|
|
88
|
+
self._worker: Optional[ShellWorker] = None
|
|
89
|
+
self._build_ui()
|
|
90
|
+
|
|
91
|
+
def _build_ui(self) -> None:
|
|
92
|
+
root = QVBoxLayout(self)
|
|
93
|
+
root.setContentsMargins(0, 0, 0, 0)
|
|
94
|
+
root.setSpacing(0)
|
|
95
|
+
|
|
96
|
+
# ── Header ──────────────────────────────────────
|
|
97
|
+
header = QHBoxLayout()
|
|
98
|
+
header.setContentsMargins(24, 16, 24, 12)
|
|
99
|
+
header.setSpacing(12)
|
|
100
|
+
|
|
101
|
+
title = QLabel("Shell")
|
|
102
|
+
title.setStyleSheet(f"font-size: 22px; font-weight: 700; color: {Colors.TEXT_PRIMARY};")
|
|
103
|
+
header.addWidget(title)
|
|
104
|
+
|
|
105
|
+
header.addStretch()
|
|
106
|
+
|
|
107
|
+
header.addWidget(QLabel("Device:"))
|
|
108
|
+
self._device_combo = QComboBox()
|
|
109
|
+
self._device_combo.setMinimumWidth(200)
|
|
110
|
+
self._device_combo.currentTextChanged.connect(self._on_device_changed)
|
|
111
|
+
header.addWidget(self._device_combo)
|
|
112
|
+
|
|
113
|
+
btn_clear = QPushButton("Clear")
|
|
114
|
+
btn_clear.setFixedWidth(60)
|
|
115
|
+
btn_clear.clicked.connect(self._output.clear if hasattr(self, "_output") else lambda: None)
|
|
116
|
+
header.addWidget(btn_clear)
|
|
117
|
+
|
|
118
|
+
header_w = QWidget()
|
|
119
|
+
header_w.setStyleSheet(
|
|
120
|
+
f"background: {Colors.BG_SURFACE}; border-bottom: 1px solid {Colors.BORDER};"
|
|
121
|
+
)
|
|
122
|
+
header_w.setLayout(header)
|
|
123
|
+
root.addWidget(header_w)
|
|
124
|
+
|
|
125
|
+
# ── Terminal output ──────────────────────────────
|
|
126
|
+
self._output = QPlainTextEdit()
|
|
127
|
+
self._output.setReadOnly(True)
|
|
128
|
+
self._output.setObjectName("LogView")
|
|
129
|
+
mono = QFont("JetBrains Mono, Cascadia Code, Consolas, monospace", 13)
|
|
130
|
+
self._output.setFont(mono)
|
|
131
|
+
self._output.setStyleSheet(
|
|
132
|
+
f"background: {Colors.BG_BASE}; border: none; padding: 16px;"
|
|
133
|
+
)
|
|
134
|
+
root.addWidget(self._output, stretch=1)
|
|
135
|
+
|
|
136
|
+
# Fix clear button now that _output exists
|
|
137
|
+
btn_clear.clicked.disconnect()
|
|
138
|
+
btn_clear.clicked.connect(self._output.clear)
|
|
139
|
+
|
|
140
|
+
# ── Input bar ────────────────────────────────────
|
|
141
|
+
input_bar = QHBoxLayout()
|
|
142
|
+
input_bar.setContentsMargins(16, 10, 16, 12)
|
|
143
|
+
input_bar.setSpacing(8)
|
|
144
|
+
|
|
145
|
+
prompt = QLabel("$")
|
|
146
|
+
prompt.setStyleSheet(
|
|
147
|
+
f"color: {Colors.ACCENT}; font-family: monospace; font-size: 15px; font-weight: 700;"
|
|
148
|
+
)
|
|
149
|
+
input_bar.addWidget(prompt)
|
|
150
|
+
|
|
151
|
+
self._cmd_edit = HistoryLineEdit()
|
|
152
|
+
self._cmd_edit.setPlaceholderText("Enter shell command…")
|
|
153
|
+
self._cmd_edit.setFont(mono)
|
|
154
|
+
self._cmd_edit.returnPressed.connect(self._run_command)
|
|
155
|
+
input_bar.addWidget(self._cmd_edit, stretch=1)
|
|
156
|
+
|
|
157
|
+
self._run_btn = QPushButton("Run")
|
|
158
|
+
self._run_btn.setObjectName("accent")
|
|
159
|
+
self._run_btn.setFixedWidth(60)
|
|
160
|
+
self._run_btn.clicked.connect(self._run_command)
|
|
161
|
+
input_bar.addWidget(self._run_btn)
|
|
162
|
+
|
|
163
|
+
input_bar_w = QWidget()
|
|
164
|
+
input_bar_w.setStyleSheet(
|
|
165
|
+
f"background: {Colors.BG_SURFACE}; border-top: 1px solid {Colors.BORDER};"
|
|
166
|
+
)
|
|
167
|
+
input_bar_w.setLayout(input_bar)
|
|
168
|
+
root.addWidget(input_bar_w)
|
|
169
|
+
|
|
170
|
+
self._print_welcome()
|
|
171
|
+
|
|
172
|
+
def _print_welcome(self) -> None:
|
|
173
|
+
self._append(
|
|
174
|
+
"VoidRemote Shell — ADB shell emulator\n"
|
|
175
|
+
"Select a device above, then type a command and press Enter.\n"
|
|
176
|
+
"Use ↑ / ↓ to navigate command history.\n",
|
|
177
|
+
error=False,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def set_devices(self, devices: list) -> None:
|
|
181
|
+
current = self._device_combo.currentText()
|
|
182
|
+
self._device_combo.blockSignals(True)
|
|
183
|
+
self._device_combo.clear()
|
|
184
|
+
for d in devices:
|
|
185
|
+
self._device_combo.addItem(f"{d.info.display_name} ({d.serial})", userData=d.serial)
|
|
186
|
+
idx = self._device_combo.findData(current)
|
|
187
|
+
if idx >= 0:
|
|
188
|
+
self._device_combo.setCurrentIndex(idx)
|
|
189
|
+
elif self._device_combo.count() > 0:
|
|
190
|
+
self._serial = self._device_combo.currentData()
|
|
191
|
+
self._device_combo.blockSignals(False)
|
|
192
|
+
|
|
193
|
+
def _on_device_changed(self) -> None:
|
|
194
|
+
self._serial = self._device_combo.currentData()
|
|
195
|
+
|
|
196
|
+
def _run_command(self) -> None:
|
|
197
|
+
cmd = self._cmd_edit.text().strip()
|
|
198
|
+
if not cmd:
|
|
199
|
+
return
|
|
200
|
+
if not self._serial:
|
|
201
|
+
self._append("No device selected.\n", error=True)
|
|
202
|
+
return
|
|
203
|
+
if self._worker and self._worker.isRunning():
|
|
204
|
+
self._append("Previous command still running…\n", error=True)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
self._cmd_edit.add_to_history(cmd)
|
|
208
|
+
self._cmd_edit.clear()
|
|
209
|
+
self._run_btn.setEnabled(False)
|
|
210
|
+
self._append(f"$ {cmd}\n", error=False, dim=True)
|
|
211
|
+
|
|
212
|
+
self._worker = ShellWorker(self._controller, self._serial, cmd, self)
|
|
213
|
+
self._worker.output.connect(self._on_output)
|
|
214
|
+
self._worker.finished.connect(lambda: self._run_btn.setEnabled(True))
|
|
215
|
+
self._worker.start()
|
|
216
|
+
|
|
217
|
+
@Slot(str, bool)
|
|
218
|
+
def _on_output(self, text: str, is_error: bool) -> None:
|
|
219
|
+
self._append(text + "\n", error=is_error)
|
|
220
|
+
|
|
221
|
+
def _append(self, text: str, error: bool = False, dim: bool = False) -> None:
|
|
222
|
+
fmt = QTextCharFormat()
|
|
223
|
+
if error:
|
|
224
|
+
fmt.setForeground(QColor(Colors.ERROR))
|
|
225
|
+
elif dim:
|
|
226
|
+
fmt.setForeground(QColor(Colors.TEXT_MUTED))
|
|
227
|
+
else:
|
|
228
|
+
fmt.setForeground(QColor(Colors.TEXT_PRIMARY))
|
|
229
|
+
|
|
230
|
+
cursor = self._output.textCursor()
|
|
231
|
+
cursor.movePosition(QTextCursor.MoveOperation.End)
|
|
232
|
+
cursor.insertText(text, fmt)
|
|
233
|
+
self._output.setTextCursor(cursor)
|
|
234
|
+
self._output.ensureCursorVisible()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""VoidRemote UI widgets."""
|
|
2
|
+
|
|
3
|
+
from voidremote.ui.widgets.device_card import DeviceCard, StatusDot, BatteryBar
|
|
4
|
+
from voidremote.ui.widgets.log_view import LogView, QtLogHandler
|
|
5
|
+
from voidremote.ui.widgets.metric_gauge import MetricGauge
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BatteryBar",
|
|
9
|
+
"DeviceCard",
|
|
10
|
+
"LogView",
|
|
11
|
+
"MetricGauge",
|
|
12
|
+
"QtLogHandler",
|
|
13
|
+
"StatusDot",
|
|
14
|
+
]
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Device card widget for the dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import Qt, Signal, QPropertyAnimation, QEasingCurve, Property
|
|
8
|
+
from PySide6.QtGui import QColor, QPainter, QPen, QBrush, QFont
|
|
9
|
+
from PySide6.QtWidgets import (
|
|
10
|
+
QFrame,
|
|
11
|
+
QHBoxLayout,
|
|
12
|
+
QLabel,
|
|
13
|
+
QProgressBar,
|
|
14
|
+
QPushButton,
|
|
15
|
+
QVBoxLayout,
|
|
16
|
+
QWidget,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from voidremote.models.device import Device, DeviceState
|
|
20
|
+
from voidremote.ui.theme import Colors
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BatteryBar(QWidget):
|
|
24
|
+
"""Compact battery level indicator."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
|
27
|
+
super().__init__(parent)
|
|
28
|
+
self._level = 0
|
|
29
|
+
self._charging = False
|
|
30
|
+
self.setFixedSize(28, 14)
|
|
31
|
+
|
|
32
|
+
def set_level(self, level: int, charging: bool = False) -> None:
|
|
33
|
+
self._level = max(0, min(100, level))
|
|
34
|
+
self._charging = charging
|
|
35
|
+
self.update()
|
|
36
|
+
|
|
37
|
+
def paintEvent(self, event: object) -> None: # type: ignore[override]
|
|
38
|
+
painter = QPainter(self)
|
|
39
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
40
|
+
|
|
41
|
+
# Outer shell
|
|
42
|
+
pen = QPen(QColor(Colors.BORDER))
|
|
43
|
+
pen.setWidth(1)
|
|
44
|
+
painter.setPen(pen)
|
|
45
|
+
painter.setBrush(QBrush(QColor(Colors.BG_ELEVATED)))
|
|
46
|
+
painter.drawRoundedRect(0, 0, 24, 14, 3, 3)
|
|
47
|
+
|
|
48
|
+
# Tip
|
|
49
|
+
painter.setBrush(QBrush(QColor(Colors.BORDER)))
|
|
50
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
51
|
+
painter.drawRoundedRect(24, 4, 3, 6, 1, 1)
|
|
52
|
+
|
|
53
|
+
# Fill
|
|
54
|
+
if self._level > 50:
|
|
55
|
+
color = QColor(Colors.BATTERY_FULL)
|
|
56
|
+
elif self._level > 20:
|
|
57
|
+
color = QColor(Colors.BATTERY_MID)
|
|
58
|
+
else:
|
|
59
|
+
color = QColor(Colors.BATTERY_LOW)
|
|
60
|
+
|
|
61
|
+
fill_width = int((22 * self._level) / 100)
|
|
62
|
+
if fill_width > 0:
|
|
63
|
+
painter.setBrush(QBrush(color))
|
|
64
|
+
painter.drawRoundedRect(1, 1, fill_width, 12, 2, 2)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class StatusDot(QWidget):
|
|
68
|
+
"""Animated status indicator dot."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
|
71
|
+
super().__init__(parent)
|
|
72
|
+
self._color = QColor(Colors.TEXT_MUTED)
|
|
73
|
+
self.setFixedSize(10, 10)
|
|
74
|
+
|
|
75
|
+
def set_state(self, state: DeviceState) -> None:
|
|
76
|
+
color_map = {
|
|
77
|
+
DeviceState.ONLINE: Colors.SUCCESS,
|
|
78
|
+
DeviceState.OFFLINE: Colors.ERROR,
|
|
79
|
+
DeviceState.UNAUTHORIZED: Colors.WARNING,
|
|
80
|
+
DeviceState.CONNECTING: Colors.INFO,
|
|
81
|
+
}
|
|
82
|
+
self._color = QColor(color_map.get(state, Colors.TEXT_MUTED))
|
|
83
|
+
self.update()
|
|
84
|
+
|
|
85
|
+
def paintEvent(self, event: object) -> None: # type: ignore[override]
|
|
86
|
+
painter = QPainter(self)
|
|
87
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
88
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
89
|
+
painter.setBrush(QBrush(self._color))
|
|
90
|
+
painter.drawEllipse(1, 1, 8, 8)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class DeviceCard(QFrame):
|
|
94
|
+
"""
|
|
95
|
+
Device card widget shown in the devices dashboard.
|
|
96
|
+
|
|
97
|
+
Emits:
|
|
98
|
+
connect_requested(serial): User clicked Connect.
|
|
99
|
+
disconnect_requested(serial): User clicked Disconnect.
|
|
100
|
+
mirror_requested(serial): User clicked Mirror.
|
|
101
|
+
info_requested(serial): User clicked Info / device name.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
connect_requested: Signal = Signal(str)
|
|
105
|
+
disconnect_requested: Signal = Signal(str)
|
|
106
|
+
mirror_requested: Signal = Signal(str)
|
|
107
|
+
info_requested: Signal = Signal(str)
|
|
108
|
+
|
|
109
|
+
def __init__(self, device: Device, parent: Optional[QWidget] = None) -> None:
|
|
110
|
+
super().__init__(parent)
|
|
111
|
+
self._device = device
|
|
112
|
+
self.setObjectName("DeviceCard")
|
|
113
|
+
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
114
|
+
self._build_ui()
|
|
115
|
+
self.refresh(device)
|
|
116
|
+
|
|
117
|
+
def _build_ui(self) -> None:
|
|
118
|
+
self.setFrameShape(QFrame.Shape.StyledPanel)
|
|
119
|
+
self.setMinimumHeight(140)
|
|
120
|
+
|
|
121
|
+
root = QVBoxLayout(self)
|
|
122
|
+
root.setContentsMargins(16, 14, 16, 14)
|
|
123
|
+
root.setSpacing(10)
|
|
124
|
+
|
|
125
|
+
# ── Header ──────────────────────────────────────
|
|
126
|
+
header = QHBoxLayout()
|
|
127
|
+
header.setSpacing(8)
|
|
128
|
+
|
|
129
|
+
self._status_dot = StatusDot()
|
|
130
|
+
header.addWidget(self._status_dot)
|
|
131
|
+
|
|
132
|
+
self._name_label = QLabel()
|
|
133
|
+
self._name_label.setStyleSheet(
|
|
134
|
+
f"font-size: 15px; font-weight: 700; color: {Colors.TEXT_PRIMARY};"
|
|
135
|
+
)
|
|
136
|
+
header.addWidget(self._name_label, stretch=1)
|
|
137
|
+
|
|
138
|
+
self._battery_bar = BatteryBar()
|
|
139
|
+
header.addWidget(self._battery_bar)
|
|
140
|
+
|
|
141
|
+
self._battery_label = QLabel()
|
|
142
|
+
self._battery_label.setStyleSheet(
|
|
143
|
+
f"font-size: 11px; color: {Colors.TEXT_SECONDARY}; min-width: 32px;"
|
|
144
|
+
)
|
|
145
|
+
header.addWidget(self._battery_label)
|
|
146
|
+
|
|
147
|
+
root.addLayout(header)
|
|
148
|
+
|
|
149
|
+
# ── Meta row ────────────────────────────────────
|
|
150
|
+
meta = QHBoxLayout()
|
|
151
|
+
meta.setSpacing(16)
|
|
152
|
+
|
|
153
|
+
self._serial_label = QLabel()
|
|
154
|
+
self._serial_label.setStyleSheet(
|
|
155
|
+
f"font-size: 11px; color: {Colors.TEXT_MUTED}; font-family: monospace;"
|
|
156
|
+
)
|
|
157
|
+
meta.addWidget(self._serial_label, stretch=1)
|
|
158
|
+
|
|
159
|
+
self._android_label = QLabel()
|
|
160
|
+
self._android_label.setStyleSheet(
|
|
161
|
+
f"font-size: 11px; color: {Colors.TEXT_SECONDARY};"
|
|
162
|
+
)
|
|
163
|
+
meta.addWidget(self._android_label)
|
|
164
|
+
|
|
165
|
+
root.addLayout(meta)
|
|
166
|
+
|
|
167
|
+
# ── Info row ────────────────────────────────────
|
|
168
|
+
info = QHBoxLayout()
|
|
169
|
+
info.setSpacing(20)
|
|
170
|
+
|
|
171
|
+
self._res_label = self._make_info_label("Resolution", "–")
|
|
172
|
+
self._ip_label = self._make_info_label("IP", "–")
|
|
173
|
+
self._type_label = self._make_info_label("Type", "–")
|
|
174
|
+
|
|
175
|
+
for lbl in (self._res_label, self._ip_label, self._type_label):
|
|
176
|
+
info.addWidget(lbl)
|
|
177
|
+
info.addStretch()
|
|
178
|
+
|
|
179
|
+
root.addLayout(info)
|
|
180
|
+
root.addStretch()
|
|
181
|
+
|
|
182
|
+
# ── Actions ─────────────────────────────────────
|
|
183
|
+
actions = QHBoxLayout()
|
|
184
|
+
actions.setSpacing(8)
|
|
185
|
+
actions.addStretch()
|
|
186
|
+
|
|
187
|
+
self._btn_info = QPushButton("Info")
|
|
188
|
+
self._btn_info.setFixedWidth(60)
|
|
189
|
+
self._btn_info.clicked.connect(lambda: self.info_requested.emit(self._device.serial))
|
|
190
|
+
actions.addWidget(self._btn_info)
|
|
191
|
+
|
|
192
|
+
self._btn_mirror = QPushButton("Mirror")
|
|
193
|
+
self._btn_mirror.setFixedWidth(66)
|
|
194
|
+
self._btn_mirror.clicked.connect(lambda: self.mirror_requested.emit(self._device.serial))
|
|
195
|
+
actions.addWidget(self._btn_mirror)
|
|
196
|
+
|
|
197
|
+
self._btn_connect = QPushButton("Connect")
|
|
198
|
+
self._btn_connect.setObjectName("accent")
|
|
199
|
+
self._btn_connect.setFixedWidth(80)
|
|
200
|
+
self._btn_connect.clicked.connect(self._on_connect_toggle)
|
|
201
|
+
actions.addWidget(self._btn_connect)
|
|
202
|
+
|
|
203
|
+
root.addLayout(actions)
|
|
204
|
+
|
|
205
|
+
def _make_info_label(self, title: str, value: str) -> QLabel:
|
|
206
|
+
label = QLabel(f"<span style='color:{Colors.TEXT_MUTED};font-size:10px'>{title}</span>"
|
|
207
|
+
f"<br><span style='color:{Colors.TEXT_PRIMARY};font-size:12px'>{value}</span>")
|
|
208
|
+
label.setTextFormat(Qt.TextFormat.RichText)
|
|
209
|
+
return label
|
|
210
|
+
|
|
211
|
+
def _on_connect_toggle(self) -> None:
|
|
212
|
+
if self._device.is_connected:
|
|
213
|
+
self.disconnect_requested.emit(self._device.serial)
|
|
214
|
+
else:
|
|
215
|
+
self.connect_requested.emit(self._device.serial)
|
|
216
|
+
|
|
217
|
+
def refresh(self, device: Device) -> None:
|
|
218
|
+
"""Update card contents from a new Device snapshot."""
|
|
219
|
+
self._device = device
|
|
220
|
+
info = device.info
|
|
221
|
+
|
|
222
|
+
self._status_dot.set_state(device.state)
|
|
223
|
+
self._name_label.setText(device.display_name)
|
|
224
|
+
self._serial_label.setText(device.serial)
|
|
225
|
+
self._android_label.setText(f"Android {info.android_version}")
|
|
226
|
+
|
|
227
|
+
batt = info.battery.level
|
|
228
|
+
self._battery_bar.set_level(batt, info.battery.is_charging)
|
|
229
|
+
batt_color = (
|
|
230
|
+
Colors.BATTERY_FULL if batt > 50
|
|
231
|
+
else Colors.BATTERY_MID if batt > 20
|
|
232
|
+
else Colors.BATTERY_LOW
|
|
233
|
+
)
|
|
234
|
+
self._battery_label.setText(f"<span style='color:{batt_color}'>{batt}%</span>")
|
|
235
|
+
self._battery_label.setTextFormat(Qt.TextFormat.RichText)
|
|
236
|
+
|
|
237
|
+
self._res_label.setText(
|
|
238
|
+
f"<span style='color:{Colors.TEXT_MUTED};font-size:10px'>Resolution</span>"
|
|
239
|
+
f"<br><span style='color:{Colors.TEXT_PRIMARY};font-size:12px'>{info.screen_resolution}</span>"
|
|
240
|
+
)
|
|
241
|
+
self._ip_label.setText(
|
|
242
|
+
f"<span style='color:{Colors.TEXT_MUTED};font-size:10px'>IP</span>"
|
|
243
|
+
f"<br><span style='color:{Colors.TEXT_PRIMARY};font-size:12px'>{info.network.ip_address or 'N/A'}</span>"
|
|
244
|
+
)
|
|
245
|
+
self._type_label.setText(
|
|
246
|
+
f"<span style='color:{Colors.TEXT_MUTED};font-size:10px'>Type</span>"
|
|
247
|
+
f"<br><span style='color:{Colors.TEXT_PRIMARY};font-size:12px'>{device.connection_type.value}</span>"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if device.is_connected:
|
|
251
|
+
self._btn_connect.setText("Disconnect")
|
|
252
|
+
self._btn_connect.setObjectName("danger")
|
|
253
|
+
else:
|
|
254
|
+
self._btn_connect.setText("Connect")
|
|
255
|
+
self._btn_connect.setObjectName("accent")
|
|
256
|
+
|
|
257
|
+
# Re-polish to pick up objectName style changes
|
|
258
|
+
self._btn_connect.style().unpolish(self._btn_connect)
|
|
259
|
+
self._btn_connect.style().polish(self._btn_connect)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Live scrolling log viewer widget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import Qt, QObject, Signal, Slot
|
|
9
|
+
from PySide6.QtGui import QTextCharFormat, QColor, QFont, QTextCursor
|
|
10
|
+
from PySide6.QtWidgets import (
|
|
11
|
+
QHBoxLayout,
|
|
12
|
+
QPlainTextEdit,
|
|
13
|
+
QPushButton,
|
|
14
|
+
QVBoxLayout,
|
|
15
|
+
QWidget,
|
|
16
|
+
QComboBox,
|
|
17
|
+
QLabel,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from voidremote.ui.theme import Colors
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QtLogHandler(logging.Handler, QObject):
|
|
24
|
+
"""
|
|
25
|
+
Logging handler that emits Qt signals so log records can be
|
|
26
|
+
displayed safely in the GUI from any thread.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
log_record = Signal(int, str)
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
logging.Handler.__init__(self)
|
|
33
|
+
QObject.__init__(self)
|
|
34
|
+
|
|
35
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
36
|
+
try:
|
|
37
|
+
msg = self.format(record)
|
|
38
|
+
self.log_record.emit(record.levelno, msg)
|
|
39
|
+
except Exception:
|
|
40
|
+
self.handleError(record)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LogView(QWidget):
|
|
44
|
+
"""
|
|
45
|
+
Embeddable log viewer with level filtering, search, and auto-scroll.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
|
49
|
+
super().__init__(parent)
|
|
50
|
+
self._handler: Optional[QtLogHandler] = None
|
|
51
|
+
self._max_lines = 2000
|
|
52
|
+
self._level_filter = logging.DEBUG
|
|
53
|
+
self._build_ui()
|
|
54
|
+
self._install_handler()
|
|
55
|
+
|
|
56
|
+
def _build_ui(self) -> None:
|
|
57
|
+
layout = QVBoxLayout(self)
|
|
58
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
59
|
+
layout.setSpacing(0)
|
|
60
|
+
|
|
61
|
+
# ── Toolbar ─────────────────────────────────────
|
|
62
|
+
toolbar = QHBoxLayout()
|
|
63
|
+
toolbar.setContentsMargins(8, 6, 8, 6)
|
|
64
|
+
toolbar.setSpacing(8)
|
|
65
|
+
|
|
66
|
+
toolbar.addWidget(QLabel("Level:"))
|
|
67
|
+
|
|
68
|
+
self._level_combo = QComboBox()
|
|
69
|
+
self._level_combo.addItems(["DEBUG", "INFO", "WARNING", "ERROR"])
|
|
70
|
+
self._level_combo.setCurrentText("INFO")
|
|
71
|
+
self._level_combo.setFixedWidth(100)
|
|
72
|
+
self._level_combo.currentTextChanged.connect(self._on_level_changed)
|
|
73
|
+
toolbar.addWidget(self._level_combo)
|
|
74
|
+
|
|
75
|
+
toolbar.addStretch()
|
|
76
|
+
|
|
77
|
+
self._auto_scroll_btn = QPushButton("Auto-scroll: ON")
|
|
78
|
+
self._auto_scroll_btn.setCheckable(True)
|
|
79
|
+
self._auto_scroll_btn.setChecked(True)
|
|
80
|
+
self._auto_scroll_btn.setFixedWidth(120)
|
|
81
|
+
self._auto_scroll_btn.clicked.connect(self._toggle_autoscroll)
|
|
82
|
+
toolbar.addWidget(self._auto_scroll_btn)
|
|
83
|
+
|
|
84
|
+
btn_clear = QPushButton("Clear")
|
|
85
|
+
btn_clear.setFixedWidth(60)
|
|
86
|
+
btn_clear.clicked.connect(self.clear)
|
|
87
|
+
toolbar.addWidget(btn_clear)
|
|
88
|
+
|
|
89
|
+
toolbar_widget = QWidget()
|
|
90
|
+
toolbar_widget.setStyleSheet(
|
|
91
|
+
f"background: {Colors.BG_SURFACE}; border-bottom: 1px solid {Colors.BORDER};"
|
|
92
|
+
)
|
|
93
|
+
toolbar_widget.setLayout(toolbar)
|
|
94
|
+
layout.addWidget(toolbar_widget)
|
|
95
|
+
|
|
96
|
+
# ── Text area ───────────────────────────────────
|
|
97
|
+
self._text = QPlainTextEdit()
|
|
98
|
+
self._text.setObjectName("LogView")
|
|
99
|
+
self._text.setReadOnly(True)
|
|
100
|
+
self._text.setMaximumBlockCount(self._max_lines)
|
|
101
|
+
font = QFont("JetBrains Mono, Cascadia Code, Fira Code, Consolas, monospace", 12)
|
|
102
|
+
self._text.setFont(font)
|
|
103
|
+
layout.addWidget(self._text, stretch=1)
|
|
104
|
+
|
|
105
|
+
self._auto_scroll = True
|
|
106
|
+
|
|
107
|
+
def _install_handler(self) -> None:
|
|
108
|
+
handler = QtLogHandler()
|
|
109
|
+
handler.setFormatter(
|
|
110
|
+
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
|
111
|
+
datefmt="%H:%M:%S")
|
|
112
|
+
)
|
|
113
|
+
handler.log_record.connect(self._append_record)
|
|
114
|
+
self._handler = handler
|
|
115
|
+
logging.getLogger().addHandler(handler)
|
|
116
|
+
|
|
117
|
+
def remove_handler(self) -> None:
|
|
118
|
+
if self._handler:
|
|
119
|
+
logging.getLogger().removeHandler(self._handler)
|
|
120
|
+
|
|
121
|
+
@Slot(int, str)
|
|
122
|
+
def _append_record(self, level: int, message: str) -> None:
|
|
123
|
+
if level < self._level_filter:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
fmt = QTextCharFormat()
|
|
127
|
+
if level >= logging.CRITICAL:
|
|
128
|
+
fmt.setForeground(QColor(Colors.ERROR))
|
|
129
|
+
fmt.setFontWeight(QFont.Weight.Bold)
|
|
130
|
+
elif level >= logging.ERROR:
|
|
131
|
+
fmt.setForeground(QColor(Colors.ERROR))
|
|
132
|
+
elif level >= logging.WARNING:
|
|
133
|
+
fmt.setForeground(QColor(Colors.WARNING))
|
|
134
|
+
elif level >= logging.INFO:
|
|
135
|
+
fmt.setForeground(QColor(Colors.TEXT_PRIMARY))
|
|
136
|
+
else:
|
|
137
|
+
fmt.setForeground(QColor(Colors.TEXT_MUTED))
|
|
138
|
+
|
|
139
|
+
cursor = self._text.textCursor()
|
|
140
|
+
cursor.movePosition(QTextCursor.MoveOperation.End)
|
|
141
|
+
cursor.insertText(message + "\n", fmt)
|
|
142
|
+
|
|
143
|
+
if self._auto_scroll:
|
|
144
|
+
self._text.setTextCursor(cursor)
|
|
145
|
+
self._text.ensureCursorVisible()
|
|
146
|
+
|
|
147
|
+
def _on_level_changed(self, text: str) -> None:
|
|
148
|
+
self._level_filter = getattr(logging, text, logging.DEBUG)
|
|
149
|
+
|
|
150
|
+
def _toggle_autoscroll(self, checked: bool) -> None:
|
|
151
|
+
self._auto_scroll = checked
|
|
152
|
+
self._auto_scroll_btn.setText(f"Auto-scroll: {'ON' if checked else 'OFF'}")
|
|
153
|
+
|
|
154
|
+
def clear(self) -> None:
|
|
155
|
+
self._text.clear()
|
|
156
|
+
|
|
157
|
+
def append(self, message: str, level: int = logging.INFO) -> None:
|
|
158
|
+
"""Manually append a message (also respects level filter)."""
|
|
159
|
+
self._append_record(level, message)
|