ntermqt 0.1.0__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/__init__.py +54 -0
- nterm/__main__.py +619 -0
- nterm/askpass/__init__.py +22 -0
- nterm/askpass/server.py +393 -0
- nterm/config.py +158 -0
- nterm/connection/__init__.py +17 -0
- nterm/connection/profile.py +296 -0
- nterm/manager/__init__.py +29 -0
- nterm/manager/connect_dialog.py +322 -0
- nterm/manager/editor.py +262 -0
- nterm/manager/io.py +678 -0
- nterm/manager/models.py +346 -0
- nterm/manager/settings.py +264 -0
- nterm/manager/tree.py +493 -0
- nterm/resources.py +48 -0
- nterm/session/__init__.py +60 -0
- nterm/session/askpass_ssh.py +399 -0
- nterm/session/base.py +110 -0
- nterm/session/interactive_ssh.py +522 -0
- nterm/session/pty_transport.py +571 -0
- nterm/session/ssh.py +610 -0
- nterm/terminal/__init__.py +11 -0
- nterm/terminal/bridge.py +83 -0
- nterm/terminal/resources/terminal.html +253 -0
- nterm/terminal/resources/terminal.js +414 -0
- nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
- nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
- nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
- nterm/terminal/resources/xterm.css +209 -0
- nterm/terminal/resources/xterm.min.js +8 -0
- nterm/terminal/widget.py +380 -0
- nterm/theme/__init__.py +10 -0
- nterm/theme/engine.py +456 -0
- nterm/theme/stylesheet.py +377 -0
- nterm/theme/themes/clean.yaml +0 -0
- nterm/theme/themes/default.yaml +36 -0
- nterm/theme/themes/dracula.yaml +36 -0
- nterm/theme/themes/gruvbox_dark.yaml +36 -0
- nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
- nterm/theme/themes/gruvbox_light.yaml +36 -0
- nterm/vault/__init__.py +32 -0
- nterm/vault/credential_manager.py +163 -0
- nterm/vault/keychain.py +135 -0
- nterm/vault/manager_ui.py +962 -0
- nterm/vault/profile.py +219 -0
- nterm/vault/resolver.py +250 -0
- nterm/vault/store.py +642 -0
- ntermqt-0.1.0.dist-info/METADATA +327 -0
- ntermqt-0.1.0.dist-info/RECORD +52 -0
- ntermqt-0.1.0.dist-info/WHEEL +5 -0
- ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
- ntermqt-0.1.0.dist-info/top_level.txt +1 -0
nterm/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nterm - A themeable SSH terminal widget for PyQt6.
|
|
3
|
+
|
|
4
|
+
Clean architecture with:
|
|
5
|
+
- Connection profiles (fully serializable)
|
|
6
|
+
- Session management with auto-reconnect
|
|
7
|
+
- Jump host / bastion support
|
|
8
|
+
- YubiKey/FIDO2 agent auth (native SSH + PTY)
|
|
9
|
+
- xterm.js rendering
|
|
10
|
+
- Themeable UI
|
|
11
|
+
|
|
12
|
+
Session types:
|
|
13
|
+
- SSHSession: Paramiko-based, programmatic auth
|
|
14
|
+
- InteractiveSSHSession: Native ssh with PTY for full interactive auth
|
|
15
|
+
- HybridSSHSession: Interactive auth with ControlMaster reuse (Unix only)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__version__ = "0.2.0"
|
|
19
|
+
__author__ = "Scott Peterman"
|
|
20
|
+
|
|
21
|
+
from .connection.profile import (
|
|
22
|
+
ConnectionProfile,
|
|
23
|
+
AuthConfig,
|
|
24
|
+
AuthMethod,
|
|
25
|
+
JumpHostConfig,
|
|
26
|
+
)
|
|
27
|
+
from .session.base import Session, SessionState
|
|
28
|
+
from .session.ssh import SSHSession
|
|
29
|
+
from .session.interactive_ssh import InteractiveSSHSession, HybridSSHSession
|
|
30
|
+
from .session.pty_transport import is_pty_available, IS_WINDOWS
|
|
31
|
+
from .terminal.widget import TerminalWidget
|
|
32
|
+
from .theme.engine import Theme, ThemeEngine
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# Connection
|
|
36
|
+
"ConnectionProfile",
|
|
37
|
+
"AuthConfig",
|
|
38
|
+
"AuthMethod",
|
|
39
|
+
"JumpHostConfig",
|
|
40
|
+
# Sessions
|
|
41
|
+
"Session",
|
|
42
|
+
"SessionState",
|
|
43
|
+
"SSHSession",
|
|
44
|
+
"InteractiveSSHSession",
|
|
45
|
+
"HybridSSHSession",
|
|
46
|
+
# Utilities
|
|
47
|
+
"is_pty_available",
|
|
48
|
+
"IS_WINDOWS",
|
|
49
|
+
# Terminal
|
|
50
|
+
"TerminalWidget",
|
|
51
|
+
# Themes
|
|
52
|
+
"Theme",
|
|
53
|
+
"ThemeEngine",
|
|
54
|
+
]
|
nterm/__main__.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example: Session Manager with Tabbed Terminal
|
|
3
|
+
|
|
4
|
+
Demonstrates integrating the session tree with the terminal widget
|
|
5
|
+
and vault for a complete SSH client experience.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from PyQt6.QtWidgets import (
|
|
11
|
+
QApplication, QMainWindow, QSplitter, QTabWidget,
|
|
12
|
+
QWidget, QVBoxLayout, QHBoxLayout, QMessageBox,
|
|
13
|
+
QDialog, QLabel, QLineEdit, QPushButton, QCheckBox,
|
|
14
|
+
QMenuBar, QMenu
|
|
15
|
+
)
|
|
16
|
+
from PyQt6.QtCore import Qt
|
|
17
|
+
from PyQt6.QtGui import QAction, QKeySequence
|
|
18
|
+
|
|
19
|
+
from nterm.manager import (
|
|
20
|
+
SessionTreeWidget, SessionStore, SavedSession, QuickConnectDialog,
|
|
21
|
+
SettingsDialog, ExportDialog, ImportDialog, ImportTerminalTelemetryDialog
|
|
22
|
+
)
|
|
23
|
+
from nterm.terminal.widget import TerminalWidget
|
|
24
|
+
from nterm.session.ssh import SSHSession
|
|
25
|
+
from nterm.connection.profile import ConnectionProfile, AuthConfig
|
|
26
|
+
from nterm.vault import CredentialManagerWidget
|
|
27
|
+
from nterm.vault.resolver import CredentialResolver
|
|
28
|
+
from nterm.theme.engine import ThemeEngine, Theme
|
|
29
|
+
from nterm.theme.stylesheet import generate_stylesheet
|
|
30
|
+
from nterm.manager.connect_dialog import ConnectDialog
|
|
31
|
+
from nterm.config import get_settings_manager, get_settings, save_settings
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Vault location
|
|
36
|
+
NTERM_DIR = Path.home() / ".nterm"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class VaultUnlockDialog(QDialog):
|
|
40
|
+
"""Dialog to unlock the credential vault on startup."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, resolver: CredentialResolver, parent=None):
|
|
43
|
+
super().__init__(parent)
|
|
44
|
+
self.resolver = resolver
|
|
45
|
+
self._unlocked = False
|
|
46
|
+
|
|
47
|
+
self.setWindowTitle("Unlock Vault")
|
|
48
|
+
self.setFixedWidth(350)
|
|
49
|
+
self.setModal(True)
|
|
50
|
+
|
|
51
|
+
# Apply default theme styling
|
|
52
|
+
default_theme = Theme.default()
|
|
53
|
+
self.setStyleSheet(generate_stylesheet(default_theme))
|
|
54
|
+
|
|
55
|
+
layout = QVBoxLayout(self)
|
|
56
|
+
|
|
57
|
+
# Icon/message
|
|
58
|
+
if not resolver.is_initialized():
|
|
59
|
+
msg = QLabel("No vault found. Create a new vault password:")
|
|
60
|
+
self._is_new = True
|
|
61
|
+
else:
|
|
62
|
+
msg = QLabel("Enter vault password to unlock credentials:")
|
|
63
|
+
self._is_new = False
|
|
64
|
+
layout.addWidget(msg)
|
|
65
|
+
|
|
66
|
+
# Password input
|
|
67
|
+
self._password_input = QLineEdit()
|
|
68
|
+
self._password_input.setEchoMode(QLineEdit.EchoMode.Password)
|
|
69
|
+
self._password_input.setPlaceholderText("Vault password")
|
|
70
|
+
self._password_input.returnPressed.connect(self._on_unlock)
|
|
71
|
+
layout.addWidget(self._password_input)
|
|
72
|
+
|
|
73
|
+
# Confirm password (new vault only)
|
|
74
|
+
if self._is_new:
|
|
75
|
+
self._confirm_input = QLineEdit()
|
|
76
|
+
self._confirm_input.setEchoMode(QLineEdit.EchoMode.Password)
|
|
77
|
+
self._confirm_input.setPlaceholderText("Confirm password")
|
|
78
|
+
self._confirm_input.returnPressed.connect(self._on_unlock)
|
|
79
|
+
layout.addWidget(self._confirm_input)
|
|
80
|
+
|
|
81
|
+
# Error label
|
|
82
|
+
self._error_label = QLabel()
|
|
83
|
+
self._error_label.setStyleSheet("color: #f38ba8;")
|
|
84
|
+
self._error_label.hide()
|
|
85
|
+
layout.addWidget(self._error_label)
|
|
86
|
+
|
|
87
|
+
# Buttons
|
|
88
|
+
btn_layout = QHBoxLayout()
|
|
89
|
+
btn_layout.addStretch()
|
|
90
|
+
|
|
91
|
+
self._skip_btn = QPushButton("Skip")
|
|
92
|
+
self._skip_btn.setToolTip("Continue without vault (agent auth only)")
|
|
93
|
+
self._skip_btn.clicked.connect(self.accept)
|
|
94
|
+
btn_layout.addWidget(self._skip_btn)
|
|
95
|
+
|
|
96
|
+
self._unlock_btn = QPushButton("Create Vault" if self._is_new else "Unlock")
|
|
97
|
+
self._unlock_btn.setDefault(True)
|
|
98
|
+
self._unlock_btn.clicked.connect(self._on_unlock)
|
|
99
|
+
btn_layout.addWidget(self._unlock_btn)
|
|
100
|
+
|
|
101
|
+
layout.addLayout(btn_layout)
|
|
102
|
+
|
|
103
|
+
self._password_input.setFocus()
|
|
104
|
+
|
|
105
|
+
def _on_unlock(self):
|
|
106
|
+
"""Attempt to unlock/create vault."""
|
|
107
|
+
password = self._password_input.text()
|
|
108
|
+
|
|
109
|
+
if not password:
|
|
110
|
+
self._show_error("Password required")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if self._is_new:
|
|
114
|
+
confirm = self._confirm_input.text()
|
|
115
|
+
if password != confirm:
|
|
116
|
+
self._show_error("Passwords don't match")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
self.resolver.init_vault(password)
|
|
121
|
+
self._unlocked = True
|
|
122
|
+
self.accept()
|
|
123
|
+
except Exception as e:
|
|
124
|
+
self._show_error(f"Failed to create vault: {e}")
|
|
125
|
+
else:
|
|
126
|
+
if self.resolver.unlock_vault(password):
|
|
127
|
+
self._unlocked = True
|
|
128
|
+
self.accept()
|
|
129
|
+
else:
|
|
130
|
+
self._show_error("Incorrect password")
|
|
131
|
+
self._password_input.selectAll()
|
|
132
|
+
self._password_input.setFocus()
|
|
133
|
+
|
|
134
|
+
def _show_error(self, msg: str):
|
|
135
|
+
"""Show error message."""
|
|
136
|
+
self._error_label.setText(msg)
|
|
137
|
+
self._error_label.show()
|
|
138
|
+
|
|
139
|
+
def is_unlocked(self) -> bool:
|
|
140
|
+
"""Check if vault was successfully unlocked."""
|
|
141
|
+
return self._unlocked
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class CredentialManagerDialog(QDialog):
|
|
145
|
+
"""Dialog wrapper for the credential manager widget."""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
credential_resolver: CredentialResolver,
|
|
150
|
+
theme: Theme = None,
|
|
151
|
+
parent=None
|
|
152
|
+
):
|
|
153
|
+
super().__init__(parent)
|
|
154
|
+
self.credential_resolver = credential_resolver
|
|
155
|
+
|
|
156
|
+
self.setWindowTitle("Credential Manager")
|
|
157
|
+
self.setMinimumSize(700, 500)
|
|
158
|
+
self.resize(800, 600)
|
|
159
|
+
|
|
160
|
+
layout = QVBoxLayout(self)
|
|
161
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
162
|
+
|
|
163
|
+
# Credential manager widget
|
|
164
|
+
self.manager = CredentialManagerWidget(store=credential_resolver.store)
|
|
165
|
+
layout.addWidget(self.manager)
|
|
166
|
+
|
|
167
|
+
# Apply theme if provided
|
|
168
|
+
if theme:
|
|
169
|
+
self.manager.set_theme(theme)
|
|
170
|
+
self.setStyleSheet(generate_stylesheet(theme))
|
|
171
|
+
|
|
172
|
+
# Try auto-unlock if available
|
|
173
|
+
self.manager.try_auto_unlock()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class TerminalTab(QWidget):
|
|
177
|
+
"""A terminal tab with session info."""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
session: SavedSession,
|
|
182
|
+
profile: ConnectionProfile,
|
|
183
|
+
credential_resolver: CredentialResolver = None,
|
|
184
|
+
parent=None
|
|
185
|
+
):
|
|
186
|
+
super().__init__(parent)
|
|
187
|
+
self.session = session
|
|
188
|
+
self.profile = profile
|
|
189
|
+
self.credential_resolver = credential_resolver
|
|
190
|
+
|
|
191
|
+
layout = QVBoxLayout(self)
|
|
192
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
193
|
+
|
|
194
|
+
self.terminal = TerminalWidget()
|
|
195
|
+
layout.addWidget(self.terminal)
|
|
196
|
+
|
|
197
|
+
# Pass the vault to SSHSession for credential resolution
|
|
198
|
+
vault = credential_resolver if credential_resolver else None
|
|
199
|
+
self.ssh_session = SSHSession(profile, vault=vault)
|
|
200
|
+
self.terminal.attach_session(self.ssh_session)
|
|
201
|
+
|
|
202
|
+
def connect(self):
|
|
203
|
+
"""Start the SSH connection."""
|
|
204
|
+
self.ssh_session.connect()
|
|
205
|
+
|
|
206
|
+
def disconnect(self):
|
|
207
|
+
"""Disconnect the session."""
|
|
208
|
+
self.ssh_session.disconnect()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class MainWindow(QMainWindow):
|
|
212
|
+
"""
|
|
213
|
+
Main application window with session tree and tabbed terminals.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def __init__(self, credential_resolver: CredentialResolver):
|
|
217
|
+
super().__init__()
|
|
218
|
+
self.setWindowTitle("nterm")
|
|
219
|
+
self.resize(1200, 800)
|
|
220
|
+
|
|
221
|
+
# Initialize stores
|
|
222
|
+
self.session_store = SessionStore()
|
|
223
|
+
self.credential_resolver = credential_resolver
|
|
224
|
+
self.theme_engine = ThemeEngine()
|
|
225
|
+
|
|
226
|
+
# Load persistent settings
|
|
227
|
+
self.settings_manager = get_settings_manager()
|
|
228
|
+
self.app_settings = self.settings_manager.settings
|
|
229
|
+
|
|
230
|
+
# Apply saved window geometry
|
|
231
|
+
self.resize(self.app_settings.window_width, self.app_settings.window_height)
|
|
232
|
+
if self.app_settings.window_x is not None and self.app_settings.window_y is not None:
|
|
233
|
+
self.move(self.app_settings.window_x, self.app_settings.window_y)
|
|
234
|
+
if self.app_settings.window_maximized:
|
|
235
|
+
self.showMaximized()
|
|
236
|
+
|
|
237
|
+
# Initialize stores
|
|
238
|
+
self.session_store = SessionStore()
|
|
239
|
+
self.credential_resolver = credential_resolver
|
|
240
|
+
self.theme_engine = ThemeEngine()
|
|
241
|
+
|
|
242
|
+
# Load saved theme (instead of default)
|
|
243
|
+
saved_theme = self.theme_engine.get_theme(self.app_settings.theme_name)
|
|
244
|
+
self.current_theme = saved_theme if saved_theme else self.theme_engine.current
|
|
245
|
+
|
|
246
|
+
self._setup_ui()
|
|
247
|
+
self._connect_signals()
|
|
248
|
+
self._refresh_credentials()
|
|
249
|
+
|
|
250
|
+
# Apply initial stylesheet
|
|
251
|
+
self._apply_qt_theme(self.current_theme)
|
|
252
|
+
self._setup_ui()
|
|
253
|
+
self._connect_signals()
|
|
254
|
+
self._refresh_credentials()
|
|
255
|
+
|
|
256
|
+
# Apply initial stylesheet
|
|
257
|
+
self._apply_qt_theme(self.current_theme)
|
|
258
|
+
|
|
259
|
+
def _on_settings_changed(self, settings):
|
|
260
|
+
"""Handle settings changes from dialog."""
|
|
261
|
+
self.app_settings = settings
|
|
262
|
+
|
|
263
|
+
# Apply multiline threshold to all open terminals
|
|
264
|
+
for i in range(self.tab_widget.count()):
|
|
265
|
+
tab = self.tab_widget.widget(i)
|
|
266
|
+
if isinstance(tab, TerminalTab):
|
|
267
|
+
tab.terminal.set_multiline_threshold(settings.multiline_paste_threshold)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _refresh_credentials(self):
|
|
271
|
+
"""Refresh credential list for session editor."""
|
|
272
|
+
self._credential_names = []
|
|
273
|
+
if self.credential_resolver.is_initialized():
|
|
274
|
+
try:
|
|
275
|
+
creds = self.credential_resolver.list_credentials()
|
|
276
|
+
self._credential_names = [c.name for c in creds]
|
|
277
|
+
except:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
def _setup_ui(self):
|
|
281
|
+
"""Build the main UI."""
|
|
282
|
+
# Menu bar
|
|
283
|
+
self._setup_menu_bar()
|
|
284
|
+
|
|
285
|
+
# Main splitter: tree | tabs
|
|
286
|
+
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
287
|
+
|
|
288
|
+
# Session tree (left panel)
|
|
289
|
+
self.session_tree = SessionTreeWidget(self.session_store)
|
|
290
|
+
self.session_tree.setMinimumWidth(200)
|
|
291
|
+
self.session_tree.setMaximumWidth(400)
|
|
292
|
+
splitter.addWidget(self.session_tree)
|
|
293
|
+
|
|
294
|
+
# Tab widget (right panel)
|
|
295
|
+
self.tab_widget = QTabWidget()
|
|
296
|
+
self.tab_widget.setTabsClosable(True)
|
|
297
|
+
self.tab_widget.setMovable(True)
|
|
298
|
+
self.tab_widget.setDocumentMode(True)
|
|
299
|
+
splitter.addWidget(self.tab_widget)
|
|
300
|
+
|
|
301
|
+
# Set initial sizes (tree: 250px, tabs: rest)
|
|
302
|
+
splitter.setSizes([250, 950])
|
|
303
|
+
|
|
304
|
+
self.setCentralWidget(splitter)
|
|
305
|
+
|
|
306
|
+
def _connect_signals(self):
|
|
307
|
+
"""Connect UI signals."""
|
|
308
|
+
self.session_tree.connect_requested.connect(self._on_connect_requested)
|
|
309
|
+
self.session_tree.quick_connect_requested.connect(self._on_quick_connect)
|
|
310
|
+
self.tab_widget.tabCloseRequested.connect(self._close_tab)
|
|
311
|
+
|
|
312
|
+
def _setup_menu_bar(self):
|
|
313
|
+
"""Setup application menu bar."""
|
|
314
|
+
menubar = self.menuBar()
|
|
315
|
+
|
|
316
|
+
# File menu
|
|
317
|
+
file_menu = menubar.addMenu("&File")
|
|
318
|
+
|
|
319
|
+
quick_connect_action = QAction("&Quick Connect...", self)
|
|
320
|
+
quick_connect_action.setShortcut(QKeySequence("Ctrl+N"))
|
|
321
|
+
quick_connect_action.triggered.connect(self._on_quick_connect)
|
|
322
|
+
file_menu.addAction(quick_connect_action)
|
|
323
|
+
|
|
324
|
+
file_menu.addSeparator()
|
|
325
|
+
|
|
326
|
+
import_action = QAction("&Import Sessions...", self)
|
|
327
|
+
import_action.setShortcut(QKeySequence("Ctrl+I"))
|
|
328
|
+
import_action.triggered.connect(self._on_import_sessions)
|
|
329
|
+
file_menu.addAction(import_action)
|
|
330
|
+
|
|
331
|
+
export_action = QAction("&Export Sessions...", self)
|
|
332
|
+
export_action.setShortcut(QKeySequence("Ctrl+E"))
|
|
333
|
+
export_action.triggered.connect(self._on_export_sessions)
|
|
334
|
+
file_menu.addAction(export_action)
|
|
335
|
+
|
|
336
|
+
file_menu.addSeparator()
|
|
337
|
+
|
|
338
|
+
tt_import_action = QAction("Import from &TerminalTelemetry...", self)
|
|
339
|
+
tt_import_action.triggered.connect(self._on_import_terminal_telemetry)
|
|
340
|
+
file_menu.addAction(tt_import_action)
|
|
341
|
+
|
|
342
|
+
file_menu.addSeparator()
|
|
343
|
+
|
|
344
|
+
quit_action = QAction("&Quit", self)
|
|
345
|
+
quit_action.setShortcut(QKeySequence.StandardKey.Quit)
|
|
346
|
+
quit_action.triggered.connect(self.close)
|
|
347
|
+
file_menu.addAction(quit_action)
|
|
348
|
+
|
|
349
|
+
# Edit menu
|
|
350
|
+
edit_menu = menubar.addMenu("&Edit")
|
|
351
|
+
|
|
352
|
+
settings_action = QAction("&Settings...", self)
|
|
353
|
+
settings_action.setShortcut(QKeySequence("Ctrl+,"))
|
|
354
|
+
settings_action.triggered.connect(self._on_settings)
|
|
355
|
+
edit_menu.addAction(settings_action)
|
|
356
|
+
|
|
357
|
+
edit_menu.addSeparator()
|
|
358
|
+
|
|
359
|
+
cred_manager_action = QAction("&Credential Manager...", self)
|
|
360
|
+
cred_manager_action.setShortcut(QKeySequence("Ctrl+Shift+C"))
|
|
361
|
+
cred_manager_action.triggered.connect(self._on_credential_manager)
|
|
362
|
+
edit_menu.addAction(cred_manager_action)
|
|
363
|
+
|
|
364
|
+
# View menu
|
|
365
|
+
view_menu = menubar.addMenu("&View")
|
|
366
|
+
|
|
367
|
+
# Theme submenu
|
|
368
|
+
theme_menu = view_menu.addMenu("&Theme")
|
|
369
|
+
for theme_name in self.theme_engine.list_themes():
|
|
370
|
+
action = QAction(theme_name.replace("_", " ").title(), self)
|
|
371
|
+
action.setData(theme_name)
|
|
372
|
+
action.triggered.connect(lambda checked, n=theme_name: self._apply_theme_by_name(n))
|
|
373
|
+
theme_menu.addAction(action)
|
|
374
|
+
|
|
375
|
+
def _on_import_sessions(self):
|
|
376
|
+
"""Show import dialog."""
|
|
377
|
+
dialog = ImportDialog(self.session_store, self)
|
|
378
|
+
if dialog.exec():
|
|
379
|
+
self.session_tree.refresh()
|
|
380
|
+
|
|
381
|
+
def _on_export_sessions(self):
|
|
382
|
+
"""Show export dialog."""
|
|
383
|
+
dialog = ExportDialog(self.session_store, self)
|
|
384
|
+
dialog.exec()
|
|
385
|
+
|
|
386
|
+
def _on_import_terminal_telemetry(self):
|
|
387
|
+
"""Show TerminalTelemetry import dialog."""
|
|
388
|
+
dialog = ImportTerminalTelemetryDialog(self.session_store, self)
|
|
389
|
+
if dialog.exec():
|
|
390
|
+
self.session_tree.refresh()
|
|
391
|
+
|
|
392
|
+
def _on_settings(self):
|
|
393
|
+
"""Show settings dialog."""
|
|
394
|
+
dialog = SettingsDialog(self.theme_engine, self.current_theme, self)
|
|
395
|
+
dialog.theme_changed.connect(self._apply_theme)
|
|
396
|
+
dialog.exec()
|
|
397
|
+
|
|
398
|
+
def _on_credential_manager(self):
|
|
399
|
+
"""Show credential manager dialog."""
|
|
400
|
+
dialog = CredentialManagerDialog(
|
|
401
|
+
self.credential_resolver,
|
|
402
|
+
self.current_theme,
|
|
403
|
+
self
|
|
404
|
+
)
|
|
405
|
+
dialog.exec()
|
|
406
|
+
# Refresh credential list in case any were added/removed
|
|
407
|
+
self._refresh_credentials()
|
|
408
|
+
|
|
409
|
+
def _apply_theme(self, theme: Theme):
|
|
410
|
+
"""Apply theme to all terminals and Qt UI."""
|
|
411
|
+
self.current_theme = theme
|
|
412
|
+
|
|
413
|
+
# Update Qt stylesheet
|
|
414
|
+
self._apply_qt_theme(theme)
|
|
415
|
+
|
|
416
|
+
# Update all open terminal tabs
|
|
417
|
+
for i in range(self.tab_widget.count()):
|
|
418
|
+
tab = self.tab_widget.widget(i)
|
|
419
|
+
if isinstance(tab, TerminalTab):
|
|
420
|
+
tab.terminal.set_theme(theme)
|
|
421
|
+
|
|
422
|
+
def _apply_qt_theme(self, theme: Theme):
|
|
423
|
+
"""Apply theme stylesheet to Qt widgets."""
|
|
424
|
+
stylesheet = generate_stylesheet(theme)
|
|
425
|
+
self.setStyleSheet(stylesheet)
|
|
426
|
+
|
|
427
|
+
def _apply_theme_by_name(self, name: str):
|
|
428
|
+
"""Apply theme by name."""
|
|
429
|
+
theme = self.theme_engine.get_theme(name)
|
|
430
|
+
if theme:
|
|
431
|
+
self.theme_engine.current = theme
|
|
432
|
+
self._apply_theme(theme)
|
|
433
|
+
|
|
434
|
+
def _on_connect_requested(self, session: SavedSession, mode: str):
|
|
435
|
+
"""Handle connect request from session tree."""
|
|
436
|
+
# Show connection dialog
|
|
437
|
+
dialog = ConnectDialog(
|
|
438
|
+
session,
|
|
439
|
+
credential_resolver=self.credential_resolver,
|
|
440
|
+
credential_names=self._credential_names,
|
|
441
|
+
parent=self
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if not dialog.exec():
|
|
445
|
+
return # User cancelled
|
|
446
|
+
|
|
447
|
+
profile = dialog.get_profile()
|
|
448
|
+
if not profile:
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
if mode == SessionTreeWidget.MODE_TAB:
|
|
452
|
+
self._open_in_tab(session, profile)
|
|
453
|
+
elif mode == SessionTreeWidget.MODE_WINDOW:
|
|
454
|
+
self._open_in_window(session, profile)
|
|
455
|
+
|
|
456
|
+
def _on_quick_connect(self):
|
|
457
|
+
"""Handle quick connect request."""
|
|
458
|
+
dialog = QuickConnectDialog(self._credential_names, self)
|
|
459
|
+
if dialog.exec():
|
|
460
|
+
session = dialog.get_session()
|
|
461
|
+
mode = dialog.get_connect_mode()
|
|
462
|
+
|
|
463
|
+
# Show connection dialog for auth options
|
|
464
|
+
connect_dialog = ConnectDialog(
|
|
465
|
+
session,
|
|
466
|
+
credential_resolver=self.credential_resolver,
|
|
467
|
+
credential_names=self._credential_names,
|
|
468
|
+
parent=self
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
if not connect_dialog.exec():
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
profile = connect_dialog.get_profile()
|
|
475
|
+
if profile:
|
|
476
|
+
if mode == "tab":
|
|
477
|
+
self._open_in_tab(session, profile)
|
|
478
|
+
else:
|
|
479
|
+
self._open_in_window(session, profile)
|
|
480
|
+
|
|
481
|
+
def _build_profile(self, session: SavedSession) -> ConnectionProfile:
|
|
482
|
+
"""Build ConnectionProfile from SavedSession."""
|
|
483
|
+
auth_methods = []
|
|
484
|
+
|
|
485
|
+
# Try to resolve credential from vault
|
|
486
|
+
if session.credential_name and self.credential_resolver.is_initialized():
|
|
487
|
+
try:
|
|
488
|
+
profile = self.credential_resolver.create_profile_for_credential(
|
|
489
|
+
session.credential_name,
|
|
490
|
+
session.hostname,
|
|
491
|
+
session.port
|
|
492
|
+
)
|
|
493
|
+
return profile
|
|
494
|
+
except Exception as e:
|
|
495
|
+
QMessageBox.warning(
|
|
496
|
+
self,
|
|
497
|
+
"Credential Error",
|
|
498
|
+
f"Failed to load credential '{session.credential_name}': {e}\n\n"
|
|
499
|
+
"Falling back to SSH agent."
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Default to agent auth
|
|
503
|
+
auth_methods.append(AuthConfig.agent_auth("$USER")) # Will use current user
|
|
504
|
+
|
|
505
|
+
return ConnectionProfile(
|
|
506
|
+
name=session.name,
|
|
507
|
+
hostname=session.hostname,
|
|
508
|
+
port=session.port,
|
|
509
|
+
auth_methods=auth_methods,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def _open_in_tab(self, session: SavedSession, profile: ConnectionProfile):
|
|
513
|
+
"""Open session in a new tab."""
|
|
514
|
+
tab = TerminalTab(session, profile, self.credential_resolver)
|
|
515
|
+
tab.terminal.set_theme(self.current_theme)
|
|
516
|
+
|
|
517
|
+
idx = self.tab_widget.addTab(tab, session.name)
|
|
518
|
+
self.tab_widget.setCurrentIndex(idx)
|
|
519
|
+
self.tab_widget.setTabToolTip(idx, f"{session.hostname}:{session.port}")
|
|
520
|
+
|
|
521
|
+
tab.connect()
|
|
522
|
+
|
|
523
|
+
def _open_in_window(self, session: SavedSession, profile: ConnectionProfile):
|
|
524
|
+
"""Open session in a separate window."""
|
|
525
|
+
window = TerminalWindow(session, profile, self.current_theme, self.credential_resolver)
|
|
526
|
+
window.show()
|
|
527
|
+
|
|
528
|
+
# Keep reference to prevent garbage collection
|
|
529
|
+
if not hasattr(self, '_child_windows'):
|
|
530
|
+
self._child_windows = []
|
|
531
|
+
self._child_windows.append(window)
|
|
532
|
+
window.destroyed.connect(lambda: self._child_windows.remove(window))
|
|
533
|
+
|
|
534
|
+
def _close_tab(self, index: int):
|
|
535
|
+
"""Close a terminal tab."""
|
|
536
|
+
tab = self.tab_widget.widget(index)
|
|
537
|
+
if isinstance(tab, TerminalTab):
|
|
538
|
+
tab.disconnect()
|
|
539
|
+
self.tab_widget.removeTab(index)
|
|
540
|
+
|
|
541
|
+
def closeEvent(self, event):
|
|
542
|
+
"""Clean up on close."""
|
|
543
|
+
if not self.isMaximized():
|
|
544
|
+
self.app_settings.window_width = self.width()
|
|
545
|
+
self.app_settings.window_height = self.height()
|
|
546
|
+
self.app_settings.window_x = self.x()
|
|
547
|
+
self.app_settings.window_y = self.y()
|
|
548
|
+
self.app_settings.window_maximized = self.isMaximized()
|
|
549
|
+
|
|
550
|
+
save_settings()
|
|
551
|
+
# Disconnect all tabs
|
|
552
|
+
for i in range(self.tab_widget.count()):
|
|
553
|
+
tab = self.tab_widget.widget(i)
|
|
554
|
+
if isinstance(tab, TerminalTab):
|
|
555
|
+
tab.disconnect()
|
|
556
|
+
|
|
557
|
+
self.session_store.close()
|
|
558
|
+
event.accept()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class TerminalWindow(QMainWindow):
|
|
562
|
+
"""Standalone terminal window for a single session."""
|
|
563
|
+
|
|
564
|
+
def __init__(
|
|
565
|
+
self,
|
|
566
|
+
session: SavedSession,
|
|
567
|
+
profile: ConnectionProfile,
|
|
568
|
+
theme: Theme,
|
|
569
|
+
credential_resolver: CredentialResolver = None
|
|
570
|
+
):
|
|
571
|
+
super().__init__()
|
|
572
|
+
self.setWindowTitle(f"{session.name} - {session.hostname}")
|
|
573
|
+
self.resize(1000, 700)
|
|
574
|
+
|
|
575
|
+
# Apply Qt theme
|
|
576
|
+
self.setStyleSheet(generate_stylesheet(theme))
|
|
577
|
+
|
|
578
|
+
self.tab = TerminalTab(session, profile, credential_resolver)
|
|
579
|
+
self.tab.terminal.set_theme(theme)
|
|
580
|
+
self.setCentralWidget(self.tab)
|
|
581
|
+
|
|
582
|
+
self.tab.connect()
|
|
583
|
+
|
|
584
|
+
def closeEvent(self, event):
|
|
585
|
+
"""Disconnect on close."""
|
|
586
|
+
self.tab.disconnect()
|
|
587
|
+
event.accept()
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def main():
|
|
591
|
+
app = QApplication(sys.argv)
|
|
592
|
+
|
|
593
|
+
# Optional: Apply app-wide style
|
|
594
|
+
app.setStyle("Fusion")
|
|
595
|
+
|
|
596
|
+
# Ensure .nterm directory exists
|
|
597
|
+
NTERM_DIR.mkdir(parents=True, exist_ok=True)
|
|
598
|
+
|
|
599
|
+
# Initialize credential resolver
|
|
600
|
+
resolver = CredentialResolver()
|
|
601
|
+
|
|
602
|
+
# Unlock vault on startup
|
|
603
|
+
unlock_dialog = VaultUnlockDialog(resolver)
|
|
604
|
+
unlock_dialog.exec()
|
|
605
|
+
|
|
606
|
+
if unlock_dialog.is_unlocked():
|
|
607
|
+
print(f"Vault unlocked: {resolver.db_path}")
|
|
608
|
+
else:
|
|
609
|
+
print("Continuing without vault (agent auth only)")
|
|
610
|
+
|
|
611
|
+
# Create and show main window
|
|
612
|
+
window = MainWindow(resolver)
|
|
613
|
+
window.show()
|
|
614
|
+
|
|
615
|
+
sys.exit(app.exec())
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
if __name__ == "__main__":
|
|
619
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSH_ASKPASS implementation for GUI-based authentication.
|
|
3
|
+
|
|
4
|
+
This module provides:
|
|
5
|
+
- AskpassServer: Listens for SSH authentication requests
|
|
6
|
+
- AskpassRequest/Response: Data structures for the protocol
|
|
7
|
+
- BlockingAskpassHandler: Helper for synchronous GUI integration
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .server import (
|
|
11
|
+
AskpassServer,
|
|
12
|
+
AskpassRequest,
|
|
13
|
+
AskpassResponse,
|
|
14
|
+
BlockingAskpassHandler,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AskpassServer",
|
|
19
|
+
"AskpassRequest",
|
|
20
|
+
"AskpassResponse",
|
|
21
|
+
"BlockingAskpassHandler",
|
|
22
|
+
]
|