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,233 @@
|
|
|
1
|
+
"""Dashboard view — device grid with refresh and quick actions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import Qt, QThread, Signal, Slot, QTimer
|
|
8
|
+
from PySide6.QtWidgets import (
|
|
9
|
+
QFrame,
|
|
10
|
+
QGridLayout,
|
|
11
|
+
QHBoxLayout,
|
|
12
|
+
QLabel,
|
|
13
|
+
QPushButton,
|
|
14
|
+
QScrollArea,
|
|
15
|
+
QSizePolicy,
|
|
16
|
+
QVBoxLayout,
|
|
17
|
+
QWidget,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from voidremote.models.device import Device
|
|
21
|
+
from voidremote.ui.theme import Colors
|
|
22
|
+
from voidremote.ui.widgets.device_card import DeviceCard
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RefreshWorker(QThread):
|
|
26
|
+
finished = Signal(list)
|
|
27
|
+
error = Signal(str)
|
|
28
|
+
|
|
29
|
+
def __init__(self, controller: object, parent: Optional[object] = None) -> None:
|
|
30
|
+
super().__init__(parent)
|
|
31
|
+
self._controller = controller
|
|
32
|
+
|
|
33
|
+
def run(self) -> None:
|
|
34
|
+
try:
|
|
35
|
+
devices = self._controller.list_devices(refresh=True)
|
|
36
|
+
self.finished.emit(devices)
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
self.error.emit(str(exc))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DashboardView(QWidget):
|
|
42
|
+
"""
|
|
43
|
+
Main dashboard showing device cards in a responsive grid.
|
|
44
|
+
|
|
45
|
+
Signals:
|
|
46
|
+
connect_device(host, port): Request connection to device.
|
|
47
|
+
disconnect_device(serial): Request disconnect.
|
|
48
|
+
mirror_device(serial): Request screen mirror.
|
|
49
|
+
show_device_info(serial): Request info panel.
|
|
50
|
+
pair_device(): Open pair dialog.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
connect_device: Signal = Signal(str, int)
|
|
54
|
+
disconnect_device: Signal = Signal(str)
|
|
55
|
+
mirror_device: Signal = Signal(str)
|
|
56
|
+
show_device_info: Signal = Signal(str)
|
|
57
|
+
pair_device: Signal = Signal()
|
|
58
|
+
|
|
59
|
+
def __init__(self, controller: object, parent: Optional[QWidget] = None) -> None:
|
|
60
|
+
super().__init__(parent)
|
|
61
|
+
self._controller = controller
|
|
62
|
+
self._cards: dict[str, DeviceCard] = {}
|
|
63
|
+
self._worker: Optional[RefreshWorker] = None
|
|
64
|
+
self._build_ui()
|
|
65
|
+
self._auto_refresh = QTimer(self)
|
|
66
|
+
self._auto_refresh.setInterval(5000)
|
|
67
|
+
self._auto_refresh.timeout.connect(self.refresh)
|
|
68
|
+
|
|
69
|
+
def _build_ui(self) -> None:
|
|
70
|
+
root = QVBoxLayout(self)
|
|
71
|
+
root.setContentsMargins(0, 0, 0, 0)
|
|
72
|
+
root.setSpacing(0)
|
|
73
|
+
|
|
74
|
+
# ── Header bar ──────────────────────────────────
|
|
75
|
+
header = QHBoxLayout()
|
|
76
|
+
header.setContentsMargins(24, 20, 24, 16)
|
|
77
|
+
header.setSpacing(12)
|
|
78
|
+
|
|
79
|
+
title = QLabel("Devices")
|
|
80
|
+
title.setStyleSheet(
|
|
81
|
+
f"font-size: 22px; font-weight: 700; color: {Colors.TEXT_PRIMARY};"
|
|
82
|
+
)
|
|
83
|
+
header.addWidget(title)
|
|
84
|
+
header.addStretch()
|
|
85
|
+
|
|
86
|
+
self._count_label = QLabel("0 devices")
|
|
87
|
+
self._count_label.setStyleSheet(f"color: {Colors.TEXT_MUTED}; font-size: 13px;")
|
|
88
|
+
header.addWidget(self._count_label)
|
|
89
|
+
|
|
90
|
+
btn_pair = QPushButton("+ Pair")
|
|
91
|
+
btn_pair.setObjectName("accent")
|
|
92
|
+
btn_pair.setFixedWidth(80)
|
|
93
|
+
btn_pair.clicked.connect(self.pair_device)
|
|
94
|
+
header.addWidget(btn_pair)
|
|
95
|
+
|
|
96
|
+
self._btn_refresh = QPushButton("↻ Refresh")
|
|
97
|
+
self._btn_refresh.setFixedWidth(90)
|
|
98
|
+
self._btn_refresh.clicked.connect(self.refresh)
|
|
99
|
+
header.addWidget(self._btn_refresh)
|
|
100
|
+
|
|
101
|
+
header_widget = QWidget()
|
|
102
|
+
header_widget.setStyleSheet(
|
|
103
|
+
f"background: {Colors.BG_SURFACE}; border-bottom: 1px solid {Colors.BORDER};"
|
|
104
|
+
)
|
|
105
|
+
header_widget.setLayout(header)
|
|
106
|
+
root.addWidget(header_widget)
|
|
107
|
+
|
|
108
|
+
# ── Scroll area with device grid ─────────────────
|
|
109
|
+
scroll = QScrollArea()
|
|
110
|
+
scroll.setWidgetResizable(True)
|
|
111
|
+
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
|
112
|
+
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
113
|
+
|
|
114
|
+
self._grid_container = QWidget()
|
|
115
|
+
self._grid_container.setStyleSheet(f"background: {Colors.BG_BASE};")
|
|
116
|
+
self._grid = QGridLayout(self._grid_container)
|
|
117
|
+
self._grid.setContentsMargins(24, 20, 24, 20)
|
|
118
|
+
self._grid.setSpacing(16)
|
|
119
|
+
self._grid.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
|
120
|
+
|
|
121
|
+
scroll.setWidget(self._grid_container)
|
|
122
|
+
root.addWidget(scroll, stretch=1)
|
|
123
|
+
|
|
124
|
+
# ── Empty state ──────────────────────────────────
|
|
125
|
+
self._empty_state = QWidget()
|
|
126
|
+
empty_layout = QVBoxLayout(self._empty_state)
|
|
127
|
+
empty_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
128
|
+
|
|
129
|
+
empty_icon = QLabel("📱")
|
|
130
|
+
empty_icon.setStyleSheet("font-size: 56px;")
|
|
131
|
+
empty_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
132
|
+
empty_layout.addWidget(empty_icon)
|
|
133
|
+
|
|
134
|
+
empty_title = QLabel("No devices connected")
|
|
135
|
+
empty_title.setStyleSheet(
|
|
136
|
+
f"font-size: 18px; font-weight: 600; color: {Colors.TEXT_SECONDARY}; margin-top: 12px;"
|
|
137
|
+
)
|
|
138
|
+
empty_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
139
|
+
empty_layout.addWidget(empty_title)
|
|
140
|
+
|
|
141
|
+
empty_hint = QLabel(
|
|
142
|
+
"Enable Wireless Debugging on your Android device,\n"
|
|
143
|
+
"then click '+ Pair' to get started."
|
|
144
|
+
)
|
|
145
|
+
empty_hint.setStyleSheet(f"color: {Colors.TEXT_MUTED}; font-size: 13px; margin-top: 6px;")
|
|
146
|
+
empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
147
|
+
empty_layout.addWidget(empty_hint)
|
|
148
|
+
|
|
149
|
+
btn_pair2 = QPushButton("Pair New Device")
|
|
150
|
+
btn_pair2.setObjectName("accent")
|
|
151
|
+
btn_pair2.setFixedWidth(160)
|
|
152
|
+
btn_pair2.clicked.connect(self.pair_device)
|
|
153
|
+
btn_pair2_wrap = QHBoxLayout()
|
|
154
|
+
btn_pair2_wrap.addStretch()
|
|
155
|
+
btn_pair2_wrap.addWidget(btn_pair2)
|
|
156
|
+
btn_pair2_wrap.addStretch()
|
|
157
|
+
empty_layout.addLayout(btn_pair2_wrap)
|
|
158
|
+
|
|
159
|
+
scroll.setWidget(self._grid_container)
|
|
160
|
+
|
|
161
|
+
# Stack grid + empty state inside scroll
|
|
162
|
+
self._stack_widget = QWidget()
|
|
163
|
+
self._stack_widget.setStyleSheet(f"background: {Colors.BG_BASE};")
|
|
164
|
+
stack_layout = QVBoxLayout(self._stack_widget)
|
|
165
|
+
stack_layout.setContentsMargins(0, 0, 0, 0)
|
|
166
|
+
stack_layout.addWidget(self._grid_container)
|
|
167
|
+
stack_layout.addWidget(self._empty_state)
|
|
168
|
+
|
|
169
|
+
scroll.setWidget(self._stack_widget)
|
|
170
|
+
self._empty_state.hide()
|
|
171
|
+
|
|
172
|
+
def start_auto_refresh(self) -> None:
|
|
173
|
+
self._auto_refresh.start()
|
|
174
|
+
|
|
175
|
+
def stop_auto_refresh(self) -> None:
|
|
176
|
+
self._auto_refresh.stop()
|
|
177
|
+
|
|
178
|
+
def refresh(self) -> None:
|
|
179
|
+
if self._worker and self._worker.isRunning():
|
|
180
|
+
return
|
|
181
|
+
self._btn_refresh.setEnabled(False)
|
|
182
|
+
self._btn_refresh.setText("Refreshing…")
|
|
183
|
+
self._worker = RefreshWorker(self._controller, self)
|
|
184
|
+
self._worker.finished.connect(self._on_devices_loaded)
|
|
185
|
+
self._worker.error.connect(self._on_refresh_error)
|
|
186
|
+
self._worker.start()
|
|
187
|
+
|
|
188
|
+
@Slot(list)
|
|
189
|
+
def _on_devices_loaded(self, devices: list) -> None:
|
|
190
|
+
self._btn_refresh.setEnabled(True)
|
|
191
|
+
self._btn_refresh.setText("↻ Refresh")
|
|
192
|
+
self._update_grid(devices)
|
|
193
|
+
count = len(devices)
|
|
194
|
+
self._count_label.setText(f"{count} device{'s' if count != 1 else ''}")
|
|
195
|
+
|
|
196
|
+
@Slot(str)
|
|
197
|
+
def _on_refresh_error(self, error: str) -> None:
|
|
198
|
+
self._btn_refresh.setEnabled(True)
|
|
199
|
+
self._btn_refresh.setText("↻ Refresh")
|
|
200
|
+
self._count_label.setText(f"Error: {error[:40]}")
|
|
201
|
+
|
|
202
|
+
def _update_grid(self, devices: list) -> None:
|
|
203
|
+
existing_serials = set(self._cards.keys())
|
|
204
|
+
new_serials = {d.serial for d in devices}
|
|
205
|
+
|
|
206
|
+
# Remove stale cards
|
|
207
|
+
for serial in existing_serials - new_serials:
|
|
208
|
+
card = self._cards.pop(serial)
|
|
209
|
+
self._grid.removeWidget(card)
|
|
210
|
+
card.deleteLater()
|
|
211
|
+
|
|
212
|
+
# Add or update cards
|
|
213
|
+
for device in devices:
|
|
214
|
+
if device.serial in self._cards:
|
|
215
|
+
self._cards[device.serial].refresh(device)
|
|
216
|
+
else:
|
|
217
|
+
card = DeviceCard(device)
|
|
218
|
+
card.connect_requested.connect(
|
|
219
|
+
lambda s: self.connect_device.emit(s.split(":")[0], int(s.split(":")[1]) if ":" in s else 5555)
|
|
220
|
+
)
|
|
221
|
+
card.disconnect_requested.connect(self.disconnect_device)
|
|
222
|
+
card.mirror_requested.connect(self.mirror_device)
|
|
223
|
+
card.info_requested.connect(self.show_device_info)
|
|
224
|
+
self._cards[device.serial] = card
|
|
225
|
+
|
|
226
|
+
# Re-layout grid (2 columns)
|
|
227
|
+
for i, (serial, card) in enumerate(self._cards.items()):
|
|
228
|
+
self._grid.addWidget(card, i // 2, i % 2)
|
|
229
|
+
card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
230
|
+
|
|
231
|
+
has_devices = bool(self._cards)
|
|
232
|
+
self._grid_container.setVisible(has_devices)
|
|
233
|
+
self._empty_state.setVisible(not has_devices)
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""File manager view for browsing and transferring device files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import Qt, QThread, Signal, Slot
|
|
10
|
+
from PySide6.QtWidgets import (
|
|
11
|
+
QAbstractItemView,
|
|
12
|
+
QComboBox,
|
|
13
|
+
QFileDialog,
|
|
14
|
+
QHBoxLayout,
|
|
15
|
+
QHeaderView,
|
|
16
|
+
QLabel,
|
|
17
|
+
QLineEdit,
|
|
18
|
+
QProgressBar,
|
|
19
|
+
QPushButton,
|
|
20
|
+
QSplitter,
|
|
21
|
+
QTableWidget,
|
|
22
|
+
QTableWidgetItem,
|
|
23
|
+
QVBoxLayout,
|
|
24
|
+
QWidget,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from voidremote.ui.theme import Colors
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ListDirWorker(QThread):
|
|
33
|
+
finished = Signal(list)
|
|
34
|
+
error = Signal(str)
|
|
35
|
+
|
|
36
|
+
def __init__(self, controller: object, serial: str, path: str, parent: Optional[object] = None) -> None:
|
|
37
|
+
super().__init__(parent)
|
|
38
|
+
self._controller = controller
|
|
39
|
+
self._serial = serial
|
|
40
|
+
self._path = path
|
|
41
|
+
|
|
42
|
+
def run(self) -> None:
|
|
43
|
+
try:
|
|
44
|
+
raw = self._controller.shell(self._serial, f"ls -la '{self._path}'")
|
|
45
|
+
entries = self._parse_ls(raw)
|
|
46
|
+
self.finished.emit(entries)
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
self.error.emit(str(exc))
|
|
49
|
+
|
|
50
|
+
def _parse_ls(self, raw: str) -> list[dict]:
|
|
51
|
+
entries: list[dict] = []
|
|
52
|
+
for line in raw.splitlines():
|
|
53
|
+
parts = line.split(None, 8)
|
|
54
|
+
if len(parts) < 8:
|
|
55
|
+
continue
|
|
56
|
+
perms = parts[0]
|
|
57
|
+
size = parts[4] if len(parts) > 4 else "0"
|
|
58
|
+
name = parts[8] if len(parts) > 8 else parts[-1]
|
|
59
|
+
if name in (".", ".."):
|
|
60
|
+
continue
|
|
61
|
+
entries.append({
|
|
62
|
+
"name": name,
|
|
63
|
+
"is_dir": perms.startswith("d"),
|
|
64
|
+
"size": size,
|
|
65
|
+
"permissions": perms,
|
|
66
|
+
})
|
|
67
|
+
return entries
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TransferWorker(QThread):
|
|
71
|
+
progress = Signal(int)
|
|
72
|
+
finished = Signal()
|
|
73
|
+
error = Signal(str)
|
|
74
|
+
|
|
75
|
+
def __init__(self, controller: object, serial: str, mode: str,
|
|
76
|
+
local: str, remote: str, parent: Optional[object] = None) -> None:
|
|
77
|
+
super().__init__(parent)
|
|
78
|
+
self._controller = controller
|
|
79
|
+
self._serial = serial
|
|
80
|
+
self._mode = mode
|
|
81
|
+
self._local = local
|
|
82
|
+
self._remote = remote
|
|
83
|
+
|
|
84
|
+
def run(self) -> None:
|
|
85
|
+
try:
|
|
86
|
+
if self._mode == "push":
|
|
87
|
+
self._controller.push_file(self._serial, Path(self._local), self._remote)
|
|
88
|
+
else:
|
|
89
|
+
self._controller.pull_file(self._serial, self._remote, Path(self._local))
|
|
90
|
+
self.progress.emit(100)
|
|
91
|
+
self.finished.emit()
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
self.error.emit(str(exc))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class FilesView(QWidget):
|
|
97
|
+
"""ADB file manager with navigation, upload, and download."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, controller: object, parent: Optional[QWidget] = None) -> None:
|
|
100
|
+
super().__init__(parent)
|
|
101
|
+
self._controller = controller
|
|
102
|
+
self._serial: Optional[str] = None
|
|
103
|
+
self._current_path = "/sdcard"
|
|
104
|
+
self._path_history: list[str] = []
|
|
105
|
+
self._worker: Optional[QThread] = None
|
|
106
|
+
self._build_ui()
|
|
107
|
+
|
|
108
|
+
def _build_ui(self) -> None:
|
|
109
|
+
root = QVBoxLayout(self)
|
|
110
|
+
root.setContentsMargins(0, 0, 0, 0)
|
|
111
|
+
root.setSpacing(0)
|
|
112
|
+
|
|
113
|
+
# ── Header ──────────────────────────────────────
|
|
114
|
+
header = QHBoxLayout()
|
|
115
|
+
header.setContentsMargins(24, 16, 24, 12)
|
|
116
|
+
header.setSpacing(10)
|
|
117
|
+
|
|
118
|
+
title = QLabel("Files")
|
|
119
|
+
title.setStyleSheet(f"font-size: 22px; font-weight: 700; color: {Colors.TEXT_PRIMARY};")
|
|
120
|
+
header.addWidget(title)
|
|
121
|
+
header.addStretch()
|
|
122
|
+
|
|
123
|
+
header.addWidget(QLabel("Device:"))
|
|
124
|
+
self._device_combo = QComboBox()
|
|
125
|
+
self._device_combo.setMinimumWidth(200)
|
|
126
|
+
self._device_combo.currentTextChanged.connect(self._on_device_changed)
|
|
127
|
+
header.addWidget(self._device_combo)
|
|
128
|
+
|
|
129
|
+
header_w = QWidget()
|
|
130
|
+
header_w.setStyleSheet(f"background: {Colors.BG_SURFACE}; border-bottom: 1px solid {Colors.BORDER};")
|
|
131
|
+
header_w.setLayout(header)
|
|
132
|
+
root.addWidget(header_w)
|
|
133
|
+
|
|
134
|
+
# ── Path bar ─────────────────────────────────────
|
|
135
|
+
path_bar = QHBoxLayout()
|
|
136
|
+
path_bar.setContentsMargins(16, 8, 16, 8)
|
|
137
|
+
path_bar.setSpacing(8)
|
|
138
|
+
|
|
139
|
+
self._back_btn = QPushButton("←")
|
|
140
|
+
self._back_btn.setFixedWidth(36)
|
|
141
|
+
self._back_btn.clicked.connect(self._go_back)
|
|
142
|
+
path_bar.addWidget(self._back_btn)
|
|
143
|
+
|
|
144
|
+
self._path_edit = QLineEdit(self._current_path)
|
|
145
|
+
self._path_edit.returnPressed.connect(self._navigate_to_path)
|
|
146
|
+
path_bar.addWidget(self._path_edit, stretch=1)
|
|
147
|
+
|
|
148
|
+
btn_go = QPushButton("Go")
|
|
149
|
+
btn_go.setFixedWidth(40)
|
|
150
|
+
btn_go.clicked.connect(self._navigate_to_path)
|
|
151
|
+
path_bar.addWidget(btn_go)
|
|
152
|
+
|
|
153
|
+
btn_upload = QPushButton("↑ Upload")
|
|
154
|
+
btn_upload.clicked.connect(self._upload_file)
|
|
155
|
+
path_bar.addWidget(btn_upload)
|
|
156
|
+
|
|
157
|
+
btn_download = QPushButton("↓ Download")
|
|
158
|
+
btn_download.clicked.connect(self._download_selected)
|
|
159
|
+
path_bar.addWidget(btn_download)
|
|
160
|
+
|
|
161
|
+
btn_refresh = QPushButton("↻")
|
|
162
|
+
btn_refresh.setFixedWidth(36)
|
|
163
|
+
btn_refresh.clicked.connect(self._load_directory)
|
|
164
|
+
path_bar.addWidget(btn_refresh)
|
|
165
|
+
|
|
166
|
+
path_bar_w = QWidget()
|
|
167
|
+
path_bar_w.setStyleSheet(f"background: {Colors.BG_ELEVATED}; border-bottom: 1px solid {Colors.BORDER};")
|
|
168
|
+
path_bar_w.setLayout(path_bar)
|
|
169
|
+
root.addWidget(path_bar_w)
|
|
170
|
+
|
|
171
|
+
# ── File table ───────────────────────────────────
|
|
172
|
+
self._table = QTableWidget(0, 4)
|
|
173
|
+
self._table.setHorizontalHeaderLabels(["Name", "Type", "Size", "Permissions"])
|
|
174
|
+
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
|
175
|
+
self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
|
176
|
+
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
|
177
|
+
self._table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
|
178
|
+
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
179
|
+
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
180
|
+
self._table.setAlternatingRowColors(True)
|
|
181
|
+
self._table.doubleClicked.connect(self._on_double_click)
|
|
182
|
+
root.addWidget(self._table, stretch=1)
|
|
183
|
+
|
|
184
|
+
# ── Progress bar ─────────────────────────────────
|
|
185
|
+
self._progress = QProgressBar()
|
|
186
|
+
self._progress.hide()
|
|
187
|
+
root.addWidget(self._progress)
|
|
188
|
+
|
|
189
|
+
# ── Status ───────────────────────────────────────
|
|
190
|
+
self._status = QLabel("Select a device to browse files")
|
|
191
|
+
self._status.setStyleSheet(
|
|
192
|
+
f"color: {Colors.TEXT_MUTED}; padding: 6px 24px; "
|
|
193
|
+
f"background: {Colors.BG_SURFACE}; border-top: 1px solid {Colors.BORDER};"
|
|
194
|
+
)
|
|
195
|
+
root.addWidget(self._status)
|
|
196
|
+
|
|
197
|
+
def set_devices(self, devices: list) -> None:
|
|
198
|
+
current = self._device_combo.currentData()
|
|
199
|
+
self._device_combo.blockSignals(True)
|
|
200
|
+
self._device_combo.clear()
|
|
201
|
+
for d in devices:
|
|
202
|
+
self._device_combo.addItem(f"{d.info.display_name} ({d.serial})", userData=d.serial)
|
|
203
|
+
idx = self._device_combo.findData(current)
|
|
204
|
+
if idx >= 0:
|
|
205
|
+
self._device_combo.setCurrentIndex(idx)
|
|
206
|
+
self._device_combo.blockSignals(False)
|
|
207
|
+
|
|
208
|
+
def _on_device_changed(self) -> None:
|
|
209
|
+
self._serial = self._device_combo.currentData()
|
|
210
|
+
if self._serial:
|
|
211
|
+
self._current_path = "/sdcard"
|
|
212
|
+
self._path_edit.setText(self._current_path)
|
|
213
|
+
self._load_directory()
|
|
214
|
+
|
|
215
|
+
def _navigate_to_path(self) -> None:
|
|
216
|
+
path = self._path_edit.text().strip()
|
|
217
|
+
if path:
|
|
218
|
+
self._path_history.append(self._current_path)
|
|
219
|
+
self._current_path = path
|
|
220
|
+
self._load_directory()
|
|
221
|
+
|
|
222
|
+
def _go_back(self) -> None:
|
|
223
|
+
if self._path_history:
|
|
224
|
+
self._current_path = self._path_history.pop()
|
|
225
|
+
self._path_edit.setText(self._current_path)
|
|
226
|
+
self._load_directory()
|
|
227
|
+
|
|
228
|
+
def _load_directory(self) -> None:
|
|
229
|
+
if not self._serial:
|
|
230
|
+
return
|
|
231
|
+
self._table.setRowCount(0)
|
|
232
|
+
self._status.setText(f"Loading {self._current_path}…")
|
|
233
|
+
w = ListDirWorker(self._controller, self._serial, self._current_path, self)
|
|
234
|
+
w.finished.connect(self._on_entries_loaded)
|
|
235
|
+
w.error.connect(lambda e: self._status.setText(f"Error: {e}"))
|
|
236
|
+
self._worker = w
|
|
237
|
+
w.start()
|
|
238
|
+
|
|
239
|
+
@Slot(list)
|
|
240
|
+
def _on_entries_loaded(self, entries: list) -> None:
|
|
241
|
+
self._table.setRowCount(len(entries))
|
|
242
|
+
for row, entry in enumerate(entries):
|
|
243
|
+
icon = "📁" if entry["is_dir"] else "📄"
|
|
244
|
+
self._table.setItem(row, 0, QTableWidgetItem(f"{icon} {entry['name']}"))
|
|
245
|
+
self._table.setItem(row, 1, QTableWidgetItem("Directory" if entry["is_dir"] else "File"))
|
|
246
|
+
self._table.setItem(row, 2, QTableWidgetItem(entry["size"]))
|
|
247
|
+
self._table.setItem(row, 3, QTableWidgetItem(entry["permissions"]))
|
|
248
|
+
# Store full entry data
|
|
249
|
+
self._table.item(row, 0).setData(Qt.ItemDataRole.UserRole, entry) # type: ignore[attr-defined]
|
|
250
|
+
self._status.setText(f"{len(entries)} items in {self._current_path}")
|
|
251
|
+
|
|
252
|
+
def _on_double_click(self, index: object) -> None:
|
|
253
|
+
row = self._table.currentRow()
|
|
254
|
+
if row < 0:
|
|
255
|
+
return
|
|
256
|
+
item = self._table.item(row, 0)
|
|
257
|
+
if item is None:
|
|
258
|
+
return
|
|
259
|
+
entry = item.data(Qt.ItemDataRole.UserRole)
|
|
260
|
+
if entry and entry.get("is_dir"):
|
|
261
|
+
self._path_history.append(self._current_path)
|
|
262
|
+
self._current_path = f"{self._current_path.rstrip('/')}/{entry['name']}"
|
|
263
|
+
self._path_edit.setText(self._current_path)
|
|
264
|
+
self._load_directory()
|
|
265
|
+
|
|
266
|
+
def _upload_file(self) -> None:
|
|
267
|
+
if not self._serial:
|
|
268
|
+
return
|
|
269
|
+
path, _ = QFileDialog.getOpenFileName(self, "Select file to upload")
|
|
270
|
+
if not path:
|
|
271
|
+
return
|
|
272
|
+
remote = f"{self._current_path.rstrip('/')}/{Path(path).name}"
|
|
273
|
+
self._progress.setRange(0, 0)
|
|
274
|
+
self._progress.show()
|
|
275
|
+
w = TransferWorker(self._controller, self._serial, "push", path, remote, self)
|
|
276
|
+
w.finished.connect(lambda: (self._progress.hide(), self._load_directory()))
|
|
277
|
+
w.error.connect(lambda e: (self._progress.hide(), self._status.setText(f"Upload error: {e}")))
|
|
278
|
+
self._worker = w
|
|
279
|
+
w.start()
|
|
280
|
+
|
|
281
|
+
def _download_selected(self) -> None:
|
|
282
|
+
if not self._serial:
|
|
283
|
+
return
|
|
284
|
+
row = self._table.currentRow()
|
|
285
|
+
if row < 0:
|
|
286
|
+
return
|
|
287
|
+
item = self._table.item(row, 0)
|
|
288
|
+
if item is None:
|
|
289
|
+
return
|
|
290
|
+
entry = item.data(Qt.ItemDataRole.UserRole)
|
|
291
|
+
if not entry or entry.get("is_dir"):
|
|
292
|
+
self._status.setText("Select a file to download (not a directory)")
|
|
293
|
+
return
|
|
294
|
+
remote = f"{self._current_path.rstrip('/')}/{entry['name']}"
|
|
295
|
+
local_dir = QFileDialog.getExistingDirectory(self, "Save to folder")
|
|
296
|
+
if not local_dir:
|
|
297
|
+
return
|
|
298
|
+
local = str(Path(local_dir) / entry["name"])
|
|
299
|
+
self._progress.setRange(0, 0)
|
|
300
|
+
self._progress.show()
|
|
301
|
+
w = TransferWorker(self._controller, self._serial, "pull", local, remote, self)
|
|
302
|
+
w.finished.connect(lambda: (self._progress.hide(), self._status.setText(f"Downloaded to {local}")))
|
|
303
|
+
w.error.connect(lambda e: (self._progress.hide(), self._status.setText(f"Download error: {e}")))
|
|
304
|
+
self._worker = w
|
|
305
|
+
w.start()
|