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.
@@ -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()