ntermqt 0.1.1__py3-none-any.whl → 0.1.4__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.
- nterm/__main__.py +132 -9
- nterm/examples/basic_terminal.py +415 -0
- nterm/manager/tree.py +125 -42
- nterm/scripting/__init__.py +43 -0
- nterm/scripting/api.py +447 -0
- nterm/scripting/cli.py +305 -0
- nterm/session/local_terminal.py +225 -0
- nterm/session/pty_transport.py +105 -91
- nterm/terminal/bridge.py +10 -0
- nterm/terminal/resources/terminal.html +9 -4
- nterm/terminal/resources/terminal.js +14 -1
- nterm/terminal/widget.py +73 -2
- nterm/theme/engine.py +45 -0
- nterm/theme/themes/nord_hybrid.yaml +43 -0
- nterm/vault/store.py +3 -3
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.4.dist-info}/METADATA +157 -21
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.4.dist-info}/RECORD +20 -14
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.4.dist-info}/entry_points.txt +1 -0
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.4.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.4.dist-info}/top_level.txt +0 -0
nterm/__main__.py
CHANGED
|
@@ -22,6 +22,7 @@ from nterm.manager import (
|
|
|
22
22
|
)
|
|
23
23
|
from nterm.terminal.widget import TerminalWidget
|
|
24
24
|
from nterm.session.ssh import SSHSession
|
|
25
|
+
from nterm.session.local_terminal import LocalTerminal
|
|
25
26
|
from nterm.connection.profile import ConnectionProfile, AuthConfig
|
|
26
27
|
from nterm.vault import CredentialManagerWidget
|
|
27
28
|
from nterm.vault.resolver import CredentialResolver
|
|
@@ -225,6 +226,35 @@ class TerminalTab(QWidget):
|
|
|
225
226
|
return True
|
|
226
227
|
|
|
227
228
|
|
|
229
|
+
class LocalTerminalTab(QWidget):
|
|
230
|
+
"""A terminal tab for local processes (shell, IPython, etc.)."""
|
|
231
|
+
|
|
232
|
+
def __init__(self, name: str, session: LocalTerminal, parent=None):
|
|
233
|
+
super().__init__(parent)
|
|
234
|
+
self.name = name
|
|
235
|
+
self.local_session = session
|
|
236
|
+
|
|
237
|
+
layout = QVBoxLayout(self)
|
|
238
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
239
|
+
|
|
240
|
+
self.terminal = TerminalWidget()
|
|
241
|
+
layout.addWidget(self.terminal)
|
|
242
|
+
|
|
243
|
+
self.terminal.attach_session(self.local_session)
|
|
244
|
+
|
|
245
|
+
def connect(self):
|
|
246
|
+
"""Start the local process."""
|
|
247
|
+
self.local_session.connect()
|
|
248
|
+
|
|
249
|
+
def disconnect(self):
|
|
250
|
+
"""Terminate the local process."""
|
|
251
|
+
self.local_session.disconnect()
|
|
252
|
+
|
|
253
|
+
def is_connected(self) -> bool:
|
|
254
|
+
"""Check if the process is still running."""
|
|
255
|
+
return self.local_session.is_connected
|
|
256
|
+
|
|
257
|
+
|
|
228
258
|
class MainWindow(QMainWindow):
|
|
229
259
|
"""
|
|
230
260
|
Main application window with session tree and tabbed terminals.
|
|
@@ -274,7 +304,7 @@ class MainWindow(QMainWindow):
|
|
|
274
304
|
# Apply multiline threshold to all open terminals
|
|
275
305
|
for i in range(self.tab_widget.count()):
|
|
276
306
|
tab = self.tab_widget.widget(i)
|
|
277
|
-
if isinstance(tab, TerminalTab):
|
|
307
|
+
if isinstance(tab, (TerminalTab, LocalTerminalTab)):
|
|
278
308
|
tab.terminal.set_multiline_threshold(settings.multiline_paste_threshold)
|
|
279
309
|
|
|
280
310
|
|
|
@@ -401,6 +431,32 @@ class MainWindow(QMainWindow):
|
|
|
401
431
|
action.triggered.connect(lambda checked, n=theme_name: self._apply_theme_by_name(n))
|
|
402
432
|
theme_menu.addAction(action)
|
|
403
433
|
|
|
434
|
+
# Dev menu
|
|
435
|
+
dev_menu = menubar.addMenu("&Dev")
|
|
436
|
+
|
|
437
|
+
# IPython submenu
|
|
438
|
+
ipython_menu = dev_menu.addMenu("&IPython")
|
|
439
|
+
|
|
440
|
+
ipython_tab_action = QAction("Open in &Tab", self)
|
|
441
|
+
ipython_tab_action.setShortcut(QKeySequence("Ctrl+Shift+I"))
|
|
442
|
+
ipython_tab_action.triggered.connect(lambda: self._open_local("IPython", LocalTerminal.ipython(), "tab"))
|
|
443
|
+
ipython_menu.addAction(ipython_tab_action)
|
|
444
|
+
|
|
445
|
+
ipython_window_action = QAction("Open in &Window", self)
|
|
446
|
+
ipython_window_action.triggered.connect(lambda: self._open_local("IPython", LocalTerminal.ipython(), "window"))
|
|
447
|
+
ipython_menu.addAction(ipython_window_action)
|
|
448
|
+
|
|
449
|
+
# Shell submenu
|
|
450
|
+
shell_menu = dev_menu.addMenu("&Shell")
|
|
451
|
+
|
|
452
|
+
shell_tab_action = QAction("Open in &Tab", self)
|
|
453
|
+
shell_tab_action.triggered.connect(lambda: self._open_local("Shell", LocalTerminal(), "tab"))
|
|
454
|
+
shell_menu.addAction(shell_tab_action)
|
|
455
|
+
|
|
456
|
+
shell_window_action = QAction("Open in &Window", self)
|
|
457
|
+
shell_window_action.triggered.connect(lambda: self._open_local("Shell", LocalTerminal(), "window"))
|
|
458
|
+
shell_menu.addAction(shell_window_action)
|
|
459
|
+
|
|
404
460
|
def _on_import_sessions(self):
|
|
405
461
|
"""Show import dialog."""
|
|
406
462
|
dialog = ImportDialog(self.session_store, self)
|
|
@@ -445,7 +501,7 @@ class MainWindow(QMainWindow):
|
|
|
445
501
|
# Update all open terminal tabs
|
|
446
502
|
for i in range(self.tab_widget.count()):
|
|
447
503
|
tab = self.tab_widget.widget(i)
|
|
448
|
-
if isinstance(tab, TerminalTab):
|
|
504
|
+
if isinstance(tab, (TerminalTab, LocalTerminalTab)):
|
|
449
505
|
tab.terminal.set_theme(theme)
|
|
450
506
|
|
|
451
507
|
def _apply_qt_theme(self, theme: Theme):
|
|
@@ -560,6 +616,23 @@ class MainWindow(QMainWindow):
|
|
|
560
616
|
self._child_windows.append(window)
|
|
561
617
|
window.destroyed.connect(lambda: self._child_windows.remove(window))
|
|
562
618
|
|
|
619
|
+
def _open_local(self, name: str, session: LocalTerminal, mode: str):
|
|
620
|
+
"""Open a local terminal session (IPython, shell, etc.)."""
|
|
621
|
+
if mode == "tab":
|
|
622
|
+
tab = LocalTerminalTab(name, session)
|
|
623
|
+
tab.terminal.set_theme(self.current_theme)
|
|
624
|
+
idx = self.tab_widget.addTab(tab, name)
|
|
625
|
+
self.tab_widget.setCurrentIndex(idx)
|
|
626
|
+
self.tab_widget.setTabToolTip(idx, f"{name} (local)")
|
|
627
|
+
tab.connect()
|
|
628
|
+
else:
|
|
629
|
+
window = LocalTerminalWindow(name, session, self.current_theme)
|
|
630
|
+
window.show()
|
|
631
|
+
if not hasattr(self, '_child_windows'):
|
|
632
|
+
self._child_windows = []
|
|
633
|
+
self._child_windows.append(window)
|
|
634
|
+
window.destroyed.connect(lambda: self._child_windows.remove(window))
|
|
635
|
+
|
|
563
636
|
# -------------------------------------------------------------------------
|
|
564
637
|
# Tab Management (NEW)
|
|
565
638
|
# -------------------------------------------------------------------------
|
|
@@ -569,7 +642,7 @@ class MainWindow(QMainWindow):
|
|
|
569
642
|
count = 0
|
|
570
643
|
for i in range(self.tab_widget.count()):
|
|
571
644
|
tab = self.tab_widget.widget(i)
|
|
572
|
-
if isinstance(tab, TerminalTab) and tab.is_connected():
|
|
645
|
+
if isinstance(tab, (TerminalTab, LocalTerminalTab)) and tab.is_connected():
|
|
573
646
|
count += 1
|
|
574
647
|
return count
|
|
575
648
|
|
|
@@ -619,13 +692,29 @@ class MainWindow(QMainWindow):
|
|
|
619
692
|
|
|
620
693
|
tab.disconnect()
|
|
621
694
|
|
|
695
|
+
elif isinstance(tab, LocalTerminalTab):
|
|
696
|
+
# Check if process is still running
|
|
697
|
+
if tab.is_connected():
|
|
698
|
+
reply = QMessageBox.question(
|
|
699
|
+
self,
|
|
700
|
+
"Close Tab",
|
|
701
|
+
f"'{tab.name}' is still running.\n\n"
|
|
702
|
+
"Terminate and close this tab?",
|
|
703
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
704
|
+
QMessageBox.StandardButton.No
|
|
705
|
+
)
|
|
706
|
+
if reply != QMessageBox.StandardButton.Yes:
|
|
707
|
+
return
|
|
708
|
+
|
|
709
|
+
tab.disconnect()
|
|
710
|
+
|
|
622
711
|
self.tab_widget.removeTab(index)
|
|
623
712
|
|
|
624
713
|
def _close_other_tabs(self, keep_index: int):
|
|
625
714
|
"""Close all tabs except the specified one."""
|
|
626
715
|
active_count = self._get_active_session_count()
|
|
627
716
|
tab_to_keep = self.tab_widget.widget(keep_index)
|
|
628
|
-
keep_is_active = isinstance(tab_to_keep, TerminalTab) and tab_to_keep.is_connected()
|
|
717
|
+
keep_is_active = isinstance(tab_to_keep, (TerminalTab, LocalTerminalTab)) and tab_to_keep.is_connected()
|
|
629
718
|
other_active = active_count - (1 if keep_is_active else 0)
|
|
630
719
|
|
|
631
720
|
if other_active > 0:
|
|
@@ -644,7 +733,7 @@ class MainWindow(QMainWindow):
|
|
|
644
733
|
for i in range(self.tab_widget.count() - 1, -1, -1):
|
|
645
734
|
if i != keep_index:
|
|
646
735
|
tab = self.tab_widget.widget(i)
|
|
647
|
-
if isinstance(tab, TerminalTab):
|
|
736
|
+
if isinstance(tab, (TerminalTab, LocalTerminalTab)):
|
|
648
737
|
tab.disconnect()
|
|
649
738
|
self.tab_widget.removeTab(i)
|
|
650
739
|
|
|
@@ -658,7 +747,7 @@ class MainWindow(QMainWindow):
|
|
|
658
747
|
active_count = 0
|
|
659
748
|
for i in range(index + 1, self.tab_widget.count()):
|
|
660
749
|
tab = self.tab_widget.widget(i)
|
|
661
|
-
if isinstance(tab, TerminalTab) and tab.is_connected():
|
|
750
|
+
if isinstance(tab, (TerminalTab, LocalTerminalTab)) and tab.is_connected():
|
|
662
751
|
active_count += 1
|
|
663
752
|
|
|
664
753
|
if active_count > 0:
|
|
@@ -676,7 +765,7 @@ class MainWindow(QMainWindow):
|
|
|
676
765
|
# Close from end to avoid index shifting
|
|
677
766
|
for i in range(self.tab_widget.count() - 1, index, -1):
|
|
678
767
|
tab = self.tab_widget.widget(i)
|
|
679
|
-
if isinstance(tab, TerminalTab):
|
|
768
|
+
if isinstance(tab, (TerminalTab, LocalTerminalTab)):
|
|
680
769
|
tab.disconnect()
|
|
681
770
|
self.tab_widget.removeTab(i)
|
|
682
771
|
|
|
@@ -702,7 +791,7 @@ class MainWindow(QMainWindow):
|
|
|
702
791
|
# Close all tabs
|
|
703
792
|
while self.tab_widget.count() > 0:
|
|
704
793
|
tab = self.tab_widget.widget(0)
|
|
705
|
-
if isinstance(tab, TerminalTab):
|
|
794
|
+
if isinstance(tab, (TerminalTab, LocalTerminalTab)):
|
|
706
795
|
tab.disconnect()
|
|
707
796
|
self.tab_widget.removeTab(0)
|
|
708
797
|
|
|
@@ -741,7 +830,7 @@ class MainWindow(QMainWindow):
|
|
|
741
830
|
# Disconnect all tabs
|
|
742
831
|
for i in range(self.tab_widget.count()):
|
|
743
832
|
tab = self.tab_widget.widget(i)
|
|
744
|
-
if isinstance(tab, TerminalTab):
|
|
833
|
+
if isinstance(tab, (TerminalTab, LocalTerminalTab)):
|
|
745
834
|
tab.disconnect()
|
|
746
835
|
|
|
747
836
|
self.session_store.close()
|
|
@@ -790,6 +879,40 @@ class TerminalWindow(QMainWindow):
|
|
|
790
879
|
event.accept()
|
|
791
880
|
|
|
792
881
|
|
|
882
|
+
class LocalTerminalWindow(QMainWindow):
|
|
883
|
+
"""Standalone window for local terminal sessions."""
|
|
884
|
+
|
|
885
|
+
def __init__(self, name: str, session: LocalTerminal, theme: Theme):
|
|
886
|
+
super().__init__()
|
|
887
|
+
self.setWindowTitle(f"{name} - Local")
|
|
888
|
+
self.resize(1000, 700)
|
|
889
|
+
|
|
890
|
+
self.setStyleSheet(generate_stylesheet(theme))
|
|
891
|
+
|
|
892
|
+
self.tab = LocalTerminalTab(name, session)
|
|
893
|
+
self.tab.terminal.set_theme(theme)
|
|
894
|
+
self.setCentralWidget(self.tab)
|
|
895
|
+
|
|
896
|
+
self.tab.connect()
|
|
897
|
+
|
|
898
|
+
def closeEvent(self, event):
|
|
899
|
+
"""Terminate on close with confirmation."""
|
|
900
|
+
if self.tab.is_connected():
|
|
901
|
+
reply = QMessageBox.question(
|
|
902
|
+
self,
|
|
903
|
+
"Close Window",
|
|
904
|
+
f"'{self.tab.name}' is still running.\n\nTerminate and close?",
|
|
905
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
906
|
+
QMessageBox.StandardButton.No
|
|
907
|
+
)
|
|
908
|
+
if reply != QMessageBox.StandardButton.Yes:
|
|
909
|
+
event.ignore()
|
|
910
|
+
return
|
|
911
|
+
|
|
912
|
+
self.tab.disconnect()
|
|
913
|
+
event.accept()
|
|
914
|
+
|
|
915
|
+
|
|
793
916
|
def main():
|
|
794
917
|
app = QApplication(sys.argv)
|
|
795
918
|
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Example nterm application.
|
|
4
|
+
|
|
5
|
+
Demonstrates basic usage of the terminal widget with different session types:
|
|
6
|
+
- SSHSession: Paramiko-based (for password/key auth)
|
|
7
|
+
- AskpassSSHSession: Native SSH with GUI prompts (recommended for YubiKey)
|
|
8
|
+
- InteractiveSSHSession: Native SSH with PTY
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import logging
|
|
13
|
+
from PyQt6.QtWidgets import (
|
|
14
|
+
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
15
|
+
QComboBox, QLabel, QPushButton, QStatusBar, QLineEdit, QSpinBox,
|
|
16
|
+
QGroupBox, QFormLayout, QMessageBox, QDialog, QDialogButtonBox,
|
|
17
|
+
QInputDialog
|
|
18
|
+
)
|
|
19
|
+
from PyQt6.QtCore import Qt, pyqtSignal, QObject
|
|
20
|
+
|
|
21
|
+
from nterm import (
|
|
22
|
+
ConnectionProfile, AuthConfig, AuthMethod, JumpHostConfig,
|
|
23
|
+
SSHSession, SessionState, TerminalWidget, Theme, ThemeEngine,
|
|
24
|
+
InteractiveSSHSession, is_pty_available
|
|
25
|
+
)
|
|
26
|
+
from nterm.session import AskpassSSHSession
|
|
27
|
+
|
|
28
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class YubiKeyDialog(QDialog):
|
|
33
|
+
"""Dialog shown when YubiKey touch is required."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, prompt: str, parent=None):
|
|
36
|
+
super().__init__(parent)
|
|
37
|
+
self.setWindowTitle("YubiKey Authentication")
|
|
38
|
+
self.setModal(True)
|
|
39
|
+
self.setMinimumWidth(350)
|
|
40
|
+
|
|
41
|
+
layout = QVBoxLayout(self)
|
|
42
|
+
|
|
43
|
+
# Icon/visual indicator
|
|
44
|
+
icon_label = QLabel("🔑")
|
|
45
|
+
icon_label.setStyleSheet("font-size: 48px;")
|
|
46
|
+
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
47
|
+
layout.addWidget(icon_label)
|
|
48
|
+
|
|
49
|
+
# Prompt
|
|
50
|
+
prompt_label = QLabel(prompt)
|
|
51
|
+
prompt_label.setWordWrap(True)
|
|
52
|
+
prompt_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
53
|
+
prompt_label.setStyleSheet("font-size: 14px; margin: 10px;")
|
|
54
|
+
layout.addWidget(prompt_label)
|
|
55
|
+
|
|
56
|
+
# Instructions
|
|
57
|
+
instructions = QLabel("Touch your YubiKey to authenticate...")
|
|
58
|
+
instructions.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
59
|
+
instructions.setStyleSheet("color: gray;")
|
|
60
|
+
layout.addWidget(instructions)
|
|
61
|
+
|
|
62
|
+
# Cancel button
|
|
63
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
|
|
64
|
+
buttons.rejected.connect(self.reject)
|
|
65
|
+
layout.addWidget(buttons)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NTermWindow(QMainWindow):
|
|
69
|
+
"""Main application window."""
|
|
70
|
+
|
|
71
|
+
def __init__(self):
|
|
72
|
+
super().__init__()
|
|
73
|
+
self.setWindowTitle("nterm - SSH Terminal")
|
|
74
|
+
self.resize(1200, 800)
|
|
75
|
+
|
|
76
|
+
self._session = None
|
|
77
|
+
self._theme_engine = ThemeEngine()
|
|
78
|
+
self._yubikey_dialog = None
|
|
79
|
+
|
|
80
|
+
self._setup_ui()
|
|
81
|
+
self._apply_theme("default")
|
|
82
|
+
|
|
83
|
+
def _setup_ui(self):
|
|
84
|
+
"""Set up the user interface."""
|
|
85
|
+
central = QWidget()
|
|
86
|
+
self.setCentralWidget(central)
|
|
87
|
+
layout = QVBoxLayout(central)
|
|
88
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
89
|
+
layout.setSpacing(0)
|
|
90
|
+
|
|
91
|
+
# Toolbar
|
|
92
|
+
toolbar = self._create_toolbar()
|
|
93
|
+
layout.addWidget(toolbar)
|
|
94
|
+
|
|
95
|
+
# Terminal
|
|
96
|
+
self._terminal = TerminalWidget()
|
|
97
|
+
self._terminal.session_state_changed.connect(self._on_state_changed)
|
|
98
|
+
self._terminal.interaction_required.connect(self._on_interaction)
|
|
99
|
+
layout.addWidget(self._terminal, 1)
|
|
100
|
+
|
|
101
|
+
# Status bar
|
|
102
|
+
self._status = QStatusBar()
|
|
103
|
+
self.setStatusBar(self._status)
|
|
104
|
+
self._status.showMessage("Disconnected")
|
|
105
|
+
|
|
106
|
+
def _create_toolbar(self) -> QWidget:
|
|
107
|
+
"""Create connection toolbar."""
|
|
108
|
+
toolbar = QWidget()
|
|
109
|
+
toolbar.setFixedHeight(100)
|
|
110
|
+
layout = QHBoxLayout(toolbar)
|
|
111
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
112
|
+
|
|
113
|
+
# Connection group
|
|
114
|
+
conn_group = QGroupBox("Connection")
|
|
115
|
+
conn_layout = QFormLayout(conn_group)
|
|
116
|
+
conn_layout.setContentsMargins(8, 4, 8, 4)
|
|
117
|
+
|
|
118
|
+
self._host_input = QLineEdit()
|
|
119
|
+
self._host_input.setPlaceholderText("hostname or IP")
|
|
120
|
+
self._host_input.setText("localhost")
|
|
121
|
+
conn_layout.addRow("Host:", self._host_input)
|
|
122
|
+
|
|
123
|
+
port_layout = QHBoxLayout()
|
|
124
|
+
self._port_input = QSpinBox()
|
|
125
|
+
self._port_input.setRange(1, 65535)
|
|
126
|
+
self._port_input.setValue(22)
|
|
127
|
+
port_layout.addWidget(self._port_input)
|
|
128
|
+
|
|
129
|
+
self._user_input = QLineEdit()
|
|
130
|
+
self._user_input.setPlaceholderText("username")
|
|
131
|
+
port_layout.addWidget(QLabel("User:"))
|
|
132
|
+
port_layout.addWidget(self._user_input)
|
|
133
|
+
conn_layout.addRow("Port:", port_layout)
|
|
134
|
+
|
|
135
|
+
layout.addWidget(conn_group)
|
|
136
|
+
|
|
137
|
+
# Session type group
|
|
138
|
+
session_group = QGroupBox("Session Type")
|
|
139
|
+
session_layout = QVBoxLayout(session_group)
|
|
140
|
+
session_layout.setContentsMargins(8, 4, 8, 4)
|
|
141
|
+
|
|
142
|
+
self._session_combo = QComboBox()
|
|
143
|
+
self._session_combo.addItem("Askpass (YubiKey GUI)", "askpass")
|
|
144
|
+
self._session_combo.addItem("Interactive (PTY)", "interactive")
|
|
145
|
+
self._session_combo.addItem("Paramiko", "paramiko")
|
|
146
|
+
self._session_combo.currentIndexChanged.connect(self._on_session_type_changed)
|
|
147
|
+
session_layout.addWidget(self._session_combo)
|
|
148
|
+
|
|
149
|
+
# Status indicator
|
|
150
|
+
self._pty_label = QLabel("✓ GUI auth prompts" if is_pty_available() else "⚠ Limited")
|
|
151
|
+
self._pty_label.setStyleSheet("color: green;" if is_pty_available() else "color: orange;")
|
|
152
|
+
session_layout.addWidget(self._pty_label)
|
|
153
|
+
|
|
154
|
+
layout.addWidget(session_group)
|
|
155
|
+
|
|
156
|
+
# Auth group (for Paramiko mode)
|
|
157
|
+
self._auth_group = QGroupBox("Authentication")
|
|
158
|
+
auth_layout = QFormLayout(self._auth_group)
|
|
159
|
+
auth_layout.setContentsMargins(8, 4, 8, 4)
|
|
160
|
+
|
|
161
|
+
self._auth_combo = QComboBox()
|
|
162
|
+
self._auth_combo.addItems(["Agent", "Password", "Key File"])
|
|
163
|
+
auth_layout.addRow("Method:", self._auth_combo)
|
|
164
|
+
|
|
165
|
+
self._password_input = QLineEdit()
|
|
166
|
+
self._password_input.setEchoMode(QLineEdit.EchoMode.Password)
|
|
167
|
+
self._password_input.setPlaceholderText("(for password auth)")
|
|
168
|
+
auth_layout.addRow("Password:", self._password_input)
|
|
169
|
+
|
|
170
|
+
self._auth_group.setVisible(False)
|
|
171
|
+
layout.addWidget(self._auth_group)
|
|
172
|
+
|
|
173
|
+
# Jump host group
|
|
174
|
+
jump_group = QGroupBox("Jump Host (Optional)")
|
|
175
|
+
jump_layout = QFormLayout(jump_group)
|
|
176
|
+
jump_layout.setContentsMargins(8, 4, 8, 4)
|
|
177
|
+
|
|
178
|
+
self._jump_host_input = QLineEdit()
|
|
179
|
+
self._jump_host_input.setPlaceholderText("bastion.example.com")
|
|
180
|
+
jump_layout.addRow("Host:", self._jump_host_input)
|
|
181
|
+
|
|
182
|
+
self._jump_user_input = QLineEdit()
|
|
183
|
+
self._jump_user_input.setPlaceholderText("(same as main if empty)")
|
|
184
|
+
jump_layout.addRow("User:", self._jump_user_input)
|
|
185
|
+
|
|
186
|
+
layout.addWidget(jump_group)
|
|
187
|
+
|
|
188
|
+
# Theme selector
|
|
189
|
+
theme_group = QGroupBox("Theme")
|
|
190
|
+
theme_layout = QVBoxLayout(theme_group)
|
|
191
|
+
theme_layout.setContentsMargins(8, 4, 8, 4)
|
|
192
|
+
|
|
193
|
+
self._theme_combo = QComboBox()
|
|
194
|
+
self._theme_combo.addItems(self._theme_engine.list_themes())
|
|
195
|
+
self._theme_combo.currentTextChanged.connect(self._apply_theme)
|
|
196
|
+
theme_layout.addWidget(self._theme_combo)
|
|
197
|
+
|
|
198
|
+
layout.addWidget(theme_group)
|
|
199
|
+
|
|
200
|
+
# Buttons
|
|
201
|
+
btn_layout = QVBoxLayout()
|
|
202
|
+
|
|
203
|
+
self._connect_btn = QPushButton("Connect")
|
|
204
|
+
self._connect_btn.clicked.connect(self._connect)
|
|
205
|
+
self._connect_btn.setDefault(True)
|
|
206
|
+
btn_layout.addWidget(self._connect_btn)
|
|
207
|
+
|
|
208
|
+
self._disconnect_btn = QPushButton("Disconnect")
|
|
209
|
+
self._disconnect_btn.clicked.connect(self._disconnect)
|
|
210
|
+
self._disconnect_btn.setEnabled(False)
|
|
211
|
+
btn_layout.addWidget(self._disconnect_btn)
|
|
212
|
+
|
|
213
|
+
layout.addLayout(btn_layout)
|
|
214
|
+
layout.addStretch()
|
|
215
|
+
|
|
216
|
+
return toolbar
|
|
217
|
+
|
|
218
|
+
def _on_session_type_changed(self, index: int):
|
|
219
|
+
"""Handle session type change."""
|
|
220
|
+
session_type = self._session_combo.currentData()
|
|
221
|
+
self._auth_group.setVisible(session_type == "paramiko")
|
|
222
|
+
|
|
223
|
+
# Update status label
|
|
224
|
+
if session_type == "askpass":
|
|
225
|
+
self._pty_label.setText("✓ GUI auth prompts")
|
|
226
|
+
self._pty_label.setStyleSheet("color: green;")
|
|
227
|
+
elif session_type == "interactive":
|
|
228
|
+
self._pty_label.setText("⚠ Console prompts")
|
|
229
|
+
self._pty_label.setStyleSheet("color: orange;")
|
|
230
|
+
else:
|
|
231
|
+
self._pty_label.setText("✓ Programmatic auth")
|
|
232
|
+
self._pty_label.setStyleSheet("color: green;")
|
|
233
|
+
|
|
234
|
+
def _apply_theme(self, theme_name: str):
|
|
235
|
+
"""Apply selected theme."""
|
|
236
|
+
theme = self._theme_engine.get_theme(theme_name)
|
|
237
|
+
if theme:
|
|
238
|
+
self._terminal.set_theme(theme)
|
|
239
|
+
|
|
240
|
+
def _connect(self):
|
|
241
|
+
"""Establish connection."""
|
|
242
|
+
hostname = self._host_input.text().strip()
|
|
243
|
+
port = self._port_input.value()
|
|
244
|
+
username = self._user_input.text().strip()
|
|
245
|
+
session_type = self._session_combo.currentData()
|
|
246
|
+
|
|
247
|
+
if not hostname:
|
|
248
|
+
QMessageBox.warning(self, "Error", "Please enter a hostname")
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
if not username:
|
|
252
|
+
QMessageBox.warning(self, "Error", "Please enter a username")
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Build auth config
|
|
256
|
+
if session_type in ("askpass", "interactive"):
|
|
257
|
+
auth = AuthConfig.agent_auth(username)
|
|
258
|
+
else:
|
|
259
|
+
auth_method = self._auth_combo.currentText()
|
|
260
|
+
if auth_method == "Agent":
|
|
261
|
+
auth = AuthConfig.agent_auth(username)
|
|
262
|
+
elif auth_method == "Password":
|
|
263
|
+
password = self._password_input.text()
|
|
264
|
+
if not password:
|
|
265
|
+
QMessageBox.warning(self, "Error", "Please enter a password")
|
|
266
|
+
return
|
|
267
|
+
auth = AuthConfig.password_auth(username, password)
|
|
268
|
+
else:
|
|
269
|
+
auth = AuthConfig.agent_auth(username, allow_fallback=True)
|
|
270
|
+
|
|
271
|
+
# Build jump host config if specified
|
|
272
|
+
jump_hosts = []
|
|
273
|
+
jump_host = self._jump_host_input.text().strip()
|
|
274
|
+
if jump_host:
|
|
275
|
+
jump_user = self._jump_user_input.text().strip() or username
|
|
276
|
+
jump_hosts.append(JumpHostConfig(
|
|
277
|
+
hostname=jump_host,
|
|
278
|
+
auth=AuthConfig.agent_auth(jump_user),
|
|
279
|
+
))
|
|
280
|
+
|
|
281
|
+
# Create profile
|
|
282
|
+
profile = ConnectionProfile(
|
|
283
|
+
name=f"{username}@{hostname}",
|
|
284
|
+
hostname=hostname,
|
|
285
|
+
port=port,
|
|
286
|
+
auth_methods=[auth],
|
|
287
|
+
jump_hosts=jump_hosts,
|
|
288
|
+
auto_reconnect=False, # Disable for testing
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Create appropriate session type
|
|
292
|
+
if session_type == "askpass":
|
|
293
|
+
if not is_pty_available():
|
|
294
|
+
QMessageBox.warning(self, "Error", "PTY support required")
|
|
295
|
+
return
|
|
296
|
+
self._session = AskpassSSHSession(profile)
|
|
297
|
+
elif session_type == "interactive":
|
|
298
|
+
if not is_pty_available():
|
|
299
|
+
QMessageBox.warning(self, "Error", "PTY support required")
|
|
300
|
+
return
|
|
301
|
+
self._session = InteractiveSSHSession(profile)
|
|
302
|
+
else:
|
|
303
|
+
self._session = SSHSession(profile)
|
|
304
|
+
|
|
305
|
+
self._terminal.attach_session(self._session)
|
|
306
|
+
|
|
307
|
+
# Connect
|
|
308
|
+
self._session.connect()
|
|
309
|
+
self._connect_btn.setEnabled(False)
|
|
310
|
+
self._disconnect_btn.setEnabled(True)
|
|
311
|
+
|
|
312
|
+
def _disconnect(self):
|
|
313
|
+
"""Disconnect session."""
|
|
314
|
+
# Close any open dialogs
|
|
315
|
+
if self._yubikey_dialog:
|
|
316
|
+
self._yubikey_dialog.close()
|
|
317
|
+
self._yubikey_dialog = None
|
|
318
|
+
|
|
319
|
+
if self._session:
|
|
320
|
+
self._session.disconnect()
|
|
321
|
+
self._terminal.detach_session()
|
|
322
|
+
self._session = None
|
|
323
|
+
|
|
324
|
+
self._connect_btn.setEnabled(True)
|
|
325
|
+
self._disconnect_btn.setEnabled(False)
|
|
326
|
+
|
|
327
|
+
def _on_state_changed(self, state: SessionState, message: str):
|
|
328
|
+
"""Handle session state changes."""
|
|
329
|
+
status_text = {
|
|
330
|
+
SessionState.DISCONNECTED: "Disconnected",
|
|
331
|
+
SessionState.CONNECTING: "Connecting...",
|
|
332
|
+
SessionState.AUTHENTICATING: "Authenticating...",
|
|
333
|
+
SessionState.CONNECTED: "Connected",
|
|
334
|
+
SessionState.RECONNECTING: f"Reconnecting: {message}",
|
|
335
|
+
SessionState.FAILED: f"Failed: {message}",
|
|
336
|
+
}.get(state, str(state))
|
|
337
|
+
|
|
338
|
+
self._status.showMessage(status_text)
|
|
339
|
+
|
|
340
|
+
# Close YubiKey dialog on connect/disconnect
|
|
341
|
+
if state in (SessionState.CONNECTED, SessionState.DISCONNECTED, SessionState.FAILED):
|
|
342
|
+
if self._yubikey_dialog:
|
|
343
|
+
self._yubikey_dialog.close()
|
|
344
|
+
self._yubikey_dialog = None
|
|
345
|
+
|
|
346
|
+
if state == SessionState.CONNECTED:
|
|
347
|
+
self._connect_btn.setEnabled(False)
|
|
348
|
+
self._disconnect_btn.setEnabled(True)
|
|
349
|
+
self._terminal.focus()
|
|
350
|
+
elif state in (SessionState.DISCONNECTED, SessionState.FAILED):
|
|
351
|
+
self._connect_btn.setEnabled(True)
|
|
352
|
+
self._disconnect_btn.setEnabled(False)
|
|
353
|
+
|
|
354
|
+
def _on_interaction(self, prompt: str, interaction_type: str):
|
|
355
|
+
"""Handle SSH authentication prompts."""
|
|
356
|
+
logger.info(f"Interaction required: {interaction_type} - {prompt}")
|
|
357
|
+
|
|
358
|
+
if not isinstance(self._session, AskpassSSHSession):
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
if interaction_type == "yubikey_touch":
|
|
362
|
+
# Show YubiKey dialog
|
|
363
|
+
self._yubikey_dialog = YubiKeyDialog(prompt, self)
|
|
364
|
+
result = self._yubikey_dialog.exec()
|
|
365
|
+
self._yubikey_dialog = None
|
|
366
|
+
|
|
367
|
+
if result == QDialog.DialogCode.Rejected:
|
|
368
|
+
# User cancelled
|
|
369
|
+
self._session.provide_askpass_response(False, error="Cancelled by user")
|
|
370
|
+
else:
|
|
371
|
+
# YubiKey was touched (dialog closed by external event)
|
|
372
|
+
self._session.provide_askpass_response(True, value="")
|
|
373
|
+
|
|
374
|
+
elif interaction_type == "password":
|
|
375
|
+
# Show password dialog
|
|
376
|
+
password, ok = QInputDialog.getText(
|
|
377
|
+
self, "SSH Authentication", prompt,
|
|
378
|
+
QLineEdit.EchoMode.Password
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if ok and password:
|
|
382
|
+
self._session.provide_askpass_response(True, value=password)
|
|
383
|
+
else:
|
|
384
|
+
self._session.provide_askpass_response(False, error="Cancelled by user")
|
|
385
|
+
|
|
386
|
+
else:
|
|
387
|
+
# Generic input
|
|
388
|
+
text, ok = QInputDialog.getText(
|
|
389
|
+
self, "SSH Authentication", prompt
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if ok:
|
|
393
|
+
self._session.provide_askpass_response(True, value=text)
|
|
394
|
+
else:
|
|
395
|
+
self._session.provide_askpass_response(False, error="Cancelled by user")
|
|
396
|
+
|
|
397
|
+
def closeEvent(self, event):
|
|
398
|
+
"""Handle window close."""
|
|
399
|
+
if self._session:
|
|
400
|
+
self._session.disconnect()
|
|
401
|
+
event.accept()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def main():
|
|
405
|
+
app = QApplication(sys.argv)
|
|
406
|
+
app.setApplicationName("nterm")
|
|
407
|
+
|
|
408
|
+
window = NTermWindow()
|
|
409
|
+
window.show()
|
|
410
|
+
|
|
411
|
+
sys.exit(app.exec())
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
if __name__ == "__main__":
|
|
415
|
+
main()
|