ntermqt 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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()