adbsshdeck 0.1.1__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.
- adbsshdeck-0.1.1.dist-info/LICENSE +21 -0
- adbsshdeck-0.1.1.dist-info/METADATA +136 -0
- adbsshdeck-0.1.1.dist-info/RECORD +29 -0
- adbsshdeck-0.1.1.dist-info/WHEEL +5 -0
- adbsshdeck-0.1.1.dist-info/entry_points.txt +2 -0
- adbsshdeck-0.1.1.dist-info/top_level.txt +1 -0
- devicedeck/__init__.py +4 -0
- devicedeck/__main__.py +4 -0
- devicedeck/app.py +45 -0
- devicedeck/config.py +130 -0
- devicedeck/services/__init__.py +1 -0
- devicedeck/services/adb_devices.py +74 -0
- devicedeck/services/commands.py +96 -0
- devicedeck/services/remote_clients.py +84 -0
- devicedeck/session.py +52 -0
- devicedeck/ui/__init__.py +1 -0
- devicedeck/ui/app_icon.py +46 -0
- devicedeck/ui/combo_utils.py +45 -0
- devicedeck/ui/first_run_dialog.py +107 -0
- devicedeck/ui/icon_utils.py +144 -0
- devicedeck/ui/main_window.py +691 -0
- devicedeck/ui/preferences_dialog.py +122 -0
- devicedeck/ui/session_login_dialog.py +795 -0
- devicedeck/ui/styles.py +1021 -0
- devicedeck/ui/tabs/__init__.py +1 -0
- devicedeck/ui/tabs/file_explorer_tab.py +3680 -0
- devicedeck/ui/tabs/scrcpy_tab.py +770 -0
- devicedeck/ui/tabs/terminal_tab.py +1192 -0
- devicedeck/ui/win_scrcpy_hotkey.py +76 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Callable, Optional
|
|
4
|
+
|
|
5
|
+
from PyQt5.QtCore import QProcess, QProcessEnvironment, Qt, QTimer
|
|
6
|
+
from PyQt5.QtGui import QFont
|
|
7
|
+
from PyQt5.QtWidgets import (
|
|
8
|
+
QCheckBox,
|
|
9
|
+
QGridLayout,
|
|
10
|
+
QGroupBox,
|
|
11
|
+
QHBoxLayout,
|
|
12
|
+
QLabel,
|
|
13
|
+
QLineEdit,
|
|
14
|
+
QMessageBox,
|
|
15
|
+
QPushButton,
|
|
16
|
+
QSizePolicy,
|
|
17
|
+
QSplitter,
|
|
18
|
+
QStyle,
|
|
19
|
+
QToolButton,
|
|
20
|
+
QVBoxLayout,
|
|
21
|
+
QWidget,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from ... import APP_TITLE
|
|
25
|
+
from ...config import AppConfig
|
|
26
|
+
from ...services.adb_devices import list_adb_devices
|
|
27
|
+
from ..combo_utils import ExpandAllComboBox
|
|
28
|
+
from ..icon_utils import icon_media_play_green, icon_media_stop_red
|
|
29
|
+
from ...services.commands import run_adb
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _win_find_window_by_title(title: str) -> int:
|
|
33
|
+
"""Return HWND of top-level visible window whose title equals `title`, or 0."""
|
|
34
|
+
if sys.platform != "win32" or not title:
|
|
35
|
+
return 0
|
|
36
|
+
try:
|
|
37
|
+
import ctypes
|
|
38
|
+
|
|
39
|
+
user32 = ctypes.windll.user32
|
|
40
|
+
hwnd = user32.FindWindowW(None, title)
|
|
41
|
+
if hwnd:
|
|
42
|
+
return int(hwnd)
|
|
43
|
+
except Exception:
|
|
44
|
+
return 0
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _win_find_window_title_contains(sub: str) -> int:
|
|
49
|
+
if sys.platform != "win32" or not sub:
|
|
50
|
+
return 0
|
|
51
|
+
try:
|
|
52
|
+
import ctypes
|
|
53
|
+
from ctypes import wintypes
|
|
54
|
+
|
|
55
|
+
user32 = ctypes.windll.user32
|
|
56
|
+
found = ctypes.c_void_p(0)
|
|
57
|
+
sub_l = sub.lower()
|
|
58
|
+
|
|
59
|
+
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
60
|
+
def _enum(hwnd, _lp):
|
|
61
|
+
if not user32.IsWindowVisible(hwnd):
|
|
62
|
+
return True
|
|
63
|
+
buf = ctypes.create_unicode_buffer(1024)
|
|
64
|
+
user32.GetWindowTextW(hwnd, buf, 1024)
|
|
65
|
+
t = (buf.value or "").lower()
|
|
66
|
+
if sub_l in t:
|
|
67
|
+
found.value = hwnd
|
|
68
|
+
return False
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
user32.EnumWindows(_enum, 0)
|
|
72
|
+
return int(found.value or 0)
|
|
73
|
+
except Exception:
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _win_embed_hwnd_into_widget(hwnd: int, parent: QWidget) -> bool:
|
|
78
|
+
"""Re-parent a native window into `parent` and stretch it to fill (Windows)."""
|
|
79
|
+
if sys.platform != "win32" or not hwnd or parent is None:
|
|
80
|
+
return False
|
|
81
|
+
try:
|
|
82
|
+
import ctypes
|
|
83
|
+
|
|
84
|
+
user32 = ctypes.windll.user32
|
|
85
|
+
ph = int(parent.winId())
|
|
86
|
+
user32.SetParent(hwnd, ph)
|
|
87
|
+
GWL_STYLE = -16
|
|
88
|
+
WS_CHILD = 0x40000000
|
|
89
|
+
WS_POPUP = int(0x80000000)
|
|
90
|
+
style = user32.GetWindowLongW(hwnd, GWL_STYLE)
|
|
91
|
+
style = (style | WS_CHILD) & ~WS_POPUP
|
|
92
|
+
user32.SetWindowLongW(hwnd, GWL_STYLE, style)
|
|
93
|
+
r = parent.contentsRect()
|
|
94
|
+
user32.MoveWindow(hwnd, 0, 0, max(1, r.width()), max(1, r.height()), True)
|
|
95
|
+
user32.ShowWindow(hwnd, 5) # SW_SHOW
|
|
96
|
+
return True
|
|
97
|
+
except Exception:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _win_move_window(hwnd: int, parent: QWidget) -> None:
|
|
102
|
+
if sys.platform != "win32" or not hwnd or parent is None:
|
|
103
|
+
return
|
|
104
|
+
try:
|
|
105
|
+
import ctypes
|
|
106
|
+
|
|
107
|
+
user32 = ctypes.windll.user32
|
|
108
|
+
r = parent.contentsRect()
|
|
109
|
+
w = max(1, r.width())
|
|
110
|
+
h = max(1, r.height())
|
|
111
|
+
user32.MoveWindow(hwnd, 0, 0, w, h, True)
|
|
112
|
+
# WM_SIZE helps input/touch routing after device rotation or splitter resize (embedded mirror).
|
|
113
|
+
WM_SIZE = 0x0005
|
|
114
|
+
SIZE_RESTORED = 0
|
|
115
|
+
lp = (h << 16) | (w & 0xFFFF)
|
|
116
|
+
user32.SendMessageW(hwnd, WM_SIZE, SIZE_RESTORED, lp)
|
|
117
|
+
user32.UpdateWindow(hwnd)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class _EmbedHost(QWidget):
|
|
123
|
+
"""Hosts a reparented scrcpy HWND; keeps the child window sized to this widget."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, parent=None):
|
|
126
|
+
super().__init__(parent)
|
|
127
|
+
self._hwnd: int = 0
|
|
128
|
+
self.setMinimumSize(320, 220)
|
|
129
|
+
|
|
130
|
+
def set_embedded_hwnd(self, hwnd: int) -> None:
|
|
131
|
+
self._hwnd = int(hwnd or 0)
|
|
132
|
+
|
|
133
|
+
def _deferred_embed_resize(self) -> None:
|
|
134
|
+
if self._hwnd:
|
|
135
|
+
_win_move_window(self._hwnd, self)
|
|
136
|
+
|
|
137
|
+
def resizeEvent(self, event):
|
|
138
|
+
super().resizeEvent(event)
|
|
139
|
+
if self._hwnd:
|
|
140
|
+
_win_move_window(self._hwnd, self)
|
|
141
|
+
QTimer.singleShot(50, self._deferred_embed_resize)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ScrcpyTab(QWidget):
|
|
145
|
+
"""Screen mirroring via scrcpy; optional Windows embed (default off — separate window for reliable touch)."""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
get_scrcpy_path: Callable[[], str],
|
|
150
|
+
get_adb_path: Callable[[], str],
|
|
151
|
+
append_log: Callable[[str], None],
|
|
152
|
+
get_serial: Callable[[], str],
|
|
153
|
+
config: AppConfig,
|
|
154
|
+
):
|
|
155
|
+
super().__init__()
|
|
156
|
+
self.get_scrcpy_path = get_scrcpy_path
|
|
157
|
+
self.get_adb_path = get_adb_path
|
|
158
|
+
self._append_log = append_log
|
|
159
|
+
self._get_serial = get_serial
|
|
160
|
+
self.config = config
|
|
161
|
+
self.proc: Optional[QProcess] = None
|
|
162
|
+
self._embed_poll: Optional[QTimer] = None
|
|
163
|
+
self._embed_title: str = ""
|
|
164
|
+
self._embed_hwnd: int = 0
|
|
165
|
+
self._build_ui()
|
|
166
|
+
|
|
167
|
+
def _sync_stop_hotkey(self, running: bool) -> None:
|
|
168
|
+
w = self.window()
|
|
169
|
+
if w is None:
|
|
170
|
+
return
|
|
171
|
+
if running:
|
|
172
|
+
if hasattr(w, "register_scrcpy_stop_hotkey"):
|
|
173
|
+
w.register_scrcpy_stop_hotkey()
|
|
174
|
+
else:
|
|
175
|
+
if hasattr(w, "unregister_scrcpy_stop_hotkey"):
|
|
176
|
+
w.unregister_scrcpy_stop_hotkey()
|
|
177
|
+
|
|
178
|
+
def _on_embed_mirror_changed(self) -> None:
|
|
179
|
+
self.config.embed_scrcpy_mirror = self.embed_mirror_cb.isChecked()
|
|
180
|
+
self.config.embed_scrcpy_mirror_opt_out = not self.embed_mirror_cb.isChecked()
|
|
181
|
+
try:
|
|
182
|
+
self.config.save()
|
|
183
|
+
except OSError:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def _adb_serial_prefix(self) -> list:
|
|
187
|
+
s = (self._selected_serial() or "").strip().split()
|
|
188
|
+
return ["-s", s[0]] if s else []
|
|
189
|
+
|
|
190
|
+
def _selected_serial(self) -> str:
|
|
191
|
+
if hasattr(self, "device_combo") and self.device_combo.count() > 0:
|
|
192
|
+
d = self.device_combo.currentData()
|
|
193
|
+
if d is not None and str(d).strip():
|
|
194
|
+
return str(d).strip()
|
|
195
|
+
t = (self.device_combo.currentText() or "").strip().split()
|
|
196
|
+
if t:
|
|
197
|
+
return t[0]
|
|
198
|
+
s = (self._get_serial() or "").strip().split()
|
|
199
|
+
return s[0] if s else ""
|
|
200
|
+
|
|
201
|
+
def _refresh_screen_devices(self) -> None:
|
|
202
|
+
if not hasattr(self, "device_combo"):
|
|
203
|
+
return
|
|
204
|
+
prev = self._selected_serial()
|
|
205
|
+
self.device_combo.blockSignals(True)
|
|
206
|
+
self.device_combo.clear()
|
|
207
|
+
pairs = list_adb_devices(self.get_adb_path())
|
|
208
|
+
if not pairs:
|
|
209
|
+
self.device_combo.addItem("No device", "")
|
|
210
|
+
self.device_combo.blockSignals(False)
|
|
211
|
+
return
|
|
212
|
+
idx = 0
|
|
213
|
+
top = (self._get_serial() or "").strip().split()
|
|
214
|
+
prefer = prev or (top[0] if top else "")
|
|
215
|
+
for i, (serial, display) in enumerate(pairs):
|
|
216
|
+
self.device_combo.addItem(display, serial)
|
|
217
|
+
if prefer and serial == prefer:
|
|
218
|
+
idx = i
|
|
219
|
+
self.device_combo.setCurrentIndex(idx)
|
|
220
|
+
self.device_combo.blockSignals(False)
|
|
221
|
+
|
|
222
|
+
def _ensure_adb_device(self) -> bool:
|
|
223
|
+
if not (self._get_serial() or "").strip():
|
|
224
|
+
QMessageBox.information(self, "ADB", "Select a device in the bar at the top of the window first.")
|
|
225
|
+
return False
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
def _ensure_device_ready_for_scrcpy(self, serial: str) -> bool:
|
|
229
|
+
adb = self.get_adb_path()
|
|
230
|
+
run_adb(adb, ["start-server"], timeout=8)
|
|
231
|
+
code, out, err = run_adb(adb, ["-s", serial, "get-state"], timeout=8)
|
|
232
|
+
state = (out or "").strip().lower()
|
|
233
|
+
if code == 0 and state == "device":
|
|
234
|
+
return True
|
|
235
|
+
# Try one reconnect cycle, then check again.
|
|
236
|
+
run_adb(adb, ["reconnect"], timeout=12)
|
|
237
|
+
code2, out2, _ = run_adb(adb, ["-s", serial, "get-state"], timeout=8)
|
|
238
|
+
state2 = (out2 or "").strip().lower()
|
|
239
|
+
if code2 == 0 and state2 == "device":
|
|
240
|
+
return True
|
|
241
|
+
self._append_log(
|
|
242
|
+
"Screen: selected device is not ready for scrcpy. "
|
|
243
|
+
+ (err.strip() if err else f"state={state or 'unknown'}")
|
|
244
|
+
)
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
def _adb_restart_server(self) -> None:
|
|
248
|
+
adb = self.get_adb_path()
|
|
249
|
+
c1, _, e1 = run_adb(adb, ["kill-server"], timeout=15)
|
|
250
|
+
self._append_log(f"ADB kill-server: exit {c1}" + (f" — {e1.strip()}" if e1 and e1.strip() else ""))
|
|
251
|
+
c2, out, e2 = run_adb(adb, ["start-server"], timeout=30)
|
|
252
|
+
msg = (out or e2 or "").strip() or f"exit {c2}"
|
|
253
|
+
self._append_log(f"ADB start-server: {msg}")
|
|
254
|
+
if c2 != 0:
|
|
255
|
+
QMessageBox.warning(self, "ADB server", e2 or "start-server failed.")
|
|
256
|
+
|
|
257
|
+
def _adb_reconnect(self) -> None:
|
|
258
|
+
code, out, err = run_adb(self.get_adb_path(), ["reconnect"], timeout=30)
|
|
259
|
+
self._append_log(f"ADB reconnect: {(out or err or '').strip() or f'exit {code}'}")
|
|
260
|
+
|
|
261
|
+
def _adb_remount(self) -> None:
|
|
262
|
+
if not self._ensure_adb_device():
|
|
263
|
+
return
|
|
264
|
+
code, out, err = run_adb(self.get_adb_path(), [*self._adb_serial_prefix(), "remount"], timeout=120)
|
|
265
|
+
text = (out or err or "").strip() or f"exit {code}"
|
|
266
|
+
self._append_log(f"ADB remount: {text}")
|
|
267
|
+
if code != 0:
|
|
268
|
+
QMessageBox.warning(self, "ADB remount", text or "Often needs adb root first.")
|
|
269
|
+
else:
|
|
270
|
+
QMessageBox.information(self, "ADB remount", text or "OK.")
|
|
271
|
+
|
|
272
|
+
def _adb_root(self) -> None:
|
|
273
|
+
if not self._ensure_adb_device():
|
|
274
|
+
return
|
|
275
|
+
code, out, err = run_adb(self.get_adb_path(), [*self._adb_serial_prefix(), "root"], timeout=90)
|
|
276
|
+
text = (out or err or "").strip() or f"exit {code}"
|
|
277
|
+
self._append_log(f"ADB root: {text}")
|
|
278
|
+
QMessageBox.information(self, "ADB root", text)
|
|
279
|
+
|
|
280
|
+
def _adb_unroot(self) -> None:
|
|
281
|
+
if not self._ensure_adb_device():
|
|
282
|
+
return
|
|
283
|
+
code, out, err = run_adb(self.get_adb_path(), [*self._adb_serial_prefix(), "unroot"], timeout=90)
|
|
284
|
+
text = (out or err or "").strip() or f"exit {code}"
|
|
285
|
+
self._append_log(f"ADB unroot: {text}")
|
|
286
|
+
QMessageBox.information(self, "ADB unroot", text)
|
|
287
|
+
|
|
288
|
+
def _adb_reboot(self) -> None:
|
|
289
|
+
if not self._ensure_adb_device():
|
|
290
|
+
return
|
|
291
|
+
r = QMessageBox.question(
|
|
292
|
+
self,
|
|
293
|
+
"Reboot device",
|
|
294
|
+
"Send adb reboot to the selected device?",
|
|
295
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
296
|
+
QMessageBox.No,
|
|
297
|
+
)
|
|
298
|
+
if r != QMessageBox.Yes:
|
|
299
|
+
return
|
|
300
|
+
code, out, err = run_adb(self.get_adb_path(), [*self._adb_serial_prefix(), "reboot"], timeout=30)
|
|
301
|
+
self._append_log(f"ADB reboot: {(out or err or '').strip() or f'exit {code}'}")
|
|
302
|
+
|
|
303
|
+
def _build_ui(self):
|
|
304
|
+
root = QVBoxLayout(self)
|
|
305
|
+
root.setContentsMargins(6, 4, 6, 4)
|
|
306
|
+
root.setSpacing(0)
|
|
307
|
+
|
|
308
|
+
split = QSplitter(Qt.Horizontal)
|
|
309
|
+
split.setObjectName("ScrcpyMainSplit")
|
|
310
|
+
split.setChildrenCollapsible(False)
|
|
311
|
+
|
|
312
|
+
left_wrap = QWidget()
|
|
313
|
+
left_wrap.setObjectName("ScrcpyLeftPanel")
|
|
314
|
+
left_wrap.setMinimumWidth(280)
|
|
315
|
+
left_wrap.setMaximumWidth(900)
|
|
316
|
+
left_wrap.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.MinimumExpanding)
|
|
317
|
+
left_l = QVBoxLayout(left_wrap)
|
|
318
|
+
left_l.setContentsMargins(0, 0, 8, 0)
|
|
319
|
+
left_l.setSpacing(4)
|
|
320
|
+
|
|
321
|
+
inner = QWidget()
|
|
322
|
+
inner.setObjectName("ScrcpyLeftInner")
|
|
323
|
+
inner.setMinimumWidth(260)
|
|
324
|
+
inner.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
|
325
|
+
form = QVBoxLayout(inner)
|
|
326
|
+
form.setContentsMargins(6, 6, 6, 6)
|
|
327
|
+
form.setSpacing(6)
|
|
328
|
+
|
|
329
|
+
hdr = QLabel("Configuration")
|
|
330
|
+
hdr.setObjectName("ScrcpyConfigTitle")
|
|
331
|
+
hdr.setFont(QFont("Segoe UI", 9, QFont.Bold))
|
|
332
|
+
form.addWidget(hdr)
|
|
333
|
+
|
|
334
|
+
grp = QGroupBox("Mirror")
|
|
335
|
+
grp.setObjectName("ScrcpyOptionsGroup")
|
|
336
|
+
grid = QGridLayout(grp)
|
|
337
|
+
grid.setHorizontalSpacing(6)
|
|
338
|
+
grid.setVerticalSpacing(10)
|
|
339
|
+
grid.setContentsMargins(6, 8, 6, 8)
|
|
340
|
+
grid.setColumnMinimumWidth(0, 110)
|
|
341
|
+
grid.setColumnStretch(1, 1)
|
|
342
|
+
|
|
343
|
+
grid.addWidget(QLabel("Device"), 0, 0)
|
|
344
|
+
dev_row = QHBoxLayout()
|
|
345
|
+
dev_row.setContentsMargins(0, 0, 0, 0)
|
|
346
|
+
dev_row.setSpacing(6)
|
|
347
|
+
self.device_combo = ExpandAllComboBox()
|
|
348
|
+
self.device_combo.setMaxVisibleItems(20)
|
|
349
|
+
self.device_combo.setMinimumHeight(34)
|
|
350
|
+
dev_row.addWidget(self.device_combo, 1)
|
|
351
|
+
b_dev_refresh = QToolButton()
|
|
352
|
+
b_dev_refresh.setIcon(self.style().standardIcon(QStyle.SP_BrowserReload))
|
|
353
|
+
b_dev_refresh.setToolTip("Refresh device list")
|
|
354
|
+
b_dev_refresh.setFixedSize(34, 34)
|
|
355
|
+
b_dev_refresh.clicked.connect(self._refresh_screen_devices)
|
|
356
|
+
dev_row.addWidget(b_dev_refresh)
|
|
357
|
+
grid.addLayout(dev_row, 0, 1)
|
|
358
|
+
self._refresh_screen_devices()
|
|
359
|
+
|
|
360
|
+
grid.addWidget(QLabel("Bit rate"), 1, 0)
|
|
361
|
+
self.bitrate_combo = ExpandAllComboBox()
|
|
362
|
+
self.bitrate_combo.setMaxVisibleItems(12)
|
|
363
|
+
self.bitrate_combo.setEditable(True)
|
|
364
|
+
self.bitrate_combo.addItems(["8M", "16M", "32M", "48M", "60M"])
|
|
365
|
+
self.bitrate_combo.setCurrentText("60M")
|
|
366
|
+
self.bitrate_combo.setToolTip(
|
|
367
|
+
"Video bit rate. Default is high quality; if the mirror stutters, try 16M or 8M."
|
|
368
|
+
)
|
|
369
|
+
self.bitrate_combo.setMinimumHeight(34)
|
|
370
|
+
grid.addWidget(self.bitrate_combo, 1, 1)
|
|
371
|
+
|
|
372
|
+
grid.addWidget(QLabel("Max size"), 2, 0)
|
|
373
|
+
self.max_size = ExpandAllComboBox()
|
|
374
|
+
self.max_size.setEditable(True)
|
|
375
|
+
self.max_size.setMaxVisibleItems(12)
|
|
376
|
+
self.max_size.addItems(["1024", "1280", "1600", "1920", "2560", "3200"])
|
|
377
|
+
self.max_size.setCurrentText("1920")
|
|
378
|
+
self.max_size.setToolTip(
|
|
379
|
+
"Longer edge in pixels (scrcpy --max-size). 1920 is a sharp default; "
|
|
380
|
+
"if the PC or device struggles, try 1280 or 1024."
|
|
381
|
+
)
|
|
382
|
+
self.max_size.setMinimumHeight(34)
|
|
383
|
+
grid.addWidget(self.max_size, 2, 1)
|
|
384
|
+
|
|
385
|
+
grid.addWidget(QLabel("Max FPS"), 3, 0)
|
|
386
|
+
self.max_fps = ExpandAllComboBox()
|
|
387
|
+
self.max_fps.setMaxVisibleItems(12)
|
|
388
|
+
self.max_fps.addItems(["(default)", "30", "60", "90", "120"])
|
|
389
|
+
self.max_fps.setCurrentText("60")
|
|
390
|
+
self.max_fps.setToolTip(
|
|
391
|
+
"Cap frame rate (--max-fps). 60 is smooth; use 30 on slower machines."
|
|
392
|
+
)
|
|
393
|
+
self.max_fps.setMinimumHeight(34)
|
|
394
|
+
grid.addWidget(self.max_fps, 3, 1)
|
|
395
|
+
|
|
396
|
+
grid.addWidget(QLabel("Window title"), 4, 0)
|
|
397
|
+
self.window_title = QLineEdit(f"{APP_TITLE} — mirror")
|
|
398
|
+
self.window_title.setToolTip("Must match for embedding (Windows)")
|
|
399
|
+
self.window_title.setMinimumHeight(34)
|
|
400
|
+
grid.addWidget(self.window_title, 4, 1)
|
|
401
|
+
|
|
402
|
+
self.audio_cb = QCheckBox("Forward audio")
|
|
403
|
+
self.audio_cb.setChecked(True)
|
|
404
|
+
self.audio_cb.setToolTip("Off → --no-audio (often faster)")
|
|
405
|
+
grid.addWidget(self.audio_cb, 5, 0)
|
|
406
|
+
|
|
407
|
+
self.stay_awake_cb = QCheckBox("Stay awake (USB)")
|
|
408
|
+
self.stay_awake_cb.setChecked(True)
|
|
409
|
+
grid.addWidget(self.stay_awake_cb, 5, 1)
|
|
410
|
+
|
|
411
|
+
self.turn_screen_off_cb = QCheckBox("Turn device screen off")
|
|
412
|
+
self.turn_screen_off_cb.setChecked(False)
|
|
413
|
+
self.turn_screen_off_cb.setToolTip(
|
|
414
|
+
"scrcpy --turn-screen-off: turns off the phone’s physical display while mirroring "
|
|
415
|
+
"(image still shows in this window). Useful to save the OLED and avoid burn-in."
|
|
416
|
+
)
|
|
417
|
+
grid.addWidget(self.turn_screen_off_cb, 6, 0)
|
|
418
|
+
|
|
419
|
+
self.fullscreen_cb = QCheckBox("Fullscreen (separate)")
|
|
420
|
+
self.fullscreen_cb.setChecked(False)
|
|
421
|
+
self.fullscreen_cb.setToolTip(
|
|
422
|
+
"Whole monitor; embedding skipped. Exit fullscreen mirror: MOD+q in scrcpy, "
|
|
423
|
+
"or Ctrl+Alt+F12 / Ctrl+Alt+End / View → Stop screen mirror."
|
|
424
|
+
)
|
|
425
|
+
grid.addWidget(self.fullscreen_cb, 6, 1)
|
|
426
|
+
|
|
427
|
+
self.embed_mirror_cb = QCheckBox("Embed in this tab (Windows)")
|
|
428
|
+
# Default off: separate scrcpy window — touch/swipes work reliably. Embedding uses SetParent and often breaks input.
|
|
429
|
+
self.embed_mirror_cb.setChecked(bool(getattr(self.config, "embed_scrcpy_mirror", False)))
|
|
430
|
+
self.config.embed_scrcpy_mirror = self.embed_mirror_cb.isChecked()
|
|
431
|
+
self.config.embed_scrcpy_mirror_opt_out = not self.embed_mirror_cb.isChecked()
|
|
432
|
+
self.embed_mirror_cb.setToolTip(
|
|
433
|
+
"Off (recommended): separate mirror window — best for touch and unlock gestures. "
|
|
434
|
+
"On: dock inside this panel (can break touch on some setups)."
|
|
435
|
+
)
|
|
436
|
+
self.embed_mirror_cb.stateChanged.connect(self._on_embed_mirror_changed)
|
|
437
|
+
grid.addWidget(self.embed_mirror_cb, 7, 0, 1, 2)
|
|
438
|
+
|
|
439
|
+
grid.addWidget(QLabel("Extra CLI"), 8, 0)
|
|
440
|
+
self.extra_args = QLineEdit()
|
|
441
|
+
self.extra_args.setPlaceholderText("optional scrcpy flags…")
|
|
442
|
+
self.extra_args.setMinimumHeight(34)
|
|
443
|
+
grid.addWidget(self.extra_args, 8, 1)
|
|
444
|
+
|
|
445
|
+
form.addWidget(grp)
|
|
446
|
+
|
|
447
|
+
st = self.style()
|
|
448
|
+
row = QHBoxLayout()
|
|
449
|
+
row.setSpacing(6)
|
|
450
|
+
b_start = QPushButton("Start")
|
|
451
|
+
b_start.setObjectName("ScrcpyStartBtn")
|
|
452
|
+
b_start.setIcon(icon_media_play_green())
|
|
453
|
+
b_start.clicked.connect(self.start_scrcpy)
|
|
454
|
+
b_stop = QPushButton("Stop")
|
|
455
|
+
b_stop.setObjectName("ScrcpyStopBtn")
|
|
456
|
+
b_stop.setIcon(icon_media_stop_red())
|
|
457
|
+
b_stop.clicked.connect(self.stop_scrcpy)
|
|
458
|
+
row.addWidget(b_start)
|
|
459
|
+
row.addWidget(b_stop)
|
|
460
|
+
form.addLayout(row)
|
|
461
|
+
|
|
462
|
+
adb_grp = QGroupBox("ADB (uses device in top bar)")
|
|
463
|
+
adb_grp.setObjectName("ScrcpyAdbGroup")
|
|
464
|
+
adb_grid = QGridLayout(adb_grp)
|
|
465
|
+
adb_grid.setHorizontalSpacing(6)
|
|
466
|
+
adb_grid.setVerticalSpacing(6)
|
|
467
|
+
adb_actions = [
|
|
468
|
+
("Restart server", "adb kill-server && adb start-server", QStyle.SP_BrowserReload, self._adb_restart_server),
|
|
469
|
+
("Reconnect", "adb reconnect", QStyle.SP_DriveNetIcon, self._adb_reconnect),
|
|
470
|
+
("Remount", "adb remount (often needs root)", QStyle.SP_DialogApplyButton, self._adb_remount),
|
|
471
|
+
("Root", "adb root", QStyle.SP_VistaShield, self._adb_root),
|
|
472
|
+
("Unroot", "adb unroot", QStyle.SP_DialogCancelButton, self._adb_unroot),
|
|
473
|
+
("Reboot", "adb reboot", QStyle.SP_ComputerIcon, self._adb_reboot),
|
|
474
|
+
]
|
|
475
|
+
for i, (text, tip, icon, slot) in enumerate(adb_actions):
|
|
476
|
+
tb = QToolButton()
|
|
477
|
+
tb.setText(text)
|
|
478
|
+
tb.setToolTip(tip)
|
|
479
|
+
tb.setIcon(st.standardIcon(icon))
|
|
480
|
+
tb.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
|
481
|
+
tb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
482
|
+
tb.setMinimumHeight(30)
|
|
483
|
+
tb.clicked.connect(slot)
|
|
484
|
+
row, col = divmod(i, 2)
|
|
485
|
+
adb_grid.addWidget(tb, row, col)
|
|
486
|
+
form.addWidget(adb_grp)
|
|
487
|
+
|
|
488
|
+
self.status = QLabel("Idle")
|
|
489
|
+
self.status.setObjectName("ScrcpyStatusLabel")
|
|
490
|
+
self.status.setWordWrap(True)
|
|
491
|
+
form.addWidget(self.status)
|
|
492
|
+
|
|
493
|
+
left_l.addWidget(inner, 0)
|
|
494
|
+
|
|
495
|
+
right_wrap = QWidget()
|
|
496
|
+
right_l = QVBoxLayout(right_wrap)
|
|
497
|
+
right_l.setContentsMargins(0, 0, 0, 0)
|
|
498
|
+
right_l.setSpacing(0)
|
|
499
|
+
|
|
500
|
+
self._embed_host = _EmbedHost(self)
|
|
501
|
+
self._embed_host.setObjectName("ScrcpyEmbedHost")
|
|
502
|
+
self._embed_host.setMinimumSize(400, 280)
|
|
503
|
+
self._embed_host.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
504
|
+
el = QVBoxLayout(self._embed_host)
|
|
505
|
+
el.setContentsMargins(0, 0, 0, 0)
|
|
506
|
+
|
|
507
|
+
self._placeholder = QLabel(
|
|
508
|
+
"How to start Screen Control:\n"
|
|
509
|
+
"1) Select a device.\n"
|
|
510
|
+
"2) Configure Bit rate, Max size, and Max FPS.\n"
|
|
511
|
+
"3) Choose options (audio/fullscreen/embed) if needed.\n"
|
|
512
|
+
"4) Click Start.\n"
|
|
513
|
+
"5) Click Stop to end mirroring.\n\n"
|
|
514
|
+
"Preview appears here after mirror starts."
|
|
515
|
+
)
|
|
516
|
+
self._placeholder.setWordWrap(True)
|
|
517
|
+
self._placeholder.setAlignment(Qt.AlignCenter)
|
|
518
|
+
self._placeholder.setMargin(16)
|
|
519
|
+
self._placeholder.setObjectName("ScrcpyHintLabel")
|
|
520
|
+
el.addWidget(self._placeholder, 1)
|
|
521
|
+
|
|
522
|
+
right_l.addWidget(self._embed_host, 1)
|
|
523
|
+
|
|
524
|
+
split.addWidget(left_wrap)
|
|
525
|
+
split.addWidget(right_wrap)
|
|
526
|
+
split.setStretchFactor(0, 0)
|
|
527
|
+
split.setStretchFactor(1, 1)
|
|
528
|
+
split.setSizes([400, 2000])
|
|
529
|
+
|
|
530
|
+
root.addWidget(split, 1)
|
|
531
|
+
|
|
532
|
+
def _clear_embed(self) -> None:
|
|
533
|
+
self._embed_hwnd = 0
|
|
534
|
+
self._embed_host.set_embedded_hwnd(0)
|
|
535
|
+
lay = self._embed_host.layout()
|
|
536
|
+
if lay is not None:
|
|
537
|
+
while lay.count():
|
|
538
|
+
w = lay.takeAt(0).widget()
|
|
539
|
+
if w is not None:
|
|
540
|
+
w.deleteLater()
|
|
541
|
+
self._placeholder = QLabel(
|
|
542
|
+
"How to start Screen Control:\n"
|
|
543
|
+
"1) Select a device.\n"
|
|
544
|
+
"2) Configure Bit rate, Max size, and Max FPS.\n"
|
|
545
|
+
"3) Choose options (audio/fullscreen/embed) if needed.\n"
|
|
546
|
+
"4) Click Start.\n"
|
|
547
|
+
"5) Click Stop to end mirroring.\n\n"
|
|
548
|
+
"Preview appears here after mirror starts."
|
|
549
|
+
)
|
|
550
|
+
self._placeholder.setWordWrap(True)
|
|
551
|
+
self._placeholder.setAlignment(Qt.AlignCenter)
|
|
552
|
+
self._placeholder.setMargin(16)
|
|
553
|
+
self._placeholder.setObjectName("ScrcpyHintLabel")
|
|
554
|
+
lay.addWidget(self._placeholder, 1)
|
|
555
|
+
|
|
556
|
+
def _stop_embed_poll(self) -> None:
|
|
557
|
+
if self._embed_poll is not None:
|
|
558
|
+
self._embed_poll.stop()
|
|
559
|
+
self._embed_poll.deleteLater()
|
|
560
|
+
self._embed_poll = None
|
|
561
|
+
|
|
562
|
+
def _poll_embed_window(self) -> None:
|
|
563
|
+
if not self.proc or self.proc.state() != QProcess.Running:
|
|
564
|
+
self._stop_embed_poll()
|
|
565
|
+
return
|
|
566
|
+
if self.fullscreen_cb.isChecked() or not self.embed_mirror_cb.isChecked():
|
|
567
|
+
self._stop_embed_poll()
|
|
568
|
+
return
|
|
569
|
+
title = (self._embed_title or "").strip()
|
|
570
|
+
if not title:
|
|
571
|
+
self._stop_embed_poll()
|
|
572
|
+
return
|
|
573
|
+
hwnd = _win_find_window_by_title(title)
|
|
574
|
+
if not hwnd:
|
|
575
|
+
hwnd = _win_find_window_title_contains(title[: min(32, len(title))])
|
|
576
|
+
if not hwnd:
|
|
577
|
+
return
|
|
578
|
+
self._stop_embed_poll()
|
|
579
|
+
if sys.platform == "win32":
|
|
580
|
+
lay = self._embed_host.layout()
|
|
581
|
+
if lay is not None:
|
|
582
|
+
while lay.count():
|
|
583
|
+
w = lay.takeAt(0).widget()
|
|
584
|
+
if w is not None:
|
|
585
|
+
w.deleteLater()
|
|
586
|
+
if _win_embed_hwnd_into_widget(hwnd, self._embed_host):
|
|
587
|
+
self._embed_hwnd = int(hwnd)
|
|
588
|
+
self._embed_host.set_embedded_hwnd(self._embed_hwnd)
|
|
589
|
+
self._append_log("Screen: embedded mirror via Win32 SetParent.")
|
|
590
|
+
else:
|
|
591
|
+
self._embed_hwnd = 0
|
|
592
|
+
self._embed_host.set_embedded_hwnd(0)
|
|
593
|
+
ph = QLabel(
|
|
594
|
+
"Could not embed the mirror window. It should still appear as a separate window.\n"
|
|
595
|
+
"Stop scrcpy from here when finished."
|
|
596
|
+
)
|
|
597
|
+
ph.setWordWrap(True)
|
|
598
|
+
ph.setMargin(12)
|
|
599
|
+
ph.setObjectName("ScrcpyHintLabel")
|
|
600
|
+
if self._embed_host.layout() is not None:
|
|
601
|
+
self._embed_host.layout().addWidget(ph, 1)
|
|
602
|
+
self._append_log("Screen: could not embed mirror window — using external scrcpy window.")
|
|
603
|
+
|
|
604
|
+
def _drain_process_log(self) -> None:
|
|
605
|
+
if not self.proc:
|
|
606
|
+
return
|
|
607
|
+
try:
|
|
608
|
+
out = bytes(self.proc.readAllStandardOutput()).decode(errors="ignore").strip()
|
|
609
|
+
if out:
|
|
610
|
+
for line in out.splitlines():
|
|
611
|
+
if line.strip():
|
|
612
|
+
self._append_log(f"scrcpy: {line.strip()}")
|
|
613
|
+
except Exception:
|
|
614
|
+
pass
|
|
615
|
+
|
|
616
|
+
def _on_proc_output(self) -> None:
|
|
617
|
+
if not self.proc:
|
|
618
|
+
return
|
|
619
|
+
chunk = bytes(self.proc.readAllStandardOutput()).decode(errors="ignore")
|
|
620
|
+
if chunk.strip():
|
|
621
|
+
for line in chunk.splitlines():
|
|
622
|
+
if line.strip():
|
|
623
|
+
self._append_log(f"scrcpy: {line.strip()}")
|
|
624
|
+
|
|
625
|
+
def _on_proc_finished(self, exit_code: int, exit_status: int) -> None:
|
|
626
|
+
self._sync_stop_hotkey(False)
|
|
627
|
+
self._stop_embed_poll()
|
|
628
|
+
self._drain_process_log()
|
|
629
|
+
self._append_log(f"Screen: scrcpy process finished — exit code {exit_code}, exit status {exit_status}.")
|
|
630
|
+
self.status.setText("Idle")
|
|
631
|
+
self._clear_embed()
|
|
632
|
+
|
|
633
|
+
def start_scrcpy(self):
|
|
634
|
+
if self.proc and self.proc.state() == QProcess.Running:
|
|
635
|
+
self._append_log("Screen: already running.")
|
|
636
|
+
return
|
|
637
|
+
self._refresh_screen_devices()
|
|
638
|
+
if not self._ensure_adb_device():
|
|
639
|
+
return
|
|
640
|
+
serial = self._selected_serial()
|
|
641
|
+
if not serial:
|
|
642
|
+
QMessageBox.information(self, "ADB", "Select a valid device serial first.")
|
|
643
|
+
return
|
|
644
|
+
if not self._ensure_device_ready_for_scrcpy(serial):
|
|
645
|
+
QMessageBox.warning(
|
|
646
|
+
self,
|
|
647
|
+
"Screen Control",
|
|
648
|
+
"Selected device is not ready. Reconnect USB and accept RSA prompt on phone.",
|
|
649
|
+
)
|
|
650
|
+
return
|
|
651
|
+
exe = self.get_scrcpy_path()
|
|
652
|
+
adb_path = self.get_adb_path()
|
|
653
|
+
win_title = (self.window_title.text().strip() or f"{APP_TITLE} - mirror").replace("\u2014", "-").replace("—", "-")
|
|
654
|
+
self._embed_title = win_title
|
|
655
|
+
|
|
656
|
+
br = (self.bitrate_combo.currentText() or "32M").strip() or "32M"
|
|
657
|
+
ms = (self.max_size.currentText() or "1920").strip() or "1920"
|
|
658
|
+
|
|
659
|
+
cmd = [
|
|
660
|
+
exe,
|
|
661
|
+
"-s",
|
|
662
|
+
serial,
|
|
663
|
+
"--video-bit-rate",
|
|
664
|
+
br,
|
|
665
|
+
"--max-size",
|
|
666
|
+
ms,
|
|
667
|
+
"--window-title",
|
|
668
|
+
win_title,
|
|
669
|
+
]
|
|
670
|
+
fps_txt = self.max_fps.currentText().strip()
|
|
671
|
+
if fps_txt and fps_txt != "(default)":
|
|
672
|
+
cmd.extend(["--max-fps", fps_txt])
|
|
673
|
+
|
|
674
|
+
if not self.audio_cb.isChecked():
|
|
675
|
+
cmd.append("--no-audio")
|
|
676
|
+
if self.stay_awake_cb.isChecked():
|
|
677
|
+
cmd.append("--stay-awake")
|
|
678
|
+
if self.turn_screen_off_cb.isChecked():
|
|
679
|
+
cmd.append("--turn-screen-off")
|
|
680
|
+
if self.fullscreen_cb.isChecked():
|
|
681
|
+
cmd.append("--fullscreen")
|
|
682
|
+
|
|
683
|
+
extras = (self.extra_args.text() or "").strip()
|
|
684
|
+
if extras:
|
|
685
|
+
try:
|
|
686
|
+
# Windows paths and quoting differ from POSIX; use non-POSIX rules on Windows.
|
|
687
|
+
tokens = shlex.split(extras, posix=sys.platform != "win32")
|
|
688
|
+
except ValueError as exc:
|
|
689
|
+
tokens = []
|
|
690
|
+
self._append_log(f"Screen: could not parse extra args ({exc}). Check quoting.")
|
|
691
|
+
blocked = {"--no-control", "--otg"}
|
|
692
|
+
cleaned = [t for t in tokens if t not in blocked]
|
|
693
|
+
removed = [t for t in tokens if t in blocked]
|
|
694
|
+
if removed:
|
|
695
|
+
self._append_log(
|
|
696
|
+
"Screen: removed extra arg(s) that disable normal touch/control: "
|
|
697
|
+
+ ", ".join(removed)
|
|
698
|
+
)
|
|
699
|
+
cmd.extend(cleaned)
|
|
700
|
+
|
|
701
|
+
self._clear_embed()
|
|
702
|
+
self._append_log(f"Screen: starting on {serial}: {' '.join(cmd)}")
|
|
703
|
+
self._append_log(f"Screen: ADB executable={adb_path!r}")
|
|
704
|
+
|
|
705
|
+
def _launch(_cmd: list, started_timeout_ms: int = 8000) -> bool:
|
|
706
|
+
if self.proc is not None:
|
|
707
|
+
prev = self.proc
|
|
708
|
+
self.proc = None
|
|
709
|
+
prev.deleteLater()
|
|
710
|
+
self.proc = QProcess(self)
|
|
711
|
+
self.proc.setProcessChannelMode(QProcess.MergedChannels)
|
|
712
|
+
env = QProcessEnvironment.systemEnvironment()
|
|
713
|
+
env.insert("ADB", adb_path)
|
|
714
|
+
self.proc.setProcessEnvironment(env)
|
|
715
|
+
self.proc.readyReadStandardOutput.connect(self._on_proc_output)
|
|
716
|
+
self.proc.finished.connect(self._on_proc_finished)
|
|
717
|
+
self.proc.start(_cmd[0], _cmd[1:])
|
|
718
|
+
return bool(self.proc.waitForStarted(started_timeout_ms))
|
|
719
|
+
|
|
720
|
+
started = _launch(cmd)
|
|
721
|
+
if not started:
|
|
722
|
+
# Fallback launch with safest settings if first start failed.
|
|
723
|
+
safe_cmd = [exe, "-s", serial, "--window-title", win_title, "--video-bit-rate", br]
|
|
724
|
+
self._append_log("Screen: first start failed, retrying with safe fallback options.")
|
|
725
|
+
started = _launch(safe_cmd, started_timeout_ms=9000)
|
|
726
|
+
|
|
727
|
+
if not started:
|
|
728
|
+
self.status.setText("Failed to start")
|
|
729
|
+
err = ""
|
|
730
|
+
if self.proc:
|
|
731
|
+
err = bytes(self.proc.readAllStandardOutput()).decode(errors="ignore")
|
|
732
|
+
self._append_log(
|
|
733
|
+
"Screen: failed to start scrcpy after retry (check scrcpy path, USB debugging, RSA trust, cable). "
|
|
734
|
+
+ (err.strip() or "(no process output)")
|
|
735
|
+
)
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
self.status.setText("Running")
|
|
739
|
+
self._sync_stop_hotkey(True)
|
|
740
|
+
self._stop_embed_poll()
|
|
741
|
+
if sys.platform == "win32" and not self.fullscreen_cb.isChecked() and self.embed_mirror_cb.isChecked():
|
|
742
|
+
self._embed_poll = QTimer(self)
|
|
743
|
+
self._embed_poll.setInterval(180)
|
|
744
|
+
n = [0]
|
|
745
|
+
|
|
746
|
+
def _tick():
|
|
747
|
+
n[0] += 1
|
|
748
|
+
if n[0] > 80:
|
|
749
|
+
self._stop_embed_poll()
|
|
750
|
+
self._append_log(
|
|
751
|
+
"Screen: embedding timed out — mirror may still be open in a separate window."
|
|
752
|
+
)
|
|
753
|
+
return
|
|
754
|
+
self._poll_embed_window()
|
|
755
|
+
|
|
756
|
+
self._embed_poll.timeout.connect(_tick)
|
|
757
|
+
self._embed_poll.start()
|
|
758
|
+
|
|
759
|
+
def stop_scrcpy(self):
|
|
760
|
+
self._sync_stop_hotkey(False)
|
|
761
|
+
self._stop_embed_poll()
|
|
762
|
+
if self.proc and self.proc.state() == QProcess.Running:
|
|
763
|
+
self.proc.kill()
|
|
764
|
+
self.proc.waitForFinished(5000)
|
|
765
|
+
self.status.setText("Stopped")
|
|
766
|
+
self._append_log("Screen: stopped.")
|
|
767
|
+
self._clear_embed()
|
|
768
|
+
|
|
769
|
+
def shutdown(self):
|
|
770
|
+
self.stop_scrcpy()
|