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,1192 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, List, Optional
|
|
8
|
+
|
|
9
|
+
from ...session import ConnectionKind, SessionProfile, ssh_command_args
|
|
10
|
+
from ...services.adb_devices import friendly_name_for_serial
|
|
11
|
+
from ..session_login_dialog import SessionLoginDialog, SessionLoginOutcome
|
|
12
|
+
|
|
13
|
+
from PyQt5.QtCore import QSize, Qt, QProcess, QTimer
|
|
14
|
+
from PyQt5.QtGui import QFont, QKeySequence, QTextCursor
|
|
15
|
+
from PyQt5.QtWidgets import (
|
|
16
|
+
QAbstractItemView,
|
|
17
|
+
QApplication,
|
|
18
|
+
QDialog,
|
|
19
|
+
QFileDialog,
|
|
20
|
+
QHBoxLayout,
|
|
21
|
+
QLabel,
|
|
22
|
+
QLineEdit,
|
|
23
|
+
QListWidget,
|
|
24
|
+
QListWidgetItem,
|
|
25
|
+
QMenu,
|
|
26
|
+
QMessageBox,
|
|
27
|
+
QPlainTextEdit,
|
|
28
|
+
QPushButton,
|
|
29
|
+
QSplitter,
|
|
30
|
+
QStyle,
|
|
31
|
+
QTabWidget,
|
|
32
|
+
QVBoxLayout,
|
|
33
|
+
QWidget,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from ...config import AppConfig
|
|
37
|
+
from ..combo_utils import ExpandAllComboBox
|
|
38
|
+
from ..icon_utils import bookmark_icon_from_entry, icon_windows_cmd_console, icon_windows_powershell
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _serial_from_combo_text(text: str) -> str:
|
|
42
|
+
t = (text or "").strip()
|
|
43
|
+
if not t or t.startswith("No ") or "not found" in t.lower():
|
|
44
|
+
return ""
|
|
45
|
+
return t.split()[0]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def adb_interactive_shell_command(adb_path: str, serial: Optional[str] = None) -> List[str]:
|
|
49
|
+
"""adb shell with -t -t: stdin is not a TTY (QProcess), so one -t is not enough; double -t forces remote PTY."""
|
|
50
|
+
cmd = [adb_path]
|
|
51
|
+
s = (serial or "").strip()
|
|
52
|
+
if s:
|
|
53
|
+
cmd.extend(["-s", s])
|
|
54
|
+
# Matches native `adb shell`: prompts like hostname:/path $ (see adb: "Use multiple -t options...")
|
|
55
|
+
cmd.extend(["shell", "-t", "-t"])
|
|
56
|
+
return cmd
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _adb_terminal_banner(adb_path: str, serial: str, display_label: str) -> str:
|
|
60
|
+
"""One line at top of buffer so device identity is visible even before the shell prints a prompt."""
|
|
61
|
+
s = (serial or "").strip()
|
|
62
|
+
name = (display_label or "").strip()
|
|
63
|
+
if not name and s:
|
|
64
|
+
name = friendly_name_for_serial(adb_path, s)
|
|
65
|
+
if not s:
|
|
66
|
+
return f"ADB shell · {name or 'default device'}"
|
|
67
|
+
# Avoid "… · RFCY… · RFCY…" when the tab label is already the serial / model lookup matches serial.
|
|
68
|
+
if not name or name == s:
|
|
69
|
+
return f"ADB shell · {s}"
|
|
70
|
+
return f"ADB shell · {name} · {s}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _normalize_pty_text(data: str, *, collapse_blank_runs: bool = True) -> str:
|
|
74
|
+
"""CRLF / stray CR from PTYs; strip ANSI. Optional blank-run collapse for 3+ newlines."""
|
|
75
|
+
if not data:
|
|
76
|
+
return data
|
|
77
|
+
# Strip ANSI escape/control sequences so `clear` and terminal color codes do not print raw bytes.
|
|
78
|
+
data = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", data)
|
|
79
|
+
data = re.sub(r"\x1b\][^\x07]*(\x07|\x1b\\)", "", data)
|
|
80
|
+
data = data.replace("\r\n", "\n")
|
|
81
|
+
data = data.replace("\r", "\n")
|
|
82
|
+
if collapse_blank_runs:
|
|
83
|
+
# Collapse 3+ newlines only (do not merge \n\n).
|
|
84
|
+
data = re.sub(r"\n{3,}", "\n\n", data)
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ShellPlainTextEdit(QPlainTextEdit):
|
|
89
|
+
"""Shell output + typing at the end. History (Up/Down), context menu, Ctrl+Shift+C/V."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, parent=None):
|
|
92
|
+
super().__init__(parent)
|
|
93
|
+
self._anchor = 0
|
|
94
|
+
self._on_commit_line: Optional[Callable[[str], None]] = None
|
|
95
|
+
self._on_save_buffer: Optional[Callable[[], None]] = None
|
|
96
|
+
self._cmd_history: List[str] = []
|
|
97
|
+
self._hist_browse_idx: Optional[int] = None
|
|
98
|
+
self._hist_stash: str = ""
|
|
99
|
+
self.setCursorWidth(2)
|
|
100
|
+
self.setUndoRedoEnabled(False)
|
|
101
|
+
|
|
102
|
+
def set_on_commit(self, fn: Callable[[str], None]) -> None:
|
|
103
|
+
self._on_commit_line = fn
|
|
104
|
+
|
|
105
|
+
def set_on_save_buffer(self, fn: Callable[[], None]) -> None:
|
|
106
|
+
self._on_save_buffer = fn
|
|
107
|
+
|
|
108
|
+
def set_initial_content(self, text: str) -> None:
|
|
109
|
+
self.setPlainText(text)
|
|
110
|
+
cur = self.textCursor()
|
|
111
|
+
cur.movePosition(QTextCursor.End)
|
|
112
|
+
self.setTextCursor(cur)
|
|
113
|
+
self._anchor = cur.position()
|
|
114
|
+
self._reset_history_browse()
|
|
115
|
+
|
|
116
|
+
def append_from_process(self, text: str) -> None:
|
|
117
|
+
self.moveCursor(QTextCursor.End)
|
|
118
|
+
self.insertPlainText(text)
|
|
119
|
+
self._anchor = self.textCursor().position()
|
|
120
|
+
self._reset_history_browse()
|
|
121
|
+
self.ensureCursorVisible()
|
|
122
|
+
|
|
123
|
+
def _reset_history_browse(self) -> None:
|
|
124
|
+
self._hist_browse_idx = None
|
|
125
|
+
self._hist_stash = ""
|
|
126
|
+
|
|
127
|
+
def _replace_input_tail(self, text: str) -> None:
|
|
128
|
+
cur = self.textCursor()
|
|
129
|
+
cur.setPosition(self._anchor)
|
|
130
|
+
cur.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
|
|
131
|
+
cur.removeSelectedText()
|
|
132
|
+
cur.insertText(text)
|
|
133
|
+
cur.movePosition(QTextCursor.End)
|
|
134
|
+
self.setTextCursor(cur)
|
|
135
|
+
|
|
136
|
+
def _history_prev(self) -> None:
|
|
137
|
+
if not self._cmd_history:
|
|
138
|
+
return
|
|
139
|
+
doc = self.toPlainText()
|
|
140
|
+
tail = doc[self._anchor :]
|
|
141
|
+
if self._hist_browse_idx is None:
|
|
142
|
+
self._hist_stash = tail
|
|
143
|
+
self._hist_browse_idx = len(self._cmd_history) - 1
|
|
144
|
+
else:
|
|
145
|
+
self._hist_browse_idx = max(0, self._hist_browse_idx - 1)
|
|
146
|
+
self._replace_input_tail(self._cmd_history[self._hist_browse_idx])
|
|
147
|
+
|
|
148
|
+
def _history_next(self) -> None:
|
|
149
|
+
if self._hist_browse_idx is None:
|
|
150
|
+
return
|
|
151
|
+
if self._hist_browse_idx >= len(self._cmd_history) - 1:
|
|
152
|
+
self._replace_input_tail(self._hist_stash)
|
|
153
|
+
self._reset_history_browse()
|
|
154
|
+
return
|
|
155
|
+
self._hist_browse_idx += 1
|
|
156
|
+
self._replace_input_tail(self._cmd_history[self._hist_browse_idx])
|
|
157
|
+
|
|
158
|
+
def contextMenuEvent(self, event):
|
|
159
|
+
menu = self.createStandardContextMenu(event.pos())
|
|
160
|
+
menu.addSeparator()
|
|
161
|
+
a_copy_all = menu.addAction("Copy all")
|
|
162
|
+
a_copy_all.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView))
|
|
163
|
+
a_copy_all.triggered.connect(self._copy_all_to_clipboard)
|
|
164
|
+
if self._on_save_buffer is not None:
|
|
165
|
+
a_save = menu.addAction("Save terminal output as…")
|
|
166
|
+
a_save.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton))
|
|
167
|
+
a_save.triggered.connect(self._on_save_buffer)
|
|
168
|
+
a_clear = menu.addAction("Clear terminal output")
|
|
169
|
+
a_clear.setIcon(self.style().standardIcon(QStyle.SP_LineEditClearButton))
|
|
170
|
+
a_clear.triggered.connect(self._clear_buffer)
|
|
171
|
+
menu.addSeparator()
|
|
172
|
+
pa = menu.addAction("Paste at prompt")
|
|
173
|
+
pa.setIcon(self.style().standardIcon(QStyle.SP_DialogOpenButton))
|
|
174
|
+
pa.setShortcut(QKeySequence("Ctrl+Shift+V"))
|
|
175
|
+
pa.triggered.connect(self._paste_at_prompt)
|
|
176
|
+
menu.exec_(event.globalPos())
|
|
177
|
+
|
|
178
|
+
def _copy_all_to_clipboard(self) -> None:
|
|
179
|
+
QApplication.clipboard().setText(self.toPlainText())
|
|
180
|
+
|
|
181
|
+
def _paste_at_prompt(self) -> None:
|
|
182
|
+
self.moveCursor(QTextCursor.End)
|
|
183
|
+
self.paste()
|
|
184
|
+
|
|
185
|
+
def _clear_buffer(self) -> None:
|
|
186
|
+
self.clear()
|
|
187
|
+
self._anchor = 0
|
|
188
|
+
self._reset_history_browse()
|
|
189
|
+
self.moveCursor(QTextCursor.End)
|
|
190
|
+
|
|
191
|
+
def keyPressEvent(self, ev):
|
|
192
|
+
cur = self.textCursor()
|
|
193
|
+
pos = cur.position()
|
|
194
|
+
k = ev.key()
|
|
195
|
+
mods = ev.modifiers()
|
|
196
|
+
|
|
197
|
+
if mods == (Qt.ControlModifier | Qt.ShiftModifier):
|
|
198
|
+
if k == Qt.Key_C:
|
|
199
|
+
self.copy()
|
|
200
|
+
ev.accept()
|
|
201
|
+
return
|
|
202
|
+
if k == Qt.Key_V:
|
|
203
|
+
self.moveCursor(QTextCursor.End)
|
|
204
|
+
self.paste()
|
|
205
|
+
ev.accept()
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if mods & Qt.ControlModifier:
|
|
209
|
+
if k == Qt.Key_C:
|
|
210
|
+
self.copy()
|
|
211
|
+
ev.accept()
|
|
212
|
+
return
|
|
213
|
+
if k == Qt.Key_V:
|
|
214
|
+
self.moveCursor(QTextCursor.End)
|
|
215
|
+
self.paste()
|
|
216
|
+
ev.accept()
|
|
217
|
+
return
|
|
218
|
+
if k == Qt.Key_X:
|
|
219
|
+
self.copy()
|
|
220
|
+
ev.accept()
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
if cur.hasSelection():
|
|
224
|
+
start = min(cur.selectionStart(), cur.selectionEnd())
|
|
225
|
+
if start < self._anchor and ev.text():
|
|
226
|
+
self.moveCursor(QTextCursor.End)
|
|
227
|
+
cur = self.textCursor()
|
|
228
|
+
pos = cur.position()
|
|
229
|
+
|
|
230
|
+
if pos >= self._anchor and self._hist_browse_idx is not None and ev.text() and k not in (
|
|
231
|
+
Qt.Key_Up,
|
|
232
|
+
Qt.Key_Down,
|
|
233
|
+
):
|
|
234
|
+
self._reset_history_browse()
|
|
235
|
+
|
|
236
|
+
if k in (Qt.Key_Up, Qt.Key_Down) and pos >= self._anchor and not (mods & Qt.ControlModifier) and not (
|
|
237
|
+
mods & Qt.AltModifier
|
|
238
|
+
):
|
|
239
|
+
if k == Qt.Key_Up:
|
|
240
|
+
self._history_prev()
|
|
241
|
+
else:
|
|
242
|
+
self._history_next()
|
|
243
|
+
ev.accept()
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
if mods & Qt.ControlModifier:
|
|
247
|
+
if k in (Qt.Key_C, Qt.Key_A):
|
|
248
|
+
return super().keyPressEvent(ev)
|
|
249
|
+
if k == Qt.Key_V and pos < self._anchor:
|
|
250
|
+
self.moveCursor(QTextCursor.End)
|
|
251
|
+
return super().keyPressEvent(ev)
|
|
252
|
+
|
|
253
|
+
if k in (Qt.Key_Return, Qt.Key_Enter):
|
|
254
|
+
if mods & Qt.ShiftModifier:
|
|
255
|
+
return super().keyPressEvent(ev)
|
|
256
|
+
self._commit_current_line()
|
|
257
|
+
ev.accept()
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
if k == Qt.Key_Backspace and pos <= self._anchor:
|
|
261
|
+
ev.accept()
|
|
262
|
+
return
|
|
263
|
+
if k == Qt.Key_Delete and pos < self._anchor:
|
|
264
|
+
ev.accept()
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
if k == Qt.Key_Left and pos <= self._anchor:
|
|
268
|
+
ev.accept()
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
if pos < self._anchor and ev.text():
|
|
272
|
+
self.moveCursor(QTextCursor.End)
|
|
273
|
+
|
|
274
|
+
super().keyPressEvent(ev)
|
|
275
|
+
|
|
276
|
+
def mousePressEvent(self, ev):
|
|
277
|
+
super().mousePressEvent(ev)
|
|
278
|
+
if ev.button() == Qt.LeftButton and self.textCursor().position() < self._anchor:
|
|
279
|
+
cur = self.textCursor()
|
|
280
|
+
cur.movePosition(QTextCursor.End)
|
|
281
|
+
self.setTextCursor(cur)
|
|
282
|
+
|
|
283
|
+
def _commit_current_line(self) -> None:
|
|
284
|
+
cur = self.textCursor()
|
|
285
|
+
pos = cur.position()
|
|
286
|
+
doc = self.toPlainText()
|
|
287
|
+
line = doc[self._anchor : pos]
|
|
288
|
+
if "\n" in line:
|
|
289
|
+
line = line.split("\n", 1)[0]
|
|
290
|
+
end_input = self._anchor + len(line)
|
|
291
|
+
if line.strip():
|
|
292
|
+
if not self._cmd_history or self._cmd_history[-1] != line:
|
|
293
|
+
self._cmd_history.append(line)
|
|
294
|
+
self._reset_history_browse()
|
|
295
|
+
# Remove what we typed before sending: an interactive shell echoes the line from the PTY, so
|
|
296
|
+
# keeping it here duplicates "ls" / blank lines and can interleave with async stdout if we send first.
|
|
297
|
+
cur = self.textCursor()
|
|
298
|
+
cur.setPosition(self._anchor)
|
|
299
|
+
cur.setPosition(end_input, QTextCursor.KeepAnchor)
|
|
300
|
+
cur.removeSelectedText()
|
|
301
|
+
doc = self.toPlainText()
|
|
302
|
+
pos = self.textCursor().position()
|
|
303
|
+
# If the shell did not end the previous line with a newline, insert one so the next prompt/output
|
|
304
|
+
# does not glue to the prior line. Do NOT insert when typing on the same line as a prompt:
|
|
305
|
+
# Windows `>`, Android/adb ` $` / `#`, or trailing space after those.
|
|
306
|
+
if pos > 0:
|
|
307
|
+
prev = doc[pos - 1]
|
|
308
|
+
at_prompt = prev in " \t" or prev in ">$#"
|
|
309
|
+
if prev != "\n" and not at_prompt:
|
|
310
|
+
cur.insertText("\n")
|
|
311
|
+
pos += 1
|
|
312
|
+
self._anchor = pos
|
|
313
|
+
self.setTextCursor(cur)
|
|
314
|
+
if self._on_commit_line:
|
|
315
|
+
self._on_commit_line(line)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class SessionWidget(QWidget):
|
|
319
|
+
"""Single terminal session: scrollback + type-at-end shell (Moba-like dark theme)."""
|
|
320
|
+
|
|
321
|
+
def __init__(
|
|
322
|
+
self,
|
|
323
|
+
session_label: str,
|
|
324
|
+
command: List[str],
|
|
325
|
+
banner: Optional[str] = None,
|
|
326
|
+
*,
|
|
327
|
+
working_dir: Optional[str] = None,
|
|
328
|
+
shell_profile: Optional[str] = None,
|
|
329
|
+
):
|
|
330
|
+
super().__init__()
|
|
331
|
+
self.session_label = session_label
|
|
332
|
+
self.command = command
|
|
333
|
+
self._banner = (banner or "").strip() or None
|
|
334
|
+
self._trim_first_pty_chunk = bool(self._banner)
|
|
335
|
+
self._shell_profile = shell_profile if shell_profile in ("cmd", "powershell") else None
|
|
336
|
+
try:
|
|
337
|
+
base = Path(working_dir or os.getcwd()).resolve()
|
|
338
|
+
except OSError:
|
|
339
|
+
base = Path.cwd()
|
|
340
|
+
self._working_dir_str = str(base)
|
|
341
|
+
self._cwd: Path = base
|
|
342
|
+
self.proc = QProcess(self)
|
|
343
|
+
self._log_fp = None
|
|
344
|
+
self._log_path = self._build_log_path()
|
|
345
|
+
self._build_ui()
|
|
346
|
+
self._start()
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def _is_adb_shell(self) -> bool:
|
|
350
|
+
if not self.command:
|
|
351
|
+
return False
|
|
352
|
+
low = [str(x).lower() for x in self.command]
|
|
353
|
+
return "shell" in low and "adb" in str(self.command[0]).lower()
|
|
354
|
+
|
|
355
|
+
def _build_log_path(self) -> Path:
|
|
356
|
+
safe = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in self.session_label)[:48] or "session"
|
|
357
|
+
d = Path(tempfile.gettempdir()) / "devicedeck_terminal_logs"
|
|
358
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
360
|
+
return d / f"{ts}_{safe}.log"
|
|
361
|
+
|
|
362
|
+
def _build_ui(self):
|
|
363
|
+
layout = QVBoxLayout(self)
|
|
364
|
+
layout.setContentsMargins(2, 2, 2, 2)
|
|
365
|
+
layout.setSpacing(0)
|
|
366
|
+
|
|
367
|
+
self.output = ShellPlainTextEdit()
|
|
368
|
+
self.output.setObjectName("MobaTerminalOutput")
|
|
369
|
+
self.output.setFont(QFont("Consolas", 11))
|
|
370
|
+
self.output.setLineWrapMode(QPlainTextEdit.NoWrap)
|
|
371
|
+
# Keep UI responsive; full stream is persisted to disk.
|
|
372
|
+
self.output.setMaximumBlockCount(60000)
|
|
373
|
+
self.output.set_on_commit(self._send_line)
|
|
374
|
+
self.output.setContextMenuPolicy(Qt.DefaultContextMenu)
|
|
375
|
+
self.output.set_on_save_buffer(self._save_buffer_as)
|
|
376
|
+
layout.addWidget(self.output, 1)
|
|
377
|
+
self._session_footer = QLabel()
|
|
378
|
+
self._session_footer.setObjectName("TerminalSessionFooter")
|
|
379
|
+
self._session_footer.setFont(QFont("Consolas", 10))
|
|
380
|
+
self._session_footer.setWordWrap(True)
|
|
381
|
+
self._session_footer.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
382
|
+
self._session_footer.setToolTip(
|
|
383
|
+
"Current folder and session type. Type in the pane above: the caret should sit one space "
|
|
384
|
+
"after the > at the end of the last line (same as Windows Terminal)."
|
|
385
|
+
)
|
|
386
|
+
layout.addWidget(self._session_footer)
|
|
387
|
+
self._update_session_footer()
|
|
388
|
+
self._pending_chunks: List[str] = []
|
|
389
|
+
self._flush_timer = QTimer(self)
|
|
390
|
+
self._flush_timer.setSingleShot(True)
|
|
391
|
+
self._flush_timer.setInterval(25)
|
|
392
|
+
self._flush_timer.timeout.connect(self._flush_pending_output)
|
|
393
|
+
|
|
394
|
+
self.proc.readyReadStandardOutput.connect(self._read_stdout)
|
|
395
|
+
self.proc.readyReadStandardError.connect(self._read_stderr)
|
|
396
|
+
self.proc.finished.connect(self._on_proc_finished)
|
|
397
|
+
|
|
398
|
+
def shutdown(self, *, wait_for_process: bool = False) -> None:
|
|
399
|
+
"""Stop the shell process when the tab is closed.
|
|
400
|
+
|
|
401
|
+
On normal tab close, the kill is fire-and-forget. On application exit, pass
|
|
402
|
+
``wait_for_process=True`` so ADB/SSH child processes are reaped before teardown.
|
|
403
|
+
"""
|
|
404
|
+
if self.proc.state() == QProcess.Running:
|
|
405
|
+
self.proc.kill()
|
|
406
|
+
if wait_for_process:
|
|
407
|
+
self.proc.waitForFinished(8000)
|
|
408
|
+
self._close_log()
|
|
409
|
+
|
|
410
|
+
def _on_proc_finished(self):
|
|
411
|
+
self.output.append_from_process("\n[session terminated]\n")
|
|
412
|
+
self._write_log("\n[session terminated]\n")
|
|
413
|
+
self._write_log(f"[full log path] {self._log_path}\n")
|
|
414
|
+
if hasattr(self, "_session_footer"):
|
|
415
|
+
self._session_footer.setText(f"Session · {self.session_label} — ended")
|
|
416
|
+
self._close_log()
|
|
417
|
+
|
|
418
|
+
def _update_session_footer(self) -> None:
|
|
419
|
+
if not hasattr(self, "_session_footer"):
|
|
420
|
+
return
|
|
421
|
+
if self._shell_profile == "cmd":
|
|
422
|
+
self._session_footer.setText(f"Session · {self.session_label} · {self._cwd}> ")
|
|
423
|
+
elif self._shell_profile == "powershell":
|
|
424
|
+
self._session_footer.setText(f"Session · {self.session_label} · PS {self._cwd}> ")
|
|
425
|
+
else:
|
|
426
|
+
self._session_footer.setText(f"Session · {self.session_label}")
|
|
427
|
+
|
|
428
|
+
def _ensure_prompt_trailing_space(self) -> None:
|
|
429
|
+
"""CMD only: pipe-backed cmd may end with `>` without a space. PowerShell prints its own prompt; adding a space here caused extra gaps and missing prompt lines."""
|
|
430
|
+
if self._shell_profile != "cmd":
|
|
431
|
+
return
|
|
432
|
+
doc = self.output.toPlainText()
|
|
433
|
+
if not doc or doc.endswith("> ") or not doc.rstrip().endswith(">"):
|
|
434
|
+
return
|
|
435
|
+
last = self._last_nonempty_line().rstrip()
|
|
436
|
+
if not re.search(r"[A-Za-z]:\\[^>\n]*>$", last):
|
|
437
|
+
return
|
|
438
|
+
self.output.append_from_process(" ")
|
|
439
|
+
|
|
440
|
+
def _send_line(self, line: str) -> None:
|
|
441
|
+
if self.proc.state() != QProcess.Running:
|
|
442
|
+
return
|
|
443
|
+
self._apply_local_cd_line(line)
|
|
444
|
+
self._update_session_footer()
|
|
445
|
+
self._write_log(f">>> {line}\n")
|
|
446
|
+
self.proc.write((line + "\n").encode("utf-8", errors="replace"))
|
|
447
|
+
|
|
448
|
+
def _format_local_prompt(self) -> str:
|
|
449
|
+
"""Trailing space after `>` so the caret sits one space past the prompt (CMD pipe sessions only)."""
|
|
450
|
+
return f"{str(self._cwd)}> "
|
|
451
|
+
|
|
452
|
+
def _last_nonempty_line(self) -> str:
|
|
453
|
+
for line in reversed(self.output.toPlainText().split("\n")):
|
|
454
|
+
if line.strip():
|
|
455
|
+
return line
|
|
456
|
+
return ""
|
|
457
|
+
|
|
458
|
+
def _buffer_already_shows_prompt(self) -> bool:
|
|
459
|
+
if self._shell_profile != "cmd":
|
|
460
|
+
return True
|
|
461
|
+
last = self._last_nonempty_line().rstrip()
|
|
462
|
+
if not last:
|
|
463
|
+
return False
|
|
464
|
+
return bool(re.search(r"[A-Za-z]:\\[^>\n]*>\s*$", last))
|
|
465
|
+
|
|
466
|
+
def _maybe_append_synthetic_prompt(self) -> None:
|
|
467
|
+
if not self._shell_profile or self.proc.state() != QProcess.Running:
|
|
468
|
+
return
|
|
469
|
+
# PowerShell already prints "PS path>" on stdout; a synthetic line duplicates it (PS ...> PS ...>).
|
|
470
|
+
if self._shell_profile == "powershell":
|
|
471
|
+
return
|
|
472
|
+
if self._buffer_already_shows_prompt():
|
|
473
|
+
return
|
|
474
|
+
p = self._format_local_prompt()
|
|
475
|
+
if not self.output.toPlainText().endswith("\n"):
|
|
476
|
+
self.output.append_from_process("\n")
|
|
477
|
+
self.output.append_from_process(p)
|
|
478
|
+
|
|
479
|
+
def _apply_local_cd_line(self, line: str) -> None:
|
|
480
|
+
"""Best-effort cwd for the synthetic prompt (the real shell updates its own cwd)."""
|
|
481
|
+
if not self._shell_profile:
|
|
482
|
+
return
|
|
483
|
+
raw = (line or "").strip()
|
|
484
|
+
if not raw:
|
|
485
|
+
return
|
|
486
|
+
low = raw.lower()
|
|
487
|
+
arg_part: Optional[str] = None
|
|
488
|
+
if low.startswith("cd "):
|
|
489
|
+
arg_part = raw[3:].strip()
|
|
490
|
+
elif low.startswith("cd\\") or low.startswith("cd/"):
|
|
491
|
+
arg_part = raw[2:].lstrip("/\\").strip()
|
|
492
|
+
elif self._shell_profile == "powershell":
|
|
493
|
+
if low.startswith("set-location "):
|
|
494
|
+
arg_part = raw.split(None, 1)[1].strip() if len(raw.split()) > 1 else ""
|
|
495
|
+
elif low == "sl" or low.startswith("sl "):
|
|
496
|
+
arg_part = raw.split(None, 1)[1].strip() if len(raw.split()) > 1 else ""
|
|
497
|
+
if arg_part is None:
|
|
498
|
+
return
|
|
499
|
+
arg = arg_part.strip().strip('"').strip("'")
|
|
500
|
+
if not arg:
|
|
501
|
+
return
|
|
502
|
+
if self._shell_profile == "cmd" and arg.lower().startswith("/d "):
|
|
503
|
+
rest = arg[3:].strip()
|
|
504
|
+
try:
|
|
505
|
+
self._cwd = Path(rest).resolve()
|
|
506
|
+
except OSError:
|
|
507
|
+
pass
|
|
508
|
+
return
|
|
509
|
+
if arg == "..":
|
|
510
|
+
self._cwd = self._cwd.parent
|
|
511
|
+
return
|
|
512
|
+
if arg in (".\\", "./", "."):
|
|
513
|
+
return
|
|
514
|
+
p = Path(arg)
|
|
515
|
+
if not p.is_absolute():
|
|
516
|
+
try:
|
|
517
|
+
self._cwd = (self._cwd / arg).resolve()
|
|
518
|
+
except OSError:
|
|
519
|
+
pass
|
|
520
|
+
else:
|
|
521
|
+
try:
|
|
522
|
+
self._cwd = p.resolve()
|
|
523
|
+
except OSError:
|
|
524
|
+
pass
|
|
525
|
+
|
|
526
|
+
def send_line(self, line: str) -> None:
|
|
527
|
+
"""Send a full line to the shell (same as pressing Enter after typing)."""
|
|
528
|
+
self._send_line((line or "").rstrip("\n"))
|
|
529
|
+
|
|
530
|
+
def _tighten_adb_chunk(self, text: str) -> str:
|
|
531
|
+
"""ADB shell PTY often emits extra blank lines between prompt, echo, and output — collapse to a single newline."""
|
|
532
|
+
if not text:
|
|
533
|
+
return text
|
|
534
|
+
text = re.sub(r"\n{2,}", "\n", text)
|
|
535
|
+
doc = self.output.toPlainText()
|
|
536
|
+
if doc.endswith("\n") and text.startswith("\n"):
|
|
537
|
+
text = text[1:]
|
|
538
|
+
return text
|
|
539
|
+
|
|
540
|
+
def _append(self, data: str):
|
|
541
|
+
if not data:
|
|
542
|
+
return
|
|
543
|
+
data = _normalize_pty_text(data)
|
|
544
|
+
if self._trim_first_pty_chunk:
|
|
545
|
+
data = data.lstrip("\n")
|
|
546
|
+
if not data:
|
|
547
|
+
return
|
|
548
|
+
self._trim_first_pty_chunk = False
|
|
549
|
+
if not data:
|
|
550
|
+
return
|
|
551
|
+
self._write_log(data)
|
|
552
|
+
self._pending_chunks.append(data)
|
|
553
|
+
if not self._flush_timer.isActive():
|
|
554
|
+
self._flush_timer.start()
|
|
555
|
+
|
|
556
|
+
def _flush_pending_output(self) -> None:
|
|
557
|
+
if not self._pending_chunks:
|
|
558
|
+
return
|
|
559
|
+
text = "".join(self._pending_chunks)
|
|
560
|
+
self._pending_chunks.clear()
|
|
561
|
+
if self._is_adb_shell:
|
|
562
|
+
text = self._tighten_adb_chunk(text)
|
|
563
|
+
self.output.append_from_process(text)
|
|
564
|
+
self._maybe_append_synthetic_prompt()
|
|
565
|
+
self._ensure_prompt_trailing_space()
|
|
566
|
+
self._update_session_footer()
|
|
567
|
+
|
|
568
|
+
def _start(self):
|
|
569
|
+
# Banner line only; first PTY bytes may include leading newlines — trimmed in _append.
|
|
570
|
+
self.output.set_initial_content((self._banner + "\n") if self._banner else "")
|
|
571
|
+
self._open_log()
|
|
572
|
+
if self._banner:
|
|
573
|
+
self._write_log(self._banner + "\n")
|
|
574
|
+
self.proc.setWorkingDirectory(self._working_dir_str)
|
|
575
|
+
self.proc.start(self.command[0], self.command[1:])
|
|
576
|
+
if not self.proc.waitForStarted(3500):
|
|
577
|
+
self._append("Failed to start terminal process.\n")
|
|
578
|
+
self.output.setFocus(Qt.OtherFocusReason)
|
|
579
|
+
if self._shell_profile == "cmd":
|
|
580
|
+
QTimer.singleShot(120, self._maybe_append_synthetic_prompt)
|
|
581
|
+
self._update_session_footer()
|
|
582
|
+
|
|
583
|
+
def _read_stdout(self):
|
|
584
|
+
self._append(bytes(self.proc.readAllStandardOutput()).decode(errors="ignore"))
|
|
585
|
+
|
|
586
|
+
def _read_stderr(self):
|
|
587
|
+
self._append(bytes(self.proc.readAllStandardError()).decode(errors="ignore"))
|
|
588
|
+
|
|
589
|
+
def _save_buffer_as(self) -> None:
|
|
590
|
+
suggested = f"{self.session_label.replace(' ', '_')}.txt"
|
|
591
|
+
path, _ = QFileDialog.getSaveFileName(self, "Save terminal output as", suggested, "Text files (*.txt);;All files (*.*)")
|
|
592
|
+
if not path:
|
|
593
|
+
return
|
|
594
|
+
try:
|
|
595
|
+
with open(path, "w", encoding="utf-8", errors="replace") as f:
|
|
596
|
+
f.write(self.output.toPlainText())
|
|
597
|
+
except OSError:
|
|
598
|
+
pass
|
|
599
|
+
|
|
600
|
+
def _open_log(self) -> None:
|
|
601
|
+
if self._log_fp is None:
|
|
602
|
+
self._log_fp = self._log_path.open("a", encoding="utf-8", errors="replace")
|
|
603
|
+
|
|
604
|
+
def _write_log(self, text: str) -> None:
|
|
605
|
+
try:
|
|
606
|
+
if self._log_fp is None:
|
|
607
|
+
self._open_log()
|
|
608
|
+
if self._log_fp is not None:
|
|
609
|
+
self._log_fp.write(text)
|
|
610
|
+
self._log_fp.flush()
|
|
611
|
+
except Exception:
|
|
612
|
+
pass
|
|
613
|
+
|
|
614
|
+
def _close_log(self) -> None:
|
|
615
|
+
try:
|
|
616
|
+
if self._log_fp is not None:
|
|
617
|
+
self._log_fp.flush()
|
|
618
|
+
self._log_fp.close()
|
|
619
|
+
except Exception:
|
|
620
|
+
pass
|
|
621
|
+
self._log_fp = None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
class TerminalTab(QWidget):
|
|
625
|
+
"""MobaXterm-like workspace: device/serial bar, sidebar + tabbed sessions (dark terminal)."""
|
|
626
|
+
|
|
627
|
+
def __init__(
|
|
628
|
+
self,
|
|
629
|
+
get_adb_path: Callable[[], str],
|
|
630
|
+
get_default_ssh_host: Callable[[], str],
|
|
631
|
+
get_default_serial_port: Callable[[], str],
|
|
632
|
+
get_default_serial_baud: Callable[[], str],
|
|
633
|
+
config: AppConfig,
|
|
634
|
+
append_log: Optional[Callable[[str], None]] = None,
|
|
635
|
+
):
|
|
636
|
+
super().__init__()
|
|
637
|
+
self.get_adb_path = get_adb_path
|
|
638
|
+
self.get_default_ssh_host = get_default_ssh_host
|
|
639
|
+
self.get_default_serial_port = get_default_serial_port
|
|
640
|
+
self.get_default_serial_baud = get_default_serial_baud
|
|
641
|
+
self.config = config
|
|
642
|
+
self._append_log = append_log or (lambda _m: None)
|
|
643
|
+
self._placeholder_tab: Optional[QWidget] = None
|
|
644
|
+
self._build_ui()
|
|
645
|
+
|
|
646
|
+
def _init_hidden_session_controls(self) -> None:
|
|
647
|
+
"""ADB device list + serial fields for MainWindow (File Explorer, menus). Combo stays hidden in Terminal."""
|
|
648
|
+
self.device_combo = ExpandAllComboBox(self)
|
|
649
|
+
self.device_combo.setObjectName("SessionDeviceCombo")
|
|
650
|
+
self.device_combo.setMaxVisibleItems(20)
|
|
651
|
+
self.device_combo.hide()
|
|
652
|
+
self.serial_port_edit = QLineEdit(self)
|
|
653
|
+
self.serial_port_edit.setObjectName("TerminalSerialPortHidden")
|
|
654
|
+
self.serial_baud_edit = QLineEdit(self)
|
|
655
|
+
self.serial_baud_edit.setObjectName("TerminalSerialBaudHidden")
|
|
656
|
+
self.serial_port_edit.setText(self.config.default_serial_port or "COM3")
|
|
657
|
+
self.serial_baud_edit.setText(self.config.default_serial_baud or "115200")
|
|
658
|
+
self.serial_port_edit.hide()
|
|
659
|
+
self.serial_baud_edit.hide()
|
|
660
|
+
|
|
661
|
+
def get_adb_serial(self) -> str:
|
|
662
|
+
return self.current_adb_serial()
|
|
663
|
+
|
|
664
|
+
def current_adb_serial(self) -> str:
|
|
665
|
+
d = self.device_combo.currentData()
|
|
666
|
+
if d is not None and str(d).strip():
|
|
667
|
+
return str(d).strip()
|
|
668
|
+
return _serial_from_combo_text(self.device_combo.currentText())
|
|
669
|
+
|
|
670
|
+
def get_session_profile(self) -> SessionProfile:
|
|
671
|
+
return SessionProfile(
|
|
672
|
+
ConnectionKind.ANDROID_ADB,
|
|
673
|
+
adb_serial=self.current_adb_serial(),
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
def get_serial_session_profile(self) -> SessionProfile:
|
|
677
|
+
return SessionProfile(
|
|
678
|
+
ConnectionKind.SERIAL,
|
|
679
|
+
serial_port=self.serial_port_edit.text().strip() or self.get_default_serial_port(),
|
|
680
|
+
serial_baud=self.serial_baud_edit.text().strip() or self.get_default_serial_baud(),
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
def sync_serial_from_config(self) -> None:
|
|
684
|
+
self.serial_port_edit.setText(self.config.default_serial_port or "COM3")
|
|
685
|
+
self.serial_baud_edit.setText(self.config.default_serial_baud or "115200")
|
|
686
|
+
|
|
687
|
+
def _build_ui(self):
|
|
688
|
+
layout = QVBoxLayout(self)
|
|
689
|
+
layout.setContentsMargins(6, 6, 6, 6)
|
|
690
|
+
layout.setSpacing(4)
|
|
691
|
+
|
|
692
|
+
self._init_hidden_session_controls()
|
|
693
|
+
|
|
694
|
+
top_toolbar = QHBoxLayout()
|
|
695
|
+
top_toolbar.setSpacing(8)
|
|
696
|
+
st = self.style()
|
|
697
|
+
b_new = QPushButton("New Session…")
|
|
698
|
+
b_new.setObjectName("MobaToolBtn")
|
|
699
|
+
b_new.setIcon(st.standardIcon(QStyle.SP_FileDialogNewFolder))
|
|
700
|
+
b_new.clicked.connect(self.add_session_dialog)
|
|
701
|
+
top_toolbar.addWidget(b_new)
|
|
702
|
+
top_toolbar.addStretch(1)
|
|
703
|
+
layout.addLayout(top_toolbar)
|
|
704
|
+
start_hint = QLabel(
|
|
705
|
+
"How to start: click New Session, choose protocol/details, then open the terminal tab."
|
|
706
|
+
)
|
|
707
|
+
start_hint.setObjectName("MobaTabCtrlLabel")
|
|
708
|
+
start_hint.setWordWrap(True)
|
|
709
|
+
layout.addWidget(start_hint)
|
|
710
|
+
|
|
711
|
+
split = QSplitter()
|
|
712
|
+
layout.addWidget(split, 1)
|
|
713
|
+
|
|
714
|
+
left = QWidget()
|
|
715
|
+
left.setObjectName("MobaLeftPane")
|
|
716
|
+
left_layout = QVBoxLayout(left)
|
|
717
|
+
left_layout.setContentsMargins(8, 8, 8, 8)
|
|
718
|
+
left_layout.setSpacing(8)
|
|
719
|
+
|
|
720
|
+
bm_lbl = QLabel("Bookmarks")
|
|
721
|
+
bm_lbl.setObjectName("MobaSidebarHeading")
|
|
722
|
+
left_layout.addWidget(bm_lbl)
|
|
723
|
+
self.bookmark_list = QListWidget()
|
|
724
|
+
self.bookmark_list.setObjectName("MobaBookmarkList")
|
|
725
|
+
self.bookmark_list.setIconSize(QSize(20, 20))
|
|
726
|
+
self.bookmark_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
|
727
|
+
self.bookmark_list.itemDoubleClicked.connect(self._on_bookmark_double_clicked)
|
|
728
|
+
self.bookmark_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
729
|
+
self.bookmark_list.customContextMenuRequested.connect(self._on_bookmark_context_menu)
|
|
730
|
+
left_layout.addWidget(self.bookmark_list, 1)
|
|
731
|
+
self._reload_bookmark_sidebar()
|
|
732
|
+
|
|
733
|
+
local_row = QHBoxLayout()
|
|
734
|
+
local_row.setSpacing(4)
|
|
735
|
+
for txt, fn, ic in [
|
|
736
|
+
("CMD", self._open_local_cmd, icon_windows_cmd_console()),
|
|
737
|
+
("PowerShell", self._open_local_powershell, icon_windows_powershell()),
|
|
738
|
+
]:
|
|
739
|
+
b = QPushButton(txt)
|
|
740
|
+
b.setObjectName("MobaToolBtn")
|
|
741
|
+
b.setIcon(ic)
|
|
742
|
+
b.clicked.connect(fn)
|
|
743
|
+
local_row.addWidget(b)
|
|
744
|
+
left_layout.addLayout(local_row)
|
|
745
|
+
|
|
746
|
+
pin_row = QHBoxLayout()
|
|
747
|
+
pin_row.setSpacing(4)
|
|
748
|
+
b1 = QPushButton("Pin CMD")
|
|
749
|
+
b1.setObjectName("MobaToolBtn")
|
|
750
|
+
b1.setIcon(st.standardIcon(QStyle.SP_DialogSaveButton))
|
|
751
|
+
b1.setToolTip("Save Command Prompt as a bookmark")
|
|
752
|
+
b1.clicked.connect(self._pin_local_cmd_bookmark)
|
|
753
|
+
b2 = QPushButton("Pin PS")
|
|
754
|
+
b2.setObjectName("MobaToolBtn")
|
|
755
|
+
b2.setIcon(st.standardIcon(QStyle.SP_DialogSaveButton))
|
|
756
|
+
b2.setToolTip("Save PowerShell as a bookmark")
|
|
757
|
+
b2.clicked.connect(self._pin_local_pwsh_bookmark)
|
|
758
|
+
pin_row.addWidget(b1)
|
|
759
|
+
pin_row.addWidget(b2)
|
|
760
|
+
left_layout.addLayout(pin_row)
|
|
761
|
+
|
|
762
|
+
go = QPushButton("Login…")
|
|
763
|
+
go.setObjectName("MobaToolBtn")
|
|
764
|
+
go.setIcon(st.standardIcon(QStyle.SP_DialogOpenButton))
|
|
765
|
+
go.clicked.connect(self.add_session_dialog)
|
|
766
|
+
left_layout.addWidget(go)
|
|
767
|
+
|
|
768
|
+
right = QWidget()
|
|
769
|
+
right.setObjectName("MobaRightPane")
|
|
770
|
+
right_layout = QVBoxLayout(right)
|
|
771
|
+
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
772
|
+
self.tabs = QTabWidget()
|
|
773
|
+
self.tabs.setObjectName("MobaTabs")
|
|
774
|
+
self.tabs.setTabsClosable(True)
|
|
775
|
+
self.tabs.setMovable(True)
|
|
776
|
+
tb = self.tabs.tabBar()
|
|
777
|
+
tb.setObjectName("MobaTabBar")
|
|
778
|
+
tb.setElideMode(Qt.ElideNone)
|
|
779
|
+
tb.setUsesScrollButtons(True)
|
|
780
|
+
tb.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
781
|
+
tb.customContextMenuRequested.connect(self._on_terminal_tab_bar_context_menu)
|
|
782
|
+
self.tabs.tabCloseRequested.connect(self._on_tab_close)
|
|
783
|
+
right_layout.addWidget(self.tabs, 1)
|
|
784
|
+
|
|
785
|
+
status = QLabel("Ready")
|
|
786
|
+
status.setObjectName("MobaStatus")
|
|
787
|
+
right_layout.addWidget(status)
|
|
788
|
+
|
|
789
|
+
split.addWidget(left)
|
|
790
|
+
split.addWidget(right)
|
|
791
|
+
split.setStretchFactor(0, 0)
|
|
792
|
+
split.setStretchFactor(1, 1)
|
|
793
|
+
split.setSizes([240, 1000])
|
|
794
|
+
|
|
795
|
+
self._add_placeholder_tab()
|
|
796
|
+
|
|
797
|
+
def _ssh_tab_title(self, profile: SessionProfile) -> str:
|
|
798
|
+
h = (profile.ssh_host or "").strip()
|
|
799
|
+
u = (profile.ssh_user or "").strip()
|
|
800
|
+
p = int(profile.ssh_port or 22)
|
|
801
|
+
if u:
|
|
802
|
+
base = f"{u}@{h}"
|
|
803
|
+
else:
|
|
804
|
+
base = h or "SSH"
|
|
805
|
+
if p != 22:
|
|
806
|
+
return f"{base}:{p}"
|
|
807
|
+
return base
|
|
808
|
+
|
|
809
|
+
def _reload_bookmark_sidebar(self) -> None:
|
|
810
|
+
self.bookmark_list.clear()
|
|
811
|
+
for bm in self.config.session_bookmarks:
|
|
812
|
+
if not isinstance(bm, dict):
|
|
813
|
+
continue
|
|
814
|
+
if bm.get("kind") not in ("ssh", "adb", "serial", "local_cmd", "local_pwsh"):
|
|
815
|
+
continue
|
|
816
|
+
it = QListWidgetItem(bm.get("name") or "Untitled")
|
|
817
|
+
it.setIcon(bookmark_icon_from_entry(bm, self))
|
|
818
|
+
it.setData(Qt.UserRole, bm)
|
|
819
|
+
self.bookmark_list.addItem(it)
|
|
820
|
+
|
|
821
|
+
def _on_bookmark_double_clicked(self, item: QListWidgetItem) -> None:
|
|
822
|
+
bm = item.data(Qt.UserRole)
|
|
823
|
+
if isinstance(bm, dict):
|
|
824
|
+
self._open_bookmark(bm)
|
|
825
|
+
|
|
826
|
+
def _on_bookmark_context_menu(self, pos) -> None:
|
|
827
|
+
it = self.bookmark_list.itemAt(pos)
|
|
828
|
+
m = QMenu(self)
|
|
829
|
+
a_login = m.addAction("Start new session…")
|
|
830
|
+
a_login.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
|
|
831
|
+
a_login.triggered.connect(self.add_session_dialog)
|
|
832
|
+
if it is not None:
|
|
833
|
+
bm = it.data(Qt.UserRole)
|
|
834
|
+
if isinstance(bm, dict):
|
|
835
|
+
m.addSeparator()
|
|
836
|
+
a_open = m.addAction("Open bookmark")
|
|
837
|
+
a_open.setIcon(self.style().standardIcon(QStyle.SP_DialogOpenButton))
|
|
838
|
+
a_open.triggered.connect(lambda: self._open_bookmark(bm))
|
|
839
|
+
a_new = m.addAction("Start new session from bookmark")
|
|
840
|
+
a_new.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
|
|
841
|
+
a_new.triggered.connect(lambda: self._open_bookmark(bm))
|
|
842
|
+
selected = [x for x in self.bookmark_list.selectedItems() if isinstance(x.data(Qt.UserRole), dict)]
|
|
843
|
+
if selected:
|
|
844
|
+
m.addSeparator()
|
|
845
|
+
a_del = m.addAction(f"Delete selected bookmark(s) ({len(selected)})")
|
|
846
|
+
a_del.setIcon(self.style().standardIcon(QStyle.SP_TrashIcon))
|
|
847
|
+
a_del.triggered.connect(self._delete_selected_bookmarks)
|
|
848
|
+
m.exec_(self.bookmark_list.mapToGlobal(pos))
|
|
849
|
+
|
|
850
|
+
def _delete_bookmark_by_name(self, name: str) -> None:
|
|
851
|
+
if not name:
|
|
852
|
+
return
|
|
853
|
+
self.config.session_bookmarks = [
|
|
854
|
+
x for x in self.config.session_bookmarks if isinstance(x, dict) and x.get("name") != name
|
|
855
|
+
]
|
|
856
|
+
self.config.save()
|
|
857
|
+
self._reload_bookmark_sidebar()
|
|
858
|
+
|
|
859
|
+
def _delete_selected_bookmarks(self) -> None:
|
|
860
|
+
names = []
|
|
861
|
+
for it in self.bookmark_list.selectedItems():
|
|
862
|
+
bm = it.data(Qt.UserRole)
|
|
863
|
+
if isinstance(bm, dict):
|
|
864
|
+
n = str(bm.get("name", "")).strip()
|
|
865
|
+
if n:
|
|
866
|
+
names.append(n)
|
|
867
|
+
if not names:
|
|
868
|
+
return
|
|
869
|
+
drop = set(names)
|
|
870
|
+
self.config.session_bookmarks = [
|
|
871
|
+
x
|
|
872
|
+
for x in self.config.session_bookmarks
|
|
873
|
+
if not (isinstance(x, dict) and str(x.get("name", "")).strip() in drop)
|
|
874
|
+
]
|
|
875
|
+
self.config.save()
|
|
876
|
+
self._reload_bookmark_sidebar()
|
|
877
|
+
|
|
878
|
+
def _open_bookmark(self, bm: dict) -> None:
|
|
879
|
+
k = bm.get("kind")
|
|
880
|
+
if k == "local_cmd":
|
|
881
|
+
self._open_local_cmd()
|
|
882
|
+
return
|
|
883
|
+
if k == "local_pwsh":
|
|
884
|
+
self._open_local_powershell()
|
|
885
|
+
return
|
|
886
|
+
if k == "adb":
|
|
887
|
+
serial = (bm.get("adb_serial") or "").strip()
|
|
888
|
+
if not serial:
|
|
889
|
+
QMessageBox.information(self, "Bookmark", "This bookmark has no device serial.")
|
|
890
|
+
return
|
|
891
|
+
adb = self.get_adb_path()
|
|
892
|
+
cmd = adb_interactive_shell_command(adb, serial)
|
|
893
|
+
label = (bm.get("adb_label") or "").strip() or f"{friendly_name_for_serial(adb, serial)} · {serial}"
|
|
894
|
+
self.add_session(label, cmd, banner=_adb_terminal_banner(adb, serial, label))
|
|
895
|
+
return
|
|
896
|
+
if k == "ssh":
|
|
897
|
+
try:
|
|
898
|
+
sp = int(bm.get("ssh_port") or 22)
|
|
899
|
+
except (TypeError, ValueError):
|
|
900
|
+
sp = 22
|
|
901
|
+
profile = SessionProfile(
|
|
902
|
+
ConnectionKind.SSH_SFTP,
|
|
903
|
+
ssh_host=bm.get("ssh_host", ""),
|
|
904
|
+
ssh_user=bm.get("ssh_user", ""),
|
|
905
|
+
ssh_port=sp,
|
|
906
|
+
ssh_password=bm.get("ssh_password", "") or "",
|
|
907
|
+
)
|
|
908
|
+
self.add_session(self._ssh_tab_title(profile), ssh_command_args(profile))
|
|
909
|
+
return
|
|
910
|
+
if k == "serial":
|
|
911
|
+
port = bm.get("serial_port") or self.get_default_serial_port()
|
|
912
|
+
baud = bm.get("serial_baud") or self.get_default_serial_baud()
|
|
913
|
+
cmd = [sys.executable, "-m", "serial.tools.miniterm", str(port), str(baud)]
|
|
914
|
+
self.add_session(f"Serial · {port}", cmd)
|
|
915
|
+
|
|
916
|
+
def _open_local_cmd(self) -> None:
|
|
917
|
+
# Small quality-of-life aliases so Linux-style habits still work in CMD.
|
|
918
|
+
cmd_exe = os.environ.get("COMSPEC", "cmd.exe")
|
|
919
|
+
# Chain with `&` — `$T` is for inside a doskey macro body, not two separate doskey commands.
|
|
920
|
+
self.add_session(
|
|
921
|
+
"Command Prompt",
|
|
922
|
+
[cmd_exe, "/K", "doskey ls=dir & doskey pwd=cd"],
|
|
923
|
+
working_dir=os.getcwd(),
|
|
924
|
+
shell_profile="cmd",
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
def _open_local_powershell(self) -> None:
|
|
928
|
+
sys_root = os.environ.get("SystemRoot", r"C:\Windows")
|
|
929
|
+
ps = Path(sys_root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
|
930
|
+
ps_exe = str(ps) if ps.is_file() else "powershell.exe"
|
|
931
|
+
# Startup command: `lx` is a common typo for `ls` (helps when muscle memory slips).
|
|
932
|
+
self.add_session(
|
|
933
|
+
"Windows PowerShell",
|
|
934
|
+
[
|
|
935
|
+
ps_exe,
|
|
936
|
+
"-NoLogo",
|
|
937
|
+
"-NoProfile",
|
|
938
|
+
"-ExecutionPolicy",
|
|
939
|
+
"Bypass",
|
|
940
|
+
"-NoExit",
|
|
941
|
+
"-Command",
|
|
942
|
+
"Set-Alias -Name lx -Value Get-ChildItem -Scope Global",
|
|
943
|
+
],
|
|
944
|
+
working_dir=os.getcwd(),
|
|
945
|
+
shell_profile="powershell",
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
def _next_bookmark_name(self, base: str) -> str:
|
|
949
|
+
names = {x.get("name") for x in self.config.session_bookmarks if isinstance(x, dict)}
|
|
950
|
+
if base not in names:
|
|
951
|
+
return base
|
|
952
|
+
n = 2
|
|
953
|
+
while f"{base} ({n})" in names:
|
|
954
|
+
n += 1
|
|
955
|
+
return f"{base} ({n})"
|
|
956
|
+
|
|
957
|
+
def _pin_local_cmd_bookmark(self) -> None:
|
|
958
|
+
name = self._next_bookmark_name("Command Prompt")
|
|
959
|
+
self.config.session_bookmarks.append({"name": name, "kind": "local_cmd"})
|
|
960
|
+
self.config.save()
|
|
961
|
+
self._reload_bookmark_sidebar()
|
|
962
|
+
|
|
963
|
+
def _pin_local_pwsh_bookmark(self) -> None:
|
|
964
|
+
name = self._next_bookmark_name("Windows PowerShell")
|
|
965
|
+
self.config.session_bookmarks.append({"name": name, "kind": "local_pwsh"})
|
|
966
|
+
self.config.save()
|
|
967
|
+
self._reload_bookmark_sidebar()
|
|
968
|
+
|
|
969
|
+
def _add_placeholder_tab(self) -> None:
|
|
970
|
+
if self._placeholder_tab is not None:
|
|
971
|
+
return
|
|
972
|
+
w = QWidget()
|
|
973
|
+
w.setObjectName("MobaRightPane")
|
|
974
|
+
lay = QVBoxLayout(w)
|
|
975
|
+
lay.setContentsMargins(20, 16, 20, 16)
|
|
976
|
+
msg = QLabel(
|
|
977
|
+
"No shell session yet.\n\n"
|
|
978
|
+
"Use the sidebar to open Command Prompt or PowerShell, or New Session for SSH or Android (ADB). "
|
|
979
|
+
"Plug in the device and use Session → Refresh if the list is empty."
|
|
980
|
+
)
|
|
981
|
+
msg.setWordWrap(True)
|
|
982
|
+
msg.setObjectName("MobaMenuText")
|
|
983
|
+
msg.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
984
|
+
lay.addStretch(1)
|
|
985
|
+
lay.addWidget(msg)
|
|
986
|
+
go = QPushButton("New Session…")
|
|
987
|
+
go.setObjectName("MobaToolBtn")
|
|
988
|
+
go.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
|
|
989
|
+
go.clicked.connect(self.add_session_dialog)
|
|
990
|
+
lay.addWidget(go, alignment=Qt.AlignHCenter)
|
|
991
|
+
lay.addStretch(2)
|
|
992
|
+
self._placeholder_tab = w
|
|
993
|
+
self.tabs.addTab(w, "Start")
|
|
994
|
+
|
|
995
|
+
def _on_tab_close(self, index: int) -> None:
|
|
996
|
+
w = self.tabs.widget(index)
|
|
997
|
+
if isinstance(w, SessionWidget):
|
|
998
|
+
w.shutdown()
|
|
999
|
+
if w is self._placeholder_tab:
|
|
1000
|
+
self._placeholder_tab = None
|
|
1001
|
+
self.tabs.removeTab(index)
|
|
1002
|
+
if self.tabs.count() == 0:
|
|
1003
|
+
self._add_placeholder_tab()
|
|
1004
|
+
|
|
1005
|
+
def _on_terminal_tab_bar_context_menu(self, pos) -> None:
|
|
1006
|
+
"""Moba-style: right-click a session tab for close / close others / close to the side."""
|
|
1007
|
+
tb = self.tabs.tabBar()
|
|
1008
|
+
idx = tb.tabAt(pos)
|
|
1009
|
+
if idx < 0:
|
|
1010
|
+
return
|
|
1011
|
+
m = QMenu(self)
|
|
1012
|
+
a_close = m.addAction("Close")
|
|
1013
|
+
a_close.setToolTip("Close this tab")
|
|
1014
|
+
a_others = m.addAction("Close others")
|
|
1015
|
+
a_others.setToolTip("Close all tabs except this one")
|
|
1016
|
+
a_right = m.addAction("Close to the right")
|
|
1017
|
+
a_right.setToolTip("Close every tab to the right of this one")
|
|
1018
|
+
a_left = m.addAction("Close to the left")
|
|
1019
|
+
a_left.setToolTip("Close every tab to the left of this one")
|
|
1020
|
+
chosen = m.exec_(tb.mapToGlobal(pos))
|
|
1021
|
+
if chosen == a_close:
|
|
1022
|
+
self._remove_tab_at(idx)
|
|
1023
|
+
if self.tabs.count() == 0:
|
|
1024
|
+
self._add_placeholder_tab()
|
|
1025
|
+
elif chosen == a_others:
|
|
1026
|
+
self._close_all_tabs_except(idx)
|
|
1027
|
+
elif chosen == a_right:
|
|
1028
|
+
self._close_tabs_to_the_right_of(idx)
|
|
1029
|
+
elif chosen == a_left:
|
|
1030
|
+
self._close_tabs_to_the_left_of(idx)
|
|
1031
|
+
|
|
1032
|
+
def _close_all_tabs_except(self, keep_index: int) -> None:
|
|
1033
|
+
for i in range(self.tabs.count() - 1, -1, -1):
|
|
1034
|
+
if i != keep_index:
|
|
1035
|
+
self._remove_tab_at(i)
|
|
1036
|
+
if self.tabs.count() == 0:
|
|
1037
|
+
self._add_placeholder_tab()
|
|
1038
|
+
|
|
1039
|
+
def _close_tabs_to_the_right_of(self, index: int) -> None:
|
|
1040
|
+
for i in range(self.tabs.count() - 1, index, -1):
|
|
1041
|
+
self._remove_tab_at(i)
|
|
1042
|
+
if self.tabs.count() == 0:
|
|
1043
|
+
self._add_placeholder_tab()
|
|
1044
|
+
|
|
1045
|
+
def _close_tabs_to_the_left_of(self, index: int) -> None:
|
|
1046
|
+
for i in range(index - 1, -1, -1):
|
|
1047
|
+
self._remove_tab_at(i)
|
|
1048
|
+
if self.tabs.count() == 0:
|
|
1049
|
+
self._add_placeholder_tab()
|
|
1050
|
+
|
|
1051
|
+
def _remove_tab_at(self, index: int) -> None:
|
|
1052
|
+
"""Remove tab by index; shutdown SessionWidget if present."""
|
|
1053
|
+
if index < 0 or index >= self.tabs.count():
|
|
1054
|
+
return
|
|
1055
|
+
w = self.tabs.widget(index)
|
|
1056
|
+
if isinstance(w, SessionWidget):
|
|
1057
|
+
w.shutdown()
|
|
1058
|
+
if w is self._placeholder_tab:
|
|
1059
|
+
self._placeholder_tab = None
|
|
1060
|
+
self.tabs.removeTab(index)
|
|
1061
|
+
|
|
1062
|
+
def add_session_dialog(self) -> None:
|
|
1063
|
+
dlg = SessionLoginDialog(
|
|
1064
|
+
self.get_adb_path,
|
|
1065
|
+
self.get_default_ssh_host(),
|
|
1066
|
+
self.current_adb_serial() or "",
|
|
1067
|
+
self,
|
|
1068
|
+
for_terminal=True,
|
|
1069
|
+
config=self.config,
|
|
1070
|
+
on_bookmarks_changed=self._reload_bookmark_sidebar,
|
|
1071
|
+
)
|
|
1072
|
+
if dlg.exec_() != QDialog.Accepted:
|
|
1073
|
+
return
|
|
1074
|
+
o = dlg.outcome()
|
|
1075
|
+
if o:
|
|
1076
|
+
self._apply_login_outcome(o)
|
|
1077
|
+
|
|
1078
|
+
def _apply_login_outcome(self, o: SessionLoginOutcome) -> None:
|
|
1079
|
+
if o.kind == "adb":
|
|
1080
|
+
adb = self.get_adb_path()
|
|
1081
|
+
serial = (o.adb_serial or "").strip()
|
|
1082
|
+
cmd = adb_interactive_shell_command(adb, serial or None)
|
|
1083
|
+
short = (o.adb_display_label or "").strip() or f"{friendly_name_for_serial(self.get_adb_path(), serial)} · {serial}"
|
|
1084
|
+
self.add_session(short, cmd, banner=_adb_terminal_banner(adb, serial, short))
|
|
1085
|
+
elif o.kind == "sftp":
|
|
1086
|
+
profile = SessionProfile(
|
|
1087
|
+
ConnectionKind.SSH_SFTP,
|
|
1088
|
+
ssh_host=o.sftp_host,
|
|
1089
|
+
ssh_user=o.sftp_user,
|
|
1090
|
+
ssh_port=o.sftp_port,
|
|
1091
|
+
ssh_password=o.sftp_password or "",
|
|
1092
|
+
)
|
|
1093
|
+
self.add_session(self._ssh_tab_title(profile), ssh_command_args(profile))
|
|
1094
|
+
elif o.kind == "serial":
|
|
1095
|
+
port = (o.serial_port or "").strip() or self.get_default_serial_port()
|
|
1096
|
+
baud = (o.serial_baud or "").strip() or self.get_default_serial_baud()
|
|
1097
|
+
cmd = [sys.executable, "-m", "serial.tools.miniterm", port, baud]
|
|
1098
|
+
self.add_session(f"Serial · {port}", cmd)
|
|
1099
|
+
elif o.kind == "local_cmd":
|
|
1100
|
+
self._open_local_cmd()
|
|
1101
|
+
elif o.kind == "local_pwsh":
|
|
1102
|
+
self._open_local_powershell()
|
|
1103
|
+
|
|
1104
|
+
def add_session(
|
|
1105
|
+
self,
|
|
1106
|
+
label: str,
|
|
1107
|
+
command: List[str],
|
|
1108
|
+
*,
|
|
1109
|
+
banner: Optional[str] = None,
|
|
1110
|
+
working_dir: Optional[str] = None,
|
|
1111
|
+
shell_profile: Optional[str] = None,
|
|
1112
|
+
):
|
|
1113
|
+
if self._placeholder_tab is not None:
|
|
1114
|
+
idx = self.tabs.indexOf(self._placeholder_tab)
|
|
1115
|
+
if idx >= 0:
|
|
1116
|
+
self.tabs.removeTab(idx)
|
|
1117
|
+
self._placeholder_tab = None
|
|
1118
|
+
widget = SessionWidget(
|
|
1119
|
+
label,
|
|
1120
|
+
command,
|
|
1121
|
+
banner=banner,
|
|
1122
|
+
working_dir=working_dir,
|
|
1123
|
+
shell_profile=shell_profile,
|
|
1124
|
+
)
|
|
1125
|
+
idx = self.tabs.addTab(widget, label)
|
|
1126
|
+
self.tabs.setCurrentIndex(idx)
|
|
1127
|
+
|
|
1128
|
+
def close_session(self):
|
|
1129
|
+
idx = self.tabs.currentIndex()
|
|
1130
|
+
if idx < 0:
|
|
1131
|
+
return
|
|
1132
|
+
w = self.tabs.widget(idx)
|
|
1133
|
+
if isinstance(w, SessionWidget):
|
|
1134
|
+
w.shutdown()
|
|
1135
|
+
if w is self._placeholder_tab:
|
|
1136
|
+
self._placeholder_tab = None
|
|
1137
|
+
self.tabs.removeTab(idx)
|
|
1138
|
+
if self.tabs.count() == 0:
|
|
1139
|
+
self._add_placeholder_tab()
|
|
1140
|
+
|
|
1141
|
+
def send_line_to_current_session(self, line: str) -> bool:
|
|
1142
|
+
"""Send one line to the active terminal tab. Returns False if no running session."""
|
|
1143
|
+
w = self.tabs.currentWidget()
|
|
1144
|
+
if not isinstance(w, SessionWidget):
|
|
1145
|
+
QMessageBox.information(
|
|
1146
|
+
self,
|
|
1147
|
+
"Terminal",
|
|
1148
|
+
"Open a terminal session tab first (e.g. Session → SSH → New SSH session).",
|
|
1149
|
+
)
|
|
1150
|
+
return False
|
|
1151
|
+
w.send_line(line)
|
|
1152
|
+
self.tabs.setCurrentWidget(w)
|
|
1153
|
+
return True
|
|
1154
|
+
|
|
1155
|
+
def shutdown_all_sessions(self) -> None:
|
|
1156
|
+
"""Terminate every open shell (ADB, SSH, serial, local) before the application exits."""
|
|
1157
|
+
for i in range(self.tabs.count()):
|
|
1158
|
+
w = self.tabs.widget(i)
|
|
1159
|
+
if isinstance(w, SessionWidget):
|
|
1160
|
+
w.shutdown(wait_for_process=True)
|
|
1161
|
+
|
|
1162
|
+
def _open_current_connection(self):
|
|
1163
|
+
self.open_session_matching_profile(self.get_session_profile())
|
|
1164
|
+
|
|
1165
|
+
def open_session_matching_profile(self, profile: SessionProfile):
|
|
1166
|
+
if profile.kind == ConnectionKind.ANDROID_ADB:
|
|
1167
|
+
adb = self.get_adb_path()
|
|
1168
|
+
serial = (profile.adb_serial or "").strip()
|
|
1169
|
+
cmd = adb_interactive_shell_command(adb, serial or None)
|
|
1170
|
+
short = f"{friendly_name_for_serial(adb, serial)} · {serial}" if serial else "ADB"
|
|
1171
|
+
self.add_session(short, cmd, banner=_adb_terminal_banner(adb, serial, short))
|
|
1172
|
+
elif profile.kind == ConnectionKind.SSH_SFTP:
|
|
1173
|
+
if not (profile.ssh_host or "").strip():
|
|
1174
|
+
QMessageBox.information(
|
|
1175
|
+
self,
|
|
1176
|
+
"SSH",
|
|
1177
|
+
"Enter host in File Explorer → SFTP tab, then use "
|
|
1178
|
+
"Session → Open SSH terminal (from Explorer SFTP fields).",
|
|
1179
|
+
)
|
|
1180
|
+
return
|
|
1181
|
+
self.add_session(self._ssh_tab_title(profile), ssh_command_args(profile))
|
|
1182
|
+
elif profile.kind == ConnectionKind.FTP:
|
|
1183
|
+
QMessageBox.information(
|
|
1184
|
+
self,
|
|
1185
|
+
"FTP",
|
|
1186
|
+
"FTP is for file transfer in File Explorer. Use New Session for a shell.",
|
|
1187
|
+
)
|
|
1188
|
+
elif profile.kind == ConnectionKind.SERIAL:
|
|
1189
|
+
port = profile.serial_port or self.get_default_serial_port()
|
|
1190
|
+
baud = profile.serial_baud or self.get_default_serial_baud()
|
|
1191
|
+
cmd = [sys.executable, "-m", "serial.tools.miniterm", port, baud]
|
|
1192
|
+
self.add_session(f"Serial · {port}", cmd)
|