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,691 @@
1
+ import html
2
+ import platform
3
+ import sys
4
+ from datetime import datetime
5
+
6
+ from PyQt5.QtCore import QSize, Qt, QTimer
7
+ from PyQt5.QtGui import QFont, QKeySequence, QTextCursor
8
+ from PyQt5.QtWidgets import (
9
+ QAction,
10
+ QApplication,
11
+ QComboBox,
12
+ QDialog,
13
+ QDialogButtonBox,
14
+ QFileDialog,
15
+ QFrame,
16
+ QHBoxLayout,
17
+ QLabel,
18
+ QLineEdit,
19
+ QMainWindow,
20
+ QMenu,
21
+ QMessageBox,
22
+ QTextBrowser,
23
+ QPushButton,
24
+ QSizePolicy,
25
+ QSplitter,
26
+ QStyle,
27
+ QTabWidget,
28
+ QVBoxLayout,
29
+ QWidget,
30
+ )
31
+
32
+ from .. import APP_TITLE
33
+ from ..config import AppConfig
34
+ from ..services.adb_devices import list_adb_devices
35
+ from ..services.commands import run_adb
36
+ from ..session import ConnectionKind, SessionProfile
37
+ from .app_icon import create_app_icon
38
+ from .first_run_dialog import FirstRunDialog
39
+ from .preferences_dialog import PreferencesDialog
40
+ from .styles import get_stylesheet
41
+ from .tabs.file_explorer_tab import FileExplorerTab
42
+ from .tabs.scrcpy_tab import ScrcpyTab
43
+ from .tabs.terminal_tab import TerminalTab
44
+ from .win_scrcpy_hotkey import (
45
+ is_windows_hotkey_message,
46
+ register_scrcpy_stop_hotkey as _win_register_scrcpy_hotkey,
47
+ unregister_scrcpy_stop_hotkey as _win_unregister_scrcpy_hotkey,
48
+ )
49
+
50
+
51
+ class MainWindow(QMainWindow):
52
+ def __init__(self, config: AppConfig, *, first_launch: bool = False):
53
+ super().__init__()
54
+ self.config = config
55
+ self._first_launch = first_launch
56
+ self.setWindowTitle(APP_TITLE)
57
+ self.setWindowIcon(create_app_icon())
58
+ self.resize(1450, 900)
59
+ self.setMinimumSize(720, 480)
60
+ self._build_ui()
61
+ self._apply_theme()
62
+ if hasattr(self, "_action_dark"):
63
+ self._action_dark.setChecked(bool(getattr(self.config, "dark_theme", False)))
64
+ self.append_log("Application started.")
65
+ self.append_log(f"ADB path: {self.get_adb_path()} · scrcpy: {self.get_scrcpy_path()}")
66
+ if getattr(self.config, "dark_theme", False):
67
+ self.append_log("Dark theme is enabled (View → Dark theme).")
68
+ self.refresh_devices()
69
+ if self._first_launch:
70
+ QTimer.singleShot(0, self.prompt_first_run_if_needed)
71
+ self._adb_poll_timer = QTimer(self)
72
+ self._adb_poll_timer.setInterval(5000)
73
+ self._adb_poll_timer.timeout.connect(self.refresh_devices)
74
+ self._adb_poll_timer.start()
75
+ self._scrcpy_hotkey_registered = False
76
+
77
+ def showEvent(self, event):
78
+ super().showEvent(event)
79
+ # Avoid duplicate startup refresh work; timer + initial call handle it.
80
+
81
+ def append_log(self, message: str) -> None:
82
+ ts = datetime.now().strftime("%H:%M:%S")
83
+ msg_l = (message or "").lower()
84
+ color = "#cbd5e1"
85
+ level = "INFO"
86
+ badge_bg = "#334155"
87
+ if any(k in msg_l for k in ("error", "failed", "warning", "denied", "not found", "timed out")):
88
+ color = "#ef4444"
89
+ level = "ERR"
90
+ badge_bg = "#991b1b"
91
+ elif any(k in msg_l for k in ("saved", "ok", "success", "running", "started", "connected")):
92
+ color = "#22c55e"
93
+ level = "OK"
94
+ badge_bg = "#166534"
95
+ elif any(k in msg_l for k in ("refresh", "adb:", "screen:", "session")):
96
+ color = "#38bdf8"
97
+ level = "INFO"
98
+ badge_bg = "#1e3a8a"
99
+ safe_msg = html.escape(message)
100
+ line = (
101
+ f'<span style="color:#94a3b8;">[{ts}]</span> '
102
+ f'<span style="background:{badge_bg}; color:#f8fafc; padding:1px 6px; border-radius:4px; '
103
+ f'font-weight:700; letter-spacing:0.3px;">{level}</span> '
104
+ f'<span style="color:{color}; font-weight:600;">{safe_msg}</span>'
105
+ )
106
+ self.log_view.append(line)
107
+ self.log_view.moveCursor(QTextCursor.End)
108
+
109
+ def _apply_theme(self) -> None:
110
+ dark = bool(getattr(self.config, "dark_theme", False))
111
+ self.setStyleSheet(get_stylesheet(dark=dark))
112
+
113
+ def _toggle_dark_theme(self) -> None:
114
+ self.config.dark_theme = self._action_dark.isChecked()
115
+ try:
116
+ self.config.save()
117
+ except OSError as exc:
118
+ self.append_log(f"Could not save theme preference: {exc}")
119
+ self._apply_theme()
120
+ self.append_log("Dark theme enabled." if self.config.dark_theme else "Light theme enabled.")
121
+
122
+ def _vsep(self) -> QFrame:
123
+ f = QFrame()
124
+ f.setFrameShape(QFrame.VLine)
125
+ f.setFrameShadow(QFrame.Sunken)
126
+ f.setObjectName("SessionStripVSep")
127
+ return f
128
+
129
+ def get_session_profile(self) -> SessionProfile:
130
+ """ADB profile from Terminal tab device bar."""
131
+ return self.terminal.get_session_profile()
132
+
133
+ def get_serial_session_profile(self) -> SessionProfile:
134
+ return self.terminal.get_serial_session_profile()
135
+
136
+ def get_ssh_profile_from_explorer(self) -> SessionProfile:
137
+ """SFTP fields from File Explorer (for Terminal → SSH)."""
138
+ if hasattr(self, "file_explorer"):
139
+ return self.file_explorer.get_sftp_session_profile()
140
+ return SessionProfile(ConnectionKind.SSH_SFTP)
141
+
142
+ def _notify_explorer_session_changed(self):
143
+ if hasattr(self, "file_explorer"):
144
+ self.file_explorer.refresh_all_remotes()
145
+
146
+ def closeEvent(self, event):
147
+ self.unregister_scrcpy_stop_hotkey()
148
+ if hasattr(self, "_adb_poll_timer"):
149
+ self._adb_poll_timer.stop()
150
+ if hasattr(self, "terminal"):
151
+ self.terminal.shutdown_all_sessions()
152
+ if hasattr(self, "file_explorer"):
153
+ self.file_explorer.disconnect_remote_services()
154
+ if hasattr(self, "scrcpy"):
155
+ self.scrcpy.shutdown()
156
+ super().closeEvent(event)
157
+
158
+ def nativeEvent(self, eventType, message):
159
+ """Windows: Ctrl+Alt+F12 or Ctrl+Alt+End stops scrcpy even when the mirror has focus."""
160
+ if sys.platform == "win32" and is_windows_hotkey_message(eventType, message):
161
+ if hasattr(self, "scrcpy"):
162
+ self.scrcpy.stop_scrcpy()
163
+ return True, 0
164
+ return super().nativeEvent(eventType, message)
165
+
166
+ def register_scrcpy_stop_hotkey(self) -> None:
167
+ if sys.platform != "win32" or self._scrcpy_hotkey_registered:
168
+ return
169
+ try:
170
+ hwnd = int(self.winId())
171
+ except Exception:
172
+ return
173
+ if _win_register_scrcpy_hotkey(hwnd):
174
+ self._scrcpy_hotkey_registered = True
175
+ self.append_log(
176
+ "Screen: Ctrl+Alt+F12 or Ctrl+Alt+End stops the mirror anytime (even fullscreen). "
177
+ "Or View → Stop screen mirror."
178
+ )
179
+ else:
180
+ self.append_log(
181
+ "Screen: could not register stop hotkeys (in use or denied). Use View → Stop screen mirror."
182
+ )
183
+
184
+ def unregister_scrcpy_stop_hotkey(self) -> None:
185
+ if sys.platform != "win32" or not self._scrcpy_hotkey_registered:
186
+ return
187
+ try:
188
+ _win_unregister_scrcpy_hotkey(int(self.winId()))
189
+ except Exception:
190
+ pass
191
+ self._scrcpy_hotkey_registered = False
192
+
193
+ def _build_ui(self):
194
+ central = QWidget()
195
+ root = QVBoxLayout(central)
196
+ root.setContentsMargins(6, 4, 6, 4)
197
+ root.setSpacing(4)
198
+
199
+ body = QWidget()
200
+ body.setObjectName("MainBody")
201
+ body_layout = QVBoxLayout(body)
202
+ body_layout.setContentsMargins(0, 0, 0, 0)
203
+ body_layout.setSpacing(4)
204
+
205
+ body_split = QSplitter(Qt.Vertical)
206
+ body_split.setObjectName("MainBodySplit")
207
+ body_split.setChildrenCollapsible(False)
208
+
209
+ self.tabs = QTabWidget()
210
+ self.tabs.setObjectName("MainTabs")
211
+ tb = self.tabs.tabBar()
212
+ tb.setElideMode(Qt.ElideNone)
213
+ tb.setUsesScrollButtons(True)
214
+ self.terminal = TerminalTab(
215
+ self.get_adb_path,
216
+ self.get_default_ssh_host,
217
+ self.get_default_serial_port,
218
+ self.get_default_serial_baud,
219
+ self.config,
220
+ append_log=self.append_log,
221
+ )
222
+ self.file_explorer = FileExplorerTab(
223
+ self.get_adb_path,
224
+ self.append_log,
225
+ config=self.config,
226
+ on_refresh_devices=self.refresh_devices,
227
+ get_default_ssh_host=self.get_default_ssh_host,
228
+ on_remote_session_changed=self._notify_explorer_session_changed,
229
+ )
230
+ self.scrcpy = ScrcpyTab(
231
+ self.get_scrcpy_path,
232
+ self.get_adb_path,
233
+ self.append_log,
234
+ get_serial=lambda: self.terminal.current_adb_serial(),
235
+ config=self.config,
236
+ )
237
+ st = self.style()
238
+ self.tabs.addTab(self.terminal, st.standardIcon(QStyle.SP_FileDialogDetailedView), "Terminal")
239
+ self.tabs.addTab(self.file_explorer, st.standardIcon(QStyle.SP_DirLinkIcon), "File Explorer")
240
+ self.tabs.addTab(self.scrcpy, st.standardIcon(QStyle.SP_ComputerIcon), "Screen Control")
241
+ self.tabs.tabBar().setIconSize(QSize(18, 18))
242
+ self.tabs.currentChanged.connect(self._on_main_tab_changed)
243
+ self.terminal.device_combo.currentTextChanged.connect(self._on_device_combo_changed)
244
+ body_split.addWidget(self.tabs)
245
+
246
+ log_wrap = QWidget()
247
+ log_v = QVBoxLayout(log_wrap)
248
+ log_v.setContentsMargins(0, 0, 0, 0)
249
+ log_v.setSpacing(4)
250
+
251
+ log_row = QHBoxLayout()
252
+ log_row.setSpacing(6)
253
+ log_lbl = QLabel("Log")
254
+ log_lbl.setObjectName("LogPanelLabel")
255
+ log_row.addWidget(log_lbl)
256
+ log_row.addStretch()
257
+ clear_log = QPushButton("Clear")
258
+ clear_log.setObjectName("HeaderMiniBtn")
259
+ clear_log.setIcon(self.style().standardIcon(QStyle.SP_LineEditClearButton))
260
+ clear_log.clicked.connect(lambda: self.log_view.clear())
261
+ log_row.addWidget(clear_log)
262
+ save_log = QPushButton("Save log")
263
+ save_log.setObjectName("HeaderMiniBtn")
264
+ save_log.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton))
265
+ save_log.clicked.connect(self._save_app_log)
266
+ log_row.addWidget(save_log)
267
+ log_v.addLayout(log_row)
268
+
269
+ self.log_view = QTextBrowser()
270
+ self.log_view.setObjectName("AppLogView")
271
+ self.log_view.setReadOnly(True)
272
+ self.log_view.setOpenExternalLinks(True)
273
+ self.log_view.document().setMaximumBlockCount(4000)
274
+ self.log_view.setFont(QFont("Consolas", 9))
275
+ self.log_view.setPlaceholderText("Application log — transfers, errors, and status appear here.")
276
+ self.log_view.setMinimumHeight(120)
277
+ self.log_view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
278
+ self.log_view.setContextMenuPolicy(Qt.CustomContextMenu)
279
+ self.log_view.customContextMenuRequested.connect(self._log_context_menu)
280
+ log_v.addWidget(self.log_view, 1)
281
+
282
+ body_split.addWidget(log_wrap)
283
+ body_split.setStretchFactor(0, 1)
284
+ body_split.setStretchFactor(1, 0)
285
+ body_split.setSizes([620, 220])
286
+
287
+ body_layout.addWidget(body_split, 1)
288
+
289
+ root.addWidget(body, 1)
290
+
291
+ self.setCentralWidget(central)
292
+ self._build_menu_bar()
293
+
294
+ def prompt_first_run_if_needed(self) -> None:
295
+ if not self._first_launch:
296
+ return
297
+ self._first_launch = False
298
+ dlg = FirstRunDialog(self.config, self)
299
+ if dlg.exec_():
300
+ self._apply_theme()
301
+ if hasattr(self, "_action_dark"):
302
+ self._action_dark.setChecked(bool(getattr(self.config, "dark_theme", False)))
303
+ self.append_log("Welcome — preferences saved. Use File → Preferences anytime.")
304
+ else:
305
+ self.append_log("Using defaults. Open File → Preferences to set paths and theme.")
306
+
307
+ def _open_serial_terminal_tab(self):
308
+ self.tabs.setCurrentIndex(0)
309
+ self.terminal.open_session_matching_profile(self.get_serial_session_profile())
310
+
311
+ def _serial_from_combo_text(self, text: str) -> str:
312
+ t = (text or "").strip()
313
+ if not t or t.startswith("No ") or "not found" in t.lower():
314
+ return ""
315
+ return t.split()[0]
316
+
317
+ def _on_main_tab_changed(self, index: int) -> None:
318
+ if index == 0 and hasattr(self, "terminal"):
319
+ self.terminal._reload_bookmark_sidebar()
320
+ if index == 1:
321
+ self.refresh_devices()
322
+
323
+ def _on_device_combo_changed(self, _text: str):
324
+ if hasattr(self, "file_explorer"):
325
+ self.file_explorer.set_remote_device(self.terminal.current_adb_serial())
326
+
327
+ def refresh_devices(self):
328
+ if not hasattr(self, "terminal"):
329
+ return
330
+ prev_selected_serial = self.terminal.current_adb_serial()
331
+ self.terminal.device_combo.blockSignals(True)
332
+ self.terminal.device_combo.clear()
333
+ pairs = list_adb_devices(self.get_adb_path())
334
+ prev_sig = getattr(self, "_last_adb_device_sig", None)
335
+ sig = tuple(pairs) if pairs else ()
336
+ if not pairs:
337
+ code, _, _ = run_adb(self.get_adb_path(), ["devices"])
338
+ if code != 0:
339
+ self.terminal.device_combo.addItem("ADB not found")
340
+ self.terminal.device_combo.blockSignals(False)
341
+ if hasattr(self, "file_explorer"):
342
+ self.file_explorer.set_remote_device("")
343
+ if prev_sig != ("__adb_err__",):
344
+ self._last_adb_device_sig = ("__adb_err__",)
345
+ self.append_log("ADB not responding — check ADB path in Preferences (menu).")
346
+ return
347
+ self.terminal.device_combo.addItem("No device")
348
+ self.terminal.device_combo.blockSignals(False)
349
+ if hasattr(self, "file_explorer"):
350
+ self.file_explorer.set_remote_device("")
351
+ if prev_sig != ():
352
+ self._last_adb_device_sig = ()
353
+ self.append_log("ADB: no devices detected — connect a device, enable USB debugging, and authorize this PC.")
354
+ return
355
+ selected_index = 0
356
+ for serial, display in pairs:
357
+ self.terminal.device_combo.addItem(display, serial)
358
+ if prev_selected_serial and serial == prev_selected_serial:
359
+ selected_index = self.terminal.device_combo.count() - 1
360
+ self.terminal.device_combo.setCurrentIndex(selected_index)
361
+ self.terminal.device_combo.blockSignals(False)
362
+ if hasattr(self, "file_explorer"):
363
+ self.file_explorer.set_remote_device(self.terminal.current_adb_serial())
364
+ if sig != prev_sig:
365
+ self._last_adb_device_sig = sig
366
+ self.append_log(
367
+ f"ADB: {len(pairs)} device(s) — {', '.join(s for s, _ in pairs[:5])}{'…' if len(pairs) > 5 else ''}"
368
+ )
369
+ if hasattr(self, "file_explorer"):
370
+ self.file_explorer.refresh_all_remotes()
371
+
372
+ def _build_menu_bar(self):
373
+ bar = self.menuBar()
374
+ bar.clear()
375
+ bar.setObjectName("AppMenuBar")
376
+ bar.setNativeMenuBar(True)
377
+
378
+ file_menu = bar.addMenu("&File")
379
+ a_new_session = QAction("&New session…", self)
380
+ a_new_session.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
381
+ a_new_session.setShortcut(QKeySequence("Ctrl+N"))
382
+ a_new_session.triggered.connect(self._menu_session_new_ssh)
383
+ file_menu.addAction(a_new_session)
384
+ a_pref = QAction("&Preferences…", self)
385
+ a_pref.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView))
386
+ a_pref.setShortcut(QKeySequence("Ctrl+,"))
387
+ a_pref.triggered.connect(self._open_preferences)
388
+ file_menu.addAction(a_pref)
389
+ a_save_settings = QAction("&Save settings", self)
390
+ a_save_settings.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton))
391
+ a_save_settings.setShortcut(QKeySequence("Ctrl+S"))
392
+ a_save_settings.triggered.connect(self._save_config_to_disk)
393
+ file_menu.addAction(a_save_settings)
394
+ file_menu.addSeparator()
395
+ a_exit = QAction("E&xit", self)
396
+ a_exit.setIcon(self.style().standardIcon(QStyle.SP_DialogCloseButton))
397
+ a_exit.setShortcut(QKeySequence("Alt+F4"))
398
+ a_exit.triggered.connect(self.close)
399
+ file_menu.addAction(a_exit)
400
+
401
+ edit_menu = bar.addMenu("&Edit")
402
+ a_copy_log = QAction("&Copy log", self)
403
+ a_copy_log.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView))
404
+ a_copy_log.setShortcut(QKeySequence.Copy)
405
+ a_copy_log.triggered.connect(lambda: QApplication.clipboard().setText(self.log_view.toPlainText()))
406
+ edit_menu.addAction(a_copy_log)
407
+ a_clear_log = QAction("C&lear log", self)
408
+ a_clear_log.setIcon(self.style().standardIcon(QStyle.SP_LineEditClearButton))
409
+ a_clear_log.setShortcut(QKeySequence("Ctrl+L"))
410
+ a_clear_log.triggered.connect(self.log_view.clear)
411
+ edit_menu.addAction(a_clear_log)
412
+ a_save_log = QAction("Save &log as…", self)
413
+ a_save_log.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton))
414
+ a_save_log.setShortcut(QKeySequence("Ctrl+Shift+S"))
415
+ a_save_log.triggered.connect(self._save_app_log)
416
+ edit_menu.addAction(a_save_log)
417
+
418
+ session = bar.addMenu("&Session")
419
+ adb_menu = session.addMenu("ADB")
420
+ adb_menu.setIcon(self.style().standardIcon(QStyle.SP_ComputerIcon))
421
+ a_refresh = QAction("Refresh / reload remote", self)
422
+ a_refresh.setIcon(self.style().standardIcon(QStyle.SP_BrowserReload))
423
+ a_refresh.setShortcut(QKeySequence("F5"))
424
+ a_refresh.triggered.connect(self._menu_session_refresh_devices)
425
+ adb_menu.addAction(a_refresh)
426
+ a_reconn = QAction("Reconnect &ADB", self)
427
+ a_reconn.setIcon(self.style().standardIcon(QStyle.SP_DriveNetIcon))
428
+ a_reconn.triggered.connect(self._menu_session_adb_reconnect)
429
+ adb_menu.addAction(a_reconn)
430
+ a_restart = QAction("Restart ADB &server", self)
431
+ a_restart.setIcon(self.style().standardIcon(QStyle.SP_BrowserReload))
432
+ a_restart.triggered.connect(self._menu_session_restart_server)
433
+ adb_menu.addAction(a_restart)
434
+ a_adb_shell = QAction("Open &ADB shell (Terminal tab)", self)
435
+ a_adb_shell.setIcon(self.style().standardIcon(QStyle.SP_ComputerIcon))
436
+ a_adb_shell.triggered.connect(self._menu_session_adb_shell)
437
+ adb_menu.addAction(a_adb_shell)
438
+
439
+ ssh_menu = session.addMenu("SSH")
440
+ ssh_menu.setIcon(self.style().standardIcon(QStyle.SP_DriveNetIcon))
441
+ a_open_ssh = QAction("Open &SSH terminal (from Explorer SFTP fields)…", self)
442
+ a_open_ssh.setIcon(self.style().standardIcon(QStyle.SP_DriveNetIcon))
443
+ a_open_ssh.triggered.connect(self._menu_open_ssh_from_explorer)
444
+ ssh_menu.addAction(a_open_ssh)
445
+ a_new_ssh = QAction("New &SSH session…", self)
446
+ a_new_ssh.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
447
+ a_new_ssh.triggered.connect(self._menu_session_new_ssh)
448
+ ssh_menu.addAction(a_new_ssh)
449
+
450
+
451
+ commands = bar.addMenu("&Commands")
452
+ adb_cmd = commands.addMenu("ADB")
453
+ adb_cmd.setIcon(self.style().standardIcon(QStyle.SP_ComputerIcon))
454
+ a_root = QAction("ADB &root", self)
455
+ a_root.setIcon(self.style().standardIcon(QStyle.SP_VistaShield))
456
+ a_root.triggered.connect(self._menu_cmd_adb_root)
457
+ adb_cmd.addAction(a_root)
458
+ a_unroot = QAction("ADB &unroot", self)
459
+ a_unroot.setIcon(self.style().standardIcon(QStyle.SP_DialogCancelButton))
460
+ a_unroot.triggered.connect(self._menu_cmd_adb_unroot)
461
+ adb_cmd.addAction(a_unroot)
462
+ a_remount = QAction("ADB &remount", self)
463
+ a_remount.setIcon(self.style().standardIcon(QStyle.SP_DialogApplyButton))
464
+ a_remount.triggered.connect(self._menu_cmd_adb_remount)
465
+ adb_cmd.addAction(a_remount)
466
+ a_reboot = QAction("ADB &reboot device", self)
467
+ a_reboot.setIcon(self.style().standardIcon(QStyle.SP_ComputerIcon))
468
+ a_reboot.triggered.connect(self._menu_cmd_adb_reboot)
469
+ adb_cmd.addAction(a_reboot)
470
+
471
+ ssh_cmd = commands.addMenu("SSH")
472
+ ssh_cmd.setIcon(self.style().standardIcon(QStyle.SP_DriveNetIcon))
473
+ for i, qc in enumerate(getattr(self.config, "ssh_quick_commands", None) or []):
474
+ if not isinstance(qc, dict):
475
+ continue
476
+ lab = str(qc.get("label", "") or "Command").strip()
477
+ cmd = str(qc.get("command", "")).strip()
478
+ if not cmd:
479
+ continue
480
+ act = QAction(lab, self)
481
+ act.setIcon(self.style().standardIcon(QStyle.SP_ArrowRight))
482
+ act.triggered.connect(lambda checked=False, c=cmd: self._menu_ssh_send_line(c))
483
+ ssh_cmd.addAction(act)
484
+
485
+ view = bar.addMenu("&View")
486
+ a_term = QAction("&Terminal", self)
487
+ a_term.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView))
488
+ a_term.setShortcut(QKeySequence("Ctrl+1"))
489
+ a_term.triggered.connect(lambda: self.tabs.setCurrentIndex(0))
490
+ view.addAction(a_term)
491
+ a_fe = QAction("&File Explorer", self)
492
+ a_fe.setIcon(self.style().standardIcon(QStyle.SP_DirIcon))
493
+ a_fe.setShortcut(QKeySequence("Ctrl+2"))
494
+ a_fe.triggered.connect(lambda: self.tabs.setCurrentIndex(1))
495
+ view.addAction(a_fe)
496
+ a_scr = QAction("&Screen Control", self)
497
+ a_scr.setIcon(self.style().standardIcon(QStyle.SP_ComputerIcon))
498
+ a_scr.setShortcut(QKeySequence("Ctrl+3"))
499
+ a_scr.triggered.connect(lambda: self.tabs.setCurrentIndex(2))
500
+ view.addAction(a_scr)
501
+ view.addSeparator()
502
+ a_stop_scr = QAction("Stop &screen mirror", self)
503
+ a_stop_scr.setIcon(self.style().standardIcon(QStyle.SP_BrowserStop))
504
+ a_stop_scr.setShortcuts(
505
+ [QKeySequence("Ctrl+Alt+F12"), QKeySequence("Ctrl+Alt+End")]
506
+ )
507
+ a_stop_scr.setToolTip("Stops scrcpy even when the mirror is fullscreen or has focus (Windows global hotkeys).")
508
+ a_stop_scr.triggered.connect(self._menu_stop_screen_mirror)
509
+ view.addAction(a_stop_scr)
510
+ view.addSeparator()
511
+ self._action_dark = QAction("&Dark theme", self)
512
+ self._action_dark.setIcon(self.style().standardIcon(QStyle.SP_DialogYesButton))
513
+ self._action_dark.setCheckable(True)
514
+ self._action_dark.setChecked(bool(getattr(self.config, "dark_theme", False)))
515
+ self._action_dark.triggered.connect(self._toggle_dark_theme)
516
+ view.addAction(self._action_dark)
517
+
518
+ help_menu = bar.addMenu("&Help")
519
+ a_about = QAction(f"&About {APP_TITLE}", self)
520
+ a_about.setIcon(self.style().standardIcon(QStyle.SP_MessageBoxInformation))
521
+ a_about.triggered.connect(self._menu_help_about)
522
+ help_menu.addAction(a_about)
523
+
524
+ def _menu_stop_screen_mirror(self) -> None:
525
+ if hasattr(self, "scrcpy"):
526
+ self.scrcpy.stop_scrcpy()
527
+
528
+ def _open_preferences(self):
529
+ dlg = PreferencesDialog(self.config, self)
530
+ if dlg.exec_():
531
+ self.append_log("Preferences saved.")
532
+ if hasattr(self, "terminal"):
533
+ self.terminal.sync_serial_from_config()
534
+ self.refresh_devices()
535
+ self._build_menu_bar()
536
+ if hasattr(self, "_action_dark"):
537
+ self._action_dark.setChecked(bool(getattr(self.config, "dark_theme", False)))
538
+
539
+ def _save_config_to_disk(self):
540
+ try:
541
+ self.config.save()
542
+ self.append_log("Configuration saved.")
543
+ QMessageBox.information(self, "Saved", "Configuration saved.")
544
+ except Exception as exc:
545
+ self.append_log(f"Save failed: {exc}")
546
+ QMessageBox.warning(self, "Save Failed", f"Unable to save config: {exc}")
547
+
548
+ def _menu_session_refresh_devices(self):
549
+ self.refresh_devices()
550
+ self.file_explorer.refresh_remote()
551
+ self.append_log("Refreshed.")
552
+
553
+ def _menu_open_connection_terminal(self):
554
+ self.tabs.setCurrentIndex(0)
555
+ self.terminal.open_session_matching_profile(self.get_session_profile())
556
+
557
+ def _menu_open_ssh_from_explorer(self):
558
+ self.tabs.setCurrentIndex(0)
559
+ profile = self.get_ssh_profile_from_explorer()
560
+ self.terminal.open_session_matching_profile(profile)
561
+
562
+ def _menu_session_adb_reconnect(self):
563
+ self.file_explorer.action_adb_reconnect()
564
+
565
+ def _menu_session_restart_server(self):
566
+ self.file_explorer.action_restart_adb_server()
567
+
568
+ def _menu_session_new_ssh(self):
569
+ self.tabs.setCurrentIndex(0)
570
+ self.terminal.add_session_dialog()
571
+
572
+ def _menu_session_adb_shell(self):
573
+ self.tabs.setCurrentIndex(0)
574
+ self.terminal.open_session_matching_profile(self.get_session_profile())
575
+
576
+ def _menu_cmd_adb_root(self):
577
+ self.tabs.setCurrentIndex(1)
578
+ self.file_explorer.action_adb_root()
579
+
580
+ def _menu_cmd_adb_unroot(self):
581
+ self.tabs.setCurrentIndex(1)
582
+ self.file_explorer.action_adb_unroot()
583
+
584
+ def _menu_cmd_adb_remount(self):
585
+ self.tabs.setCurrentIndex(1)
586
+ self.file_explorer.action_adb_remount()
587
+
588
+ def _menu_cmd_adb_reboot(self):
589
+ self.tabs.setCurrentIndex(1)
590
+ self.file_explorer.action_adb_reboot()
591
+
592
+ def _menu_ssh_send_line(self, line: str):
593
+ self.tabs.setCurrentIndex(0)
594
+ self.terminal.send_line_to_current_session(line)
595
+
596
+ def _menu_help_about(self) -> None:
597
+ dlg = QDialog(self)
598
+ dlg.setWindowTitle(f"About {APP_TITLE}")
599
+ dlg.setModal(True)
600
+ dlg.setWindowIcon(self.windowIcon())
601
+ dlg.resize(560, 480)
602
+ lay = QVBoxLayout(dlg)
603
+ body = QTextBrowser()
604
+ body.setReadOnly(True)
605
+ body.setOpenExternalLinks(True)
606
+ py_ver = html.escape(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
607
+ plat = html.escape(platform.platform())
608
+ dark = bool(getattr(self.config, "dark_theme", False))
609
+ fg = "#e6edf3" if dark else "#0f172a"
610
+ sub = "#94a3b8" if dark else "#334155"
611
+ muted = "#64748b" if not dark else "#94a3b8"
612
+ body.setHtml(
613
+ f"""
614
+ <!DOCTYPE html>
615
+ <html><body style="font-family:Segoe UI,Arial,sans-serif;font-size:13px;color:{fg};line-height:1.45;">
616
+ <h2 style="margin-top:0;">{html.escape(APP_TITLE)}</h2>
617
+ <p style="color:{sub};">A desktop workspace for Android debugging, remote files, and screen control — built with
618
+ PyQt5 and Python {py_ver} on {plat}.</p>
619
+
620
+ <h3 style="margin-bottom:6px;">What you can do</h3>
621
+ <ul style="margin-top:0;">
622
+ <li><b>Terminal</b> — SSH, ADB shell, serial, and local shells. Sessions and bookmarks live in the sidebar.</li>
623
+ <li><b>File Explorer</b> — WinSCP-style <b>Local | Remote</b> panes per session: ADB device storage, SFTP, or FTP.
624
+ Pull, push, drag-and-drop, find files, and external editors with sync where supported.</li>
625
+ <li><b>Screen Control</b> — Launch and manage <b>scrcpy</b> mirroring (paths and options in Preferences).</li>
626
+ </ul>
627
+
628
+ <h3 style="margin-bottom:6px;">Menus worth knowing</h3>
629
+ <ul style="margin-top:0;">
630
+ <li><b>File → Preferences</b> — ADB/scrcpy paths, dark theme, serial defaults, and <b>SSH quick commands</b>
631
+ (label and command per line: <code>Label | command</code>). Those commands appear under <b>Commands → SSH</b>.</li>
632
+ <li><b>Session</b> — Refresh devices (F5), ADB tools, open SSH using Explorer’s SFTP host or a new session.</li>
633
+ <li><b>Commands</b> — ADB shortcuts (root, remount, reboot) and your custom SSH lines from Preferences.</li>
634
+ <li><b>View</b> — Jump tabs (Ctrl+1–3), stop screen mirror, toggle dark theme.</li>
635
+ </ul>
636
+
637
+ <h3 style="margin-bottom:6px;">Tips</h3>
638
+ <ul style="margin-top:0;">
639
+ <li>Heavy work (e.g. file search) runs in the background so the window stays responsive.</li>
640
+ <li>Folder rows show “…” in the size column (totals are not listed for speed). Use <b>Properties</b> on a folder for a full recursive size when you need it.</li>
641
+ </ul>
642
+
643
+ <p style="color:{muted};font-size:12px;margin-bottom:0;">Configuration is stored in your user profile
644
+ (<code>.devicedeck.json</code>). Bookmarks never store passwords.</p>
645
+ </body></html>
646
+ """
647
+ )
648
+ lay.addWidget(body)
649
+ bb = QDialogButtonBox(QDialogButtonBox.Ok)
650
+ bb.accepted.connect(dlg.accept)
651
+ lay.addWidget(bb)
652
+ dlg.exec_()
653
+
654
+ def _log_context_menu(self, pos) -> None:
655
+ m = self.log_view.createStandardContextMenu()
656
+ m.addSeparator()
657
+ a_save = m.addAction("Save log as…")
658
+ a_save.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton))
659
+ a_save.triggered.connect(self._save_app_log)
660
+ m.exec_(self.log_view.mapToGlobal(pos))
661
+
662
+ def _save_app_log(self) -> None:
663
+ path, _ = QFileDialog.getSaveFileName(
664
+ self,
665
+ "Save application log",
666
+ "devicedeck_log.txt",
667
+ "Text files (*.txt);;All files (*.*)",
668
+ )
669
+ if not path:
670
+ return
671
+ try:
672
+ with open(path, "w", encoding="utf-8", errors="replace") as f:
673
+ f.write(self.log_view.toPlainText())
674
+ self.append_log(f"Log saved: {path}")
675
+ except OSError as exc:
676
+ QMessageBox.warning(self, "Save Log Failed", f"Unable to save log: {exc}")
677
+
678
+ def get_adb_path(self) -> str:
679
+ return (self.config.adb_path or "").strip() or "adb"
680
+
681
+ def get_scrcpy_path(self) -> str:
682
+ return (self.config.scrcpy_path or "").strip() or "scrcpy"
683
+
684
+ def get_default_ssh_host(self) -> str:
685
+ return (self.config.default_ssh_host or "").strip()
686
+
687
+ def get_default_serial_port(self) -> str:
688
+ return (self.config.default_serial_port or "").strip() or "COM3"
689
+
690
+ def get_default_serial_baud(self) -> str:
691
+ return (self.config.default_serial_baud or "").strip() or "115200"