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.
Files changed (52) hide show
  1. nterm/__init__.py +54 -0
  2. nterm/__main__.py +619 -0
  3. nterm/askpass/__init__.py +22 -0
  4. nterm/askpass/server.py +393 -0
  5. nterm/config.py +158 -0
  6. nterm/connection/__init__.py +17 -0
  7. nterm/connection/profile.py +296 -0
  8. nterm/manager/__init__.py +29 -0
  9. nterm/manager/connect_dialog.py +322 -0
  10. nterm/manager/editor.py +262 -0
  11. nterm/manager/io.py +678 -0
  12. nterm/manager/models.py +346 -0
  13. nterm/manager/settings.py +264 -0
  14. nterm/manager/tree.py +493 -0
  15. nterm/resources.py +48 -0
  16. nterm/session/__init__.py +60 -0
  17. nterm/session/askpass_ssh.py +399 -0
  18. nterm/session/base.py +110 -0
  19. nterm/session/interactive_ssh.py +522 -0
  20. nterm/session/pty_transport.py +571 -0
  21. nterm/session/ssh.py +610 -0
  22. nterm/terminal/__init__.py +11 -0
  23. nterm/terminal/bridge.py +83 -0
  24. nterm/terminal/resources/terminal.html +253 -0
  25. nterm/terminal/resources/terminal.js +414 -0
  26. nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
  27. nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
  28. nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
  29. nterm/terminal/resources/xterm.css +209 -0
  30. nterm/terminal/resources/xterm.min.js +8 -0
  31. nterm/terminal/widget.py +380 -0
  32. nterm/theme/__init__.py +10 -0
  33. nterm/theme/engine.py +456 -0
  34. nterm/theme/stylesheet.py +377 -0
  35. nterm/theme/themes/clean.yaml +0 -0
  36. nterm/theme/themes/default.yaml +36 -0
  37. nterm/theme/themes/dracula.yaml +36 -0
  38. nterm/theme/themes/gruvbox_dark.yaml +36 -0
  39. nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
  40. nterm/theme/themes/gruvbox_light.yaml +36 -0
  41. nterm/vault/__init__.py +32 -0
  42. nterm/vault/credential_manager.py +163 -0
  43. nterm/vault/keychain.py +135 -0
  44. nterm/vault/manager_ui.py +962 -0
  45. nterm/vault/profile.py +219 -0
  46. nterm/vault/resolver.py +250 -0
  47. nterm/vault/store.py +642 -0
  48. ntermqt-0.1.0.dist-info/METADATA +327 -0
  49. ntermqt-0.1.0.dist-info/RECORD +52 -0
  50. ntermqt-0.1.0.dist-info/WHEEL +5 -0
  51. ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
  52. ntermqt-0.1.0.dist-info/top_level.txt +1 -0
nterm/session/ssh.py ADDED
@@ -0,0 +1,610 @@
1
+ """
2
+ SSH session implementation using Paramiko.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import socket
7
+ import threading
8
+ import time
9
+ import logging
10
+ import warnings
11
+ from typing import Optional, Callable
12
+ from io import StringIO
13
+
14
+ import paramiko
15
+
16
+ from .base import (
17
+ Session, SessionState, SessionEvent,
18
+ DataReceived, StateChanged, InteractionRequired, BannerReceived
19
+ )
20
+ from ..connection.profile import (
21
+ ConnectionProfile, AuthMethod, AuthConfig, JumpHostConfig
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # =============================================================================
28
+ # Legacy Device Support - Algorithm Configuration
29
+ # =============================================================================
30
+ # These settings provide broad compatibility with older network devices
31
+ # (old Juniper, Cisco IOS, etc.) while still preferring modern algorithms.
32
+
33
+ PREFERRED_CIPHERS = (
34
+ "aes128-ctr",
35
+ "aes192-ctr",
36
+ "aes256-ctr",
37
+ "aes128-gcm@openssh.com",
38
+ "aes256-gcm@openssh.com",
39
+ "chacha20-poly1305@openssh.com",
40
+ "aes128-cbc",
41
+ "aes192-cbc",
42
+ "aes256-cbc",
43
+ "3des-cbc",
44
+ )
45
+
46
+ PREFERRED_KEX = (
47
+ "curve25519-sha256",
48
+ "curve25519-sha256@libssh.org",
49
+ "ecdh-sha2-nistp256",
50
+ "ecdh-sha2-nistp384",
51
+ "ecdh-sha2-nistp521",
52
+ "diffie-hellman-group14-sha256",
53
+ "diffie-hellman-group16-sha512",
54
+ "diffie-hellman-group-exchange-sha256",
55
+ "diffie-hellman-group14-sha1",
56
+ "diffie-hellman-group-exchange-sha1",
57
+ "diffie-hellman-group1-sha1",
58
+ )
59
+
60
+ PREFERRED_KEYS = (
61
+ "rsa-sha2-512",
62
+ "rsa-sha2-256",
63
+ "ssh-rsa",
64
+ "ecdsa-sha2-nistp256",
65
+ "ecdsa-sha2-nistp384",
66
+ "ecdsa-sha2-nistp521",
67
+ "ssh-ed25519",
68
+ )
69
+
70
+ # Disabled algorithms to force RSA SHA-1 signatures
71
+ # Required for old OpenSSH servers (< 7.2) that don't support rsa-sha2-*
72
+ RSA_SHA1_DISABLED_ALGORITHMS = {
73
+ 'pubkeys': ['rsa-sha2-256', 'rsa-sha2-512']
74
+ }
75
+
76
+ # Flag to track if we've applied global transport settings
77
+ _transport_configured = False
78
+
79
+
80
+ def _apply_global_transport_settings() -> None:
81
+ """
82
+ Apply custom transport settings globally to Paramiko for legacy device support.
83
+
84
+ This must be called before creating any SSH connections. It modifies the
85
+ Paramiko Transport class to prefer algorithms compatible with older devices.
86
+ """
87
+ global _transport_configured
88
+
89
+ if _transport_configured:
90
+ return
91
+
92
+ # Suppress deprecation warnings for legacy algorithms
93
+ warnings.filterwarnings('ignore', category=DeprecationWarning, module='paramiko')
94
+
95
+ try:
96
+ # Get what Paramiko actually supports
97
+ available_ciphers = set(paramiko.Transport._cipher_info.keys())
98
+ available_kex = set(paramiko.Transport._kex_info.keys())
99
+ available_keys = set(paramiko.Transport._key_info.keys())
100
+
101
+ # Filter to only supported algorithms
102
+ ciphers = tuple(c for c in PREFERRED_CIPHERS if c in available_ciphers)
103
+ kex = tuple(k for k in PREFERRED_KEX if k in available_kex)
104
+ keys = tuple(k for k in PREFERRED_KEYS if k in available_keys)
105
+
106
+ # Apply globally to Transport class
107
+ paramiko.Transport._preferred_ciphers = ciphers
108
+ paramiko.Transport._preferred_kex = kex
109
+ paramiko.Transport._preferred_keys = keys
110
+
111
+ logger.info(
112
+ f"Applied global transport settings: "
113
+ f"{len(ciphers)} ciphers, {len(kex)} kex, {len(keys)} keys"
114
+ )
115
+ logger.debug(f"Ciphers: {ciphers}")
116
+ logger.debug(f"KEX: {kex}")
117
+ logger.debug(f"Keys: {keys}")
118
+
119
+ except Exception as e:
120
+ logger.warning(f"Could not apply global transport settings: {e}")
121
+
122
+ _transport_configured = True
123
+
124
+
125
+ class SSHSession(Session):
126
+ """
127
+ SSH session with full reconnection support.
128
+
129
+ Thread-safe. Runs I/O on background thread,
130
+ emits events to be handled on main thread.
131
+
132
+ Supports automatic fallback for:
133
+ - RSA SHA-1 signatures (old OpenSSH < 7.2)
134
+ - Legacy crypto algorithms (old network devices)
135
+ """
136
+
137
+ READ_BUFFER_SIZE = 65536
138
+
139
+ def __init__(self, profile: ConnectionProfile, vault=None):
140
+ """
141
+ Initialize SSH session.
142
+
143
+ Args:
144
+ profile: Connection profile with auth and host info
145
+ vault: Optional credential vault for resolving credential_ref
146
+ """
147
+ # Apply global transport settings on first session creation
148
+ _apply_global_transport_settings()
149
+
150
+ self.profile = profile
151
+ self.vault = vault
152
+
153
+ self._state = SessionState.DISCONNECTED
154
+ self._state_lock = threading.Lock()
155
+
156
+ self._client: Optional[paramiko.SSHClient] = None
157
+ self._channel: Optional[paramiko.Channel] = None
158
+ self._jump_clients: list[paramiko.SSHClient] = []
159
+
160
+ self._event_handler: Optional[Callable[[SessionEvent], None]] = None
161
+ self._read_thread: Optional[threading.Thread] = None
162
+ self._stop_event = threading.Event()
163
+
164
+ self._reconnect_attempt = 0
165
+ self._cols = profile.term_cols
166
+ self._rows = profile.term_rows
167
+
168
+ # Track if we needed RSA SHA-1 fallback (for reconnects)
169
+ self._use_rsa_sha1 = getattr(profile, 'rsa_sha1', False)
170
+
171
+ @property
172
+ def state(self) -> SessionState:
173
+ """Current session state (thread-safe)."""
174
+ with self._state_lock:
175
+ return self._state
176
+
177
+ @property
178
+ def is_connected(self) -> bool:
179
+ """Is session currently connected and usable?"""
180
+ return self.state == SessionState.CONNECTED
181
+
182
+ def set_event_handler(self, handler: Callable[[SessionEvent], None]) -> None:
183
+ """Set callback for session events."""
184
+ self._event_handler = handler
185
+
186
+ def set_auto_reconnect(self, enabled: bool) -> None:
187
+ """Enable/disable automatic reconnection."""
188
+ self.profile.auto_reconnect = enabled
189
+
190
+ def _emit(self, event: SessionEvent) -> None:
191
+ """Emit event to handler."""
192
+ if self._event_handler:
193
+ try:
194
+ self._event_handler(event)
195
+ except Exception as e:
196
+ logger.exception(f"Event handler error: {e}")
197
+
198
+ def _set_state(self, new_state: SessionState, message: str = "") -> None:
199
+ """Update state and emit event."""
200
+ with self._state_lock:
201
+ old_state = self._state
202
+ self._state = new_state
203
+ logger.info(f"Session state: {old_state.name} -> {new_state.name} {message}")
204
+ self._emit(StateChanged(old_state, new_state, message))
205
+
206
+ def connect(self) -> None:
207
+ """Start connection in background thread."""
208
+ if self.state not in (SessionState.DISCONNECTED, SessionState.FAILED):
209
+ logger.warning(f"Cannot connect from state {self.state}")
210
+ return
211
+
212
+ self._stop_event.clear()
213
+ thread = threading.Thread(target=self._connect_thread, daemon=True)
214
+ thread.start()
215
+
216
+ def _create_client(self) -> paramiko.SSHClient:
217
+ """Create a new SSHClient with standard settings."""
218
+ client = paramiko.SSHClient()
219
+ client.load_system_host_keys()
220
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
221
+ return client
222
+
223
+ def _connect_thread(self) -> None:
224
+ """Connection logic - runs on background thread."""
225
+ try:
226
+ self._set_state(SessionState.CONNECTING)
227
+
228
+ # Build jump chain if needed
229
+ sock = None
230
+ if self.profile.jump_hosts:
231
+ sock = self._establish_jump_chain()
232
+
233
+ # Connect to target
234
+ self._set_state(SessionState.AUTHENTICATING)
235
+
236
+ connected = False
237
+ last_error = None
238
+
239
+ for auth in self.profile.auth_methods:
240
+ try:
241
+ logger.info(f"Trying auth method: {auth.method.value}")
242
+
243
+ # Handle agent auth with potential touch prompt
244
+ if auth.method == AuthMethod.AGENT:
245
+ self._emit(InteractionRequired(
246
+ prompt="Authenticate with your security key...",
247
+ interaction_type="touch"
248
+ ))
249
+
250
+ self._attempt_connection(auth, sock)
251
+ connected = True
252
+ break
253
+
254
+ except paramiko.AuthenticationException as e:
255
+ last_error = e
256
+ logger.debug(f"Auth method {auth.method} failed: {e}")
257
+ continue
258
+
259
+ except Exception as e:
260
+ last_error = e
261
+ logger.debug(f"Connection error with {auth.method}: {e}")
262
+ continue
263
+
264
+ if not connected:
265
+ raise paramiko.AuthenticationException(
266
+ f"All auth methods failed. Last error: {last_error}"
267
+ )
268
+
269
+ # Open shell channel
270
+ self._channel = self._client.invoke_shell(
271
+ term=self.profile.term_type,
272
+ width=self._cols,
273
+ height=self._rows,
274
+ )
275
+ self._channel.settimeout(0.1)
276
+
277
+ # Set up keepalive
278
+ transport = self._client.get_transport()
279
+ if transport:
280
+ transport.set_keepalive(self.profile.keepalive_interval)
281
+ logger.debug(
282
+ f"Negotiated: cipher={transport.remote_cipher}, "
283
+ f"mac={transport.remote_mac}"
284
+ )
285
+
286
+ self._set_state(SessionState.CONNECTED)
287
+ self._reconnect_attempt = 0
288
+
289
+ # Start read loop
290
+ self._read_loop()
291
+
292
+ except Exception as e:
293
+ logger.exception("Connection failed")
294
+ self._cleanup()
295
+ self._set_state(SessionState.FAILED, str(e))
296
+
297
+ # Auto-reconnect if enabled
298
+ if self.profile.auto_reconnect and not self._stop_event.is_set():
299
+ self._schedule_reconnect()
300
+
301
+ def _attempt_connection(self, auth: AuthConfig, sock=None) -> None:
302
+ """
303
+ Attempt connection with automatic RSA SHA-1 fallback.
304
+
305
+ For RSA keys on old servers (OpenSSH < 7.2), the server may not
306
+ support rsa-sha2-256/512 signatures. We detect this and retry
307
+ with disabled_algorithms to force legacy ssh-rsa (SHA-1).
308
+ """
309
+ kwargs = self._auth_config_to_kwargs(auth)
310
+ if sock:
311
+ kwargs['sock'] = sock
312
+
313
+ # If we already know this host needs RSA SHA-1, use it from the start
314
+ if self._use_rsa_sha1:
315
+ kwargs['disabled_algorithms'] = RSA_SHA1_DISABLED_ALGORITHMS
316
+ logger.debug("Using RSA SHA-1 mode (from previous connection)")
317
+
318
+ # Determine if this is RSA key auth (for fallback logic)
319
+ is_rsa_key = False
320
+ pkey = kwargs.get('pkey')
321
+ if pkey and isinstance(pkey, paramiko.RSAKey):
322
+ is_rsa_key = True
323
+ elif auth.method == AuthMethod.KEY_FILE and auth.key_path:
324
+ # Check if it's an RSA key file
325
+ try:
326
+ paramiko.RSAKey.from_private_key_file(auth.key_path)
327
+ is_rsa_key = True
328
+ except:
329
+ pass
330
+
331
+ # First attempt
332
+ try:
333
+ self._client = self._create_client()
334
+ self._client.connect(
335
+ self.profile.hostname,
336
+ port=self.profile.port,
337
+ timeout=self.profile.connect_timeout,
338
+ **kwargs
339
+ )
340
+ return # Success!
341
+
342
+ except paramiko.AuthenticationException as e:
343
+ # Check if this might be an RSA SHA-2 failure
344
+ if is_rsa_key and not self._use_rsa_sha1:
345
+ logger.info(f"RSA auth failed, retrying with SHA-1 fallback: {e}")
346
+
347
+ # Retry with RSA SHA-1
348
+ self._use_rsa_sha1 = True
349
+ kwargs['disabled_algorithms'] = RSA_SHA1_DISABLED_ALGORITHMS
350
+
351
+ self._client = self._create_client()
352
+ self._client.connect(
353
+ self.profile.hostname,
354
+ port=self.profile.port,
355
+ timeout=self.profile.connect_timeout,
356
+ **kwargs
357
+ )
358
+ logger.info("Connected with RSA SHA-1 fallback")
359
+ return
360
+
361
+ # Not RSA or already tried SHA-1, re-raise
362
+ raise
363
+
364
+ def _establish_jump_chain(self) -> paramiko.Channel:
365
+ """Connect through jump host chain, return channel to final hop."""
366
+ current_sock = None
367
+
368
+ for i, jump in enumerate(self.profile.jump_hosts):
369
+ logger.info(
370
+ f"Connecting to jump host {i+1}/{len(self.profile.jump_hosts)}: "
371
+ f"{jump.hostname}"
372
+ )
373
+
374
+ if jump.requires_touch:
375
+ self._emit(InteractionRequired(
376
+ prompt=jump.touch_prompt,
377
+ interaction_type="touch"
378
+ ))
379
+
380
+ jump_client = self._create_client()
381
+ kwargs = self._auth_config_to_kwargs(jump.auth)
382
+
383
+ if current_sock:
384
+ kwargs['sock'] = current_sock
385
+
386
+ # Check for jump-specific RSA SHA-1 flag
387
+ if getattr(jump, 'rsa_sha1', False):
388
+ kwargs['disabled_algorithms'] = RSA_SHA1_DISABLED_ALGORITHMS
389
+
390
+ # Try connection with RSA SHA-1 fallback
391
+ try:
392
+ jump_client.connect(
393
+ jump.hostname,
394
+ port=jump.port,
395
+ timeout=self.profile.connect_timeout,
396
+ banner_timeout=jump.banner_timeout,
397
+ **kwargs
398
+ )
399
+ except paramiko.AuthenticationException as e:
400
+ # Try RSA SHA-1 fallback for jump host
401
+ if 'disabled_algorithms' not in kwargs:
402
+ logger.info(f"Jump host {jump.hostname} auth failed, trying RSA SHA-1")
403
+ kwargs['disabled_algorithms'] = RSA_SHA1_DISABLED_ALGORITHMS
404
+ jump_client = self._create_client()
405
+ jump_client.connect(
406
+ jump.hostname,
407
+ port=jump.port,
408
+ timeout=self.profile.connect_timeout,
409
+ banner_timeout=jump.banner_timeout,
410
+ **kwargs
411
+ )
412
+ else:
413
+ raise
414
+
415
+ self._jump_clients.append(jump_client)
416
+
417
+ # Determine next hop
418
+ if i < len(self.profile.jump_hosts) - 1:
419
+ next_hop = self.profile.jump_hosts[i + 1]
420
+ next_host, next_port = next_hop.hostname, next_hop.port
421
+ else:
422
+ next_host = self.profile.hostname
423
+ next_port = self.profile.port
424
+
425
+ # Open channel to next hop
426
+ transport = jump_client.get_transport()
427
+ current_sock = transport.open_channel(
428
+ 'direct-tcpip',
429
+ dest_addr=(next_host, next_port),
430
+ src_addr=('127.0.0.1', 0)
431
+ )
432
+ logger.debug(f"Opened channel to {next_host}:{next_port}")
433
+
434
+ return current_sock
435
+
436
+ def _auth_config_to_kwargs(self, auth: AuthConfig) -> dict:
437
+ """Convert AuthConfig to paramiko connect kwargs."""
438
+ if auth is None:
439
+ return {}
440
+
441
+ kwargs = {'username': auth.username}
442
+
443
+ # Resolve credential reference if needed
444
+ password = auth.password
445
+ key_data = auth.key_data
446
+ key_passphrase = auth.key_passphrase
447
+
448
+ if auth.credential_ref and self.vault:
449
+ cred = self.vault.get_credential(auth.credential_ref)
450
+ if cred:
451
+ password = password or cred.password
452
+ key_data = key_data or getattr(cred, 'ssh_key', None)
453
+ key_passphrase = key_passphrase or getattr(cred, 'ssh_key_passphrase', None)
454
+
455
+ if auth.method == AuthMethod.PASSWORD:
456
+ kwargs['password'] = password
457
+ kwargs['look_for_keys'] = False
458
+ kwargs['allow_agent'] = False
459
+
460
+ elif auth.method == AuthMethod.AGENT:
461
+ kwargs['allow_agent'] = True
462
+ kwargs['look_for_keys'] = False
463
+
464
+ elif auth.method == AuthMethod.KEY_FILE:
465
+ kwargs['key_filename'] = auth.key_path
466
+ if key_passphrase:
467
+ kwargs['passphrase'] = key_passphrase
468
+ kwargs['allow_agent'] = auth.allow_agent_fallback
469
+ kwargs['look_for_keys'] = False
470
+
471
+ elif auth.method == AuthMethod.KEY_STORED:
472
+ if key_data:
473
+ kwargs['pkey'] = self._load_key_from_string(
474
+ key_data,
475
+ key_passphrase
476
+ )
477
+ kwargs['allow_agent'] = auth.allow_agent_fallback
478
+ kwargs['look_for_keys'] = False
479
+
480
+ elif auth.method == AuthMethod.CERTIFICATE:
481
+ kwargs['key_filename'] = auth.key_path
482
+ kwargs['allow_agent'] = False
483
+ kwargs['look_for_keys'] = False
484
+
485
+ return kwargs
486
+
487
+ def _load_key_from_string(
488
+ self,
489
+ key_data: str,
490
+ passphrase: str = None
491
+ ) -> paramiko.PKey:
492
+ """Load SSH key from string data."""
493
+ key_file = StringIO(key_data)
494
+
495
+ key_classes = [
496
+ paramiko.RSAKey,
497
+ paramiko.Ed25519Key,
498
+ paramiko.ECDSAKey,
499
+ ]
500
+
501
+ for key_class in key_classes:
502
+ try:
503
+ key_file.seek(0)
504
+ return key_class.from_private_key(key_file, password=passphrase)
505
+ except (paramiko.SSHException, ValueError):
506
+ continue
507
+
508
+ raise paramiko.SSHException("Unable to parse private key")
509
+
510
+ def _read_loop(self) -> None:
511
+ """Read data from channel until stopped or disconnected."""
512
+ while not self._stop_event.is_set():
513
+ try:
514
+ if self._channel and self._channel.recv_ready():
515
+ data = self._channel.recv(self.READ_BUFFER_SIZE)
516
+ if data:
517
+ self._emit(DataReceived(data))
518
+ else:
519
+ logger.info("Channel closed by remote")
520
+ break
521
+ elif self._channel and self._channel.closed:
522
+ logger.info("Channel closed")
523
+ break
524
+ else:
525
+ time.sleep(0.01)
526
+
527
+ except socket.timeout:
528
+ continue
529
+ except Exception as e:
530
+ logger.exception("Read error")
531
+ break
532
+
533
+ if not self._stop_event.is_set():
534
+ self._cleanup()
535
+ self._set_state(SessionState.DISCONNECTED, "Connection lost")
536
+
537
+ if self.profile.auto_reconnect:
538
+ self._schedule_reconnect()
539
+
540
+ def _schedule_reconnect(self) -> None:
541
+ """Schedule a reconnection attempt."""
542
+ if self._reconnect_attempt >= self.profile.reconnect_max_attempts:
543
+ self._set_state(SessionState.FAILED, "Max reconnection attempts reached")
544
+ return
545
+
546
+ delay = self.profile.reconnect_delay * (
547
+ self.profile.reconnect_backoff ** self._reconnect_attempt
548
+ )
549
+ self._reconnect_attempt += 1
550
+
551
+ self._set_state(
552
+ SessionState.RECONNECTING,
553
+ f"Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempt})"
554
+ )
555
+
556
+ def reconnect():
557
+ time.sleep(delay)
558
+ if not self._stop_event.is_set():
559
+ self._connect_thread()
560
+
561
+ thread = threading.Thread(target=reconnect, daemon=True)
562
+ thread.start()
563
+
564
+ def write(self, data: bytes) -> None:
565
+ """Send data to remote."""
566
+ if self._channel and not self._channel.closed:
567
+ try:
568
+ self._channel.sendall(data)
569
+ except Exception as e:
570
+ logger.error(f"Write error: {e}")
571
+
572
+ def resize(self, cols: int, rows: int) -> None:
573
+ """Notify remote of terminal resize."""
574
+ self._cols = cols
575
+ self._rows = rows
576
+ if self._channel and not self._channel.closed:
577
+ try:
578
+ self._channel.resize_pty(width=cols, height=rows)
579
+ except Exception as e:
580
+ logger.error(f"Resize error: {e}")
581
+
582
+ def disconnect(self) -> None:
583
+ """Gracefully disconnect."""
584
+ logger.info("Disconnecting...")
585
+ self._stop_event.set()
586
+ self._cleanup()
587
+ self._set_state(SessionState.DISCONNECTED, "User disconnected")
588
+
589
+ def _cleanup(self) -> None:
590
+ """Clean up connections."""
591
+ if self._channel:
592
+ try:
593
+ self._channel.close()
594
+ except Exception:
595
+ pass
596
+ self._channel = None
597
+
598
+ if self._client:
599
+ try:
600
+ self._client.close()
601
+ except Exception:
602
+ pass
603
+ self._client = None
604
+
605
+ for client in self._jump_clients:
606
+ try:
607
+ client.close()
608
+ except Exception:
609
+ pass
610
+ self._jump_clients.clear()
@@ -0,0 +1,11 @@
1
+ """
2
+ Terminal widget - PyQt6 + xterm.js rendering.
3
+ """
4
+
5
+ from .widget import TerminalWidget
6
+ from .bridge import TerminalBridge
7
+
8
+ __all__ = [
9
+ "TerminalWidget",
10
+ "TerminalBridge",
11
+ ]
@@ -0,0 +1,83 @@
1
+ """
2
+ Bridge between Python and xterm.js via QWebChannel.
3
+ """
4
+
5
+ from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
6
+
7
+
8
+ class TerminalBridge(QObject):
9
+ """
10
+ Bridge between Python and xterm.js.
11
+ Exposed to JavaScript via QWebChannel.
12
+ """
13
+
14
+ # Signals to JS (Python -> JavaScript)
15
+ write_data = pyqtSignal(str) # base64 encoded data
16
+ apply_theme = pyqtSignal(str) # JSON theme config
17
+ set_font = pyqtSignal(str, int) # family, size
18
+ do_resize = pyqtSignal(int, int) # cols, rows
19
+ show_overlay = pyqtSignal(str, bool) # message, show_spinner
20
+ hide_overlay = pyqtSignal()
21
+ focus_terminal = pyqtSignal()
22
+ clear_terminal = pyqtSignal()
23
+
24
+ # Clipboard signals to JS
25
+ do_copy = pyqtSignal() # trigger copy selection
26
+ do_paste = pyqtSignal(str) # base64 data to paste (confirmed)
27
+ show_paste_confirm = pyqtSignal(str, int) # preview text, line_count
28
+ hide_paste_confirm = pyqtSignal()
29
+
30
+ # Signals from JS (JavaScript -> Python)
31
+ data_from_terminal = pyqtSignal(str) # base64 encoded
32
+ size_changed = pyqtSignal(int, int) # cols, rows
33
+ terminal_ready = pyqtSignal()
34
+ title_changed = pyqtSignal(str)
35
+
36
+ # Clipboard signals from JS
37
+ selection_copied = pyqtSignal(str) # copied text
38
+ paste_requested = pyqtSignal(str) # base64 clipboard content for confirmation
39
+ paste_confirmed = pyqtSignal() # user confirmed multiline paste
40
+ paste_cancelled = pyqtSignal() # user cancelled multiline paste
41
+
42
+ def __init__(self):
43
+ super().__init__()
44
+
45
+ @pyqtSlot(str)
46
+ def onData(self, data_b64: str):
47
+ """Called from JS when user types."""
48
+ self.data_from_terminal.emit(data_b64)
49
+
50
+ @pyqtSlot(int, int)
51
+ def onResize(self, cols: int, rows: int):
52
+ """Called from JS on terminal resize."""
53
+ self.size_changed.emit(cols, rows)
54
+
55
+ @pyqtSlot()
56
+ def onReady(self):
57
+ """Called from JS when xterm.js is initialized."""
58
+ self.terminal_ready.emit()
59
+
60
+ @pyqtSlot(str)
61
+ def onTitleChange(self, title: str):
62
+ """Called from JS when terminal title changes."""
63
+ self.title_changed.emit(title)
64
+
65
+ @pyqtSlot(str)
66
+ def onSelectionCopied(self, text: str):
67
+ """Called from JS when selection is copied."""
68
+ self.selection_copied.emit(text)
69
+
70
+ @pyqtSlot(str)
71
+ def onPasteRequested(self, data_b64: str):
72
+ """Called from JS when paste is requested (for confirmation check)."""
73
+ self.paste_requested.emit(data_b64)
74
+
75
+ @pyqtSlot()
76
+ def onPasteConfirmed(self):
77
+ """Called from JS when user confirms multiline paste."""
78
+ self.paste_confirmed.emit()
79
+
80
+ @pyqtSlot()
81
+ def onPasteCancelled(self):
82
+ """Called from JS when user cancels multiline paste."""
83
+ self.paste_cancelled.emit()