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/askpass/server.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSH_ASKPASS server for GUI-based SSH authentication.
|
|
3
|
+
|
|
4
|
+
This module implements the SSH_ASKPASS protocol to capture authentication
|
|
5
|
+
prompts (passwords, YubiKey touch requests, etc.) and route them to the GUI.
|
|
6
|
+
|
|
7
|
+
How it works:
|
|
8
|
+
1. AskpassServer listens on a Unix socket
|
|
9
|
+
2. SSH_ASKPASS env var points to our helper script
|
|
10
|
+
3. When SSH needs input, it calls the helper
|
|
11
|
+
4. Helper connects to socket, sends prompt
|
|
12
|
+
5. Server emits signal to GUI
|
|
13
|
+
6. GUI responds (user types password or touches YubiKey)
|
|
14
|
+
7. Response sent back through socket to helper
|
|
15
|
+
8. Helper prints response to stdout for SSH
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import socket
|
|
22
|
+
import threading
|
|
23
|
+
import tempfile
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import stat
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional, Callable
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Path to the helper script (will be created alongside this module)
|
|
34
|
+
HELPER_SCRIPT_NAME = "nterm_askpass_helper.py"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class AskpassRequest:
|
|
39
|
+
"""Request from SSH for user input."""
|
|
40
|
+
prompt: str
|
|
41
|
+
is_password: bool # True if input should be hidden
|
|
42
|
+
is_confirmation: bool # True if just needs confirmation (YubiKey touch)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class AskpassResponse:
|
|
47
|
+
"""Response to send back to SSH."""
|
|
48
|
+
success: bool
|
|
49
|
+
value: str = "" # Password or empty for confirmation
|
|
50
|
+
error: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AskpassServer:
|
|
54
|
+
"""
|
|
55
|
+
Server that handles SSH_ASKPASS requests.
|
|
56
|
+
|
|
57
|
+
Usage:
|
|
58
|
+
server = AskpassServer()
|
|
59
|
+
server.set_handler(my_callback) # Called when SSH needs input
|
|
60
|
+
server.start()
|
|
61
|
+
|
|
62
|
+
# Use server.socket_path and server.helper_path in SSH environment
|
|
63
|
+
env = server.get_env()
|
|
64
|
+
|
|
65
|
+
# ... run SSH ...
|
|
66
|
+
|
|
67
|
+
server.stop()
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self):
|
|
71
|
+
self._socket_path: Optional[str] = None
|
|
72
|
+
self._helper_path: Optional[str] = None
|
|
73
|
+
self._server_socket: Optional[socket.socket] = None
|
|
74
|
+
self._thread: Optional[threading.Thread] = None
|
|
75
|
+
self._running = False
|
|
76
|
+
self._handler: Optional[Callable[[AskpassRequest], AskpassResponse]] = None
|
|
77
|
+
self._temp_dir: Optional[tempfile.TemporaryDirectory] = None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def socket_path(self) -> Optional[str]:
|
|
81
|
+
"""Path to the Unix socket."""
|
|
82
|
+
return self._socket_path
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def helper_path(self) -> Optional[str]:
|
|
86
|
+
"""Path to the askpass helper script."""
|
|
87
|
+
return self._helper_path
|
|
88
|
+
|
|
89
|
+
def set_handler(self, handler: Callable[[AskpassRequest], AskpassResponse]) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Set the callback for handling askpass requests.
|
|
92
|
+
|
|
93
|
+
The handler receives an AskpassRequest and should return an AskpassResponse.
|
|
94
|
+
This will typically show a dialog or terminal prompt in the GUI.
|
|
95
|
+
"""
|
|
96
|
+
self._handler = handler
|
|
97
|
+
|
|
98
|
+
def start(self) -> None:
|
|
99
|
+
"""Start the askpass server."""
|
|
100
|
+
if self._running:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
# Create temp directory for socket and helper
|
|
104
|
+
self._temp_dir = tempfile.TemporaryDirectory(prefix="nterm_askpass_")
|
|
105
|
+
temp_path = Path(self._temp_dir.name)
|
|
106
|
+
|
|
107
|
+
# Create Unix socket
|
|
108
|
+
self._socket_path = str(temp_path / "askpass.sock")
|
|
109
|
+
self._server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
110
|
+
self._server_socket.bind(self._socket_path)
|
|
111
|
+
self._server_socket.listen(5)
|
|
112
|
+
self._server_socket.settimeout(1.0) # Allow periodic check for shutdown
|
|
113
|
+
|
|
114
|
+
# Create helper script
|
|
115
|
+
self._helper_path = str(temp_path / HELPER_SCRIPT_NAME)
|
|
116
|
+
self._create_helper_script()
|
|
117
|
+
|
|
118
|
+
# Start server thread
|
|
119
|
+
self._running = True
|
|
120
|
+
self._thread = threading.Thread(target=self._server_loop, daemon=True)
|
|
121
|
+
self._thread.start()
|
|
122
|
+
|
|
123
|
+
logger.info(f"Askpass server started: {self._socket_path}")
|
|
124
|
+
|
|
125
|
+
def stop(self) -> None:
|
|
126
|
+
"""Stop the askpass server."""
|
|
127
|
+
self._running = False
|
|
128
|
+
|
|
129
|
+
if self._server_socket:
|
|
130
|
+
try:
|
|
131
|
+
self._server_socket.close()
|
|
132
|
+
except:
|
|
133
|
+
pass
|
|
134
|
+
self._server_socket = None
|
|
135
|
+
|
|
136
|
+
if self._thread:
|
|
137
|
+
self._thread.join(timeout=2.0)
|
|
138
|
+
self._thread = None
|
|
139
|
+
|
|
140
|
+
if self._temp_dir:
|
|
141
|
+
try:
|
|
142
|
+
self._temp_dir.cleanup()
|
|
143
|
+
except:
|
|
144
|
+
pass
|
|
145
|
+
self._temp_dir = None
|
|
146
|
+
|
|
147
|
+
self._socket_path = None
|
|
148
|
+
self._helper_path = None
|
|
149
|
+
|
|
150
|
+
logger.info("Askpass server stopped")
|
|
151
|
+
|
|
152
|
+
def get_env(self) -> dict:
|
|
153
|
+
"""
|
|
154
|
+
Get environment variables to set for SSH.
|
|
155
|
+
|
|
156
|
+
Returns dict with SSH_ASKPASS, SSH_ASKPASS_REQUIRE, and NTERM_ASKPASS_SOCK.
|
|
157
|
+
"""
|
|
158
|
+
if not self._running:
|
|
159
|
+
raise RuntimeError("Askpass server not running")
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
'SSH_ASKPASS': self._helper_path,
|
|
163
|
+
'SSH_ASKPASS_REQUIRE': 'force', # Use askpass even with TTY
|
|
164
|
+
'NTERM_ASKPASS_SOCK': self._socket_path,
|
|
165
|
+
'DISPLAY': os.environ.get('DISPLAY', ':0'), # Required for SSH_ASKPASS
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def _create_helper_script(self) -> None:
|
|
169
|
+
"""Create the askpass helper script."""
|
|
170
|
+
script = f'''#!/usr/bin/env python3
|
|
171
|
+
"""
|
|
172
|
+
SSH_ASKPASS helper script - communicates with nterm askpass server.
|
|
173
|
+
This script is called by SSH when it needs user input.
|
|
174
|
+
"""
|
|
175
|
+
import os
|
|
176
|
+
import sys
|
|
177
|
+
import socket
|
|
178
|
+
import json
|
|
179
|
+
|
|
180
|
+
def main():
|
|
181
|
+
# Get prompt from command line (SSH passes it as argv[1])
|
|
182
|
+
prompt = sys.argv[1] if len(sys.argv) > 1 else "Password: "
|
|
183
|
+
|
|
184
|
+
# Get socket path from environment
|
|
185
|
+
sock_path = os.environ.get('NTERM_ASKPASS_SOCK')
|
|
186
|
+
if not sock_path:
|
|
187
|
+
print("NTERM_ASKPASS_SOCK not set", file=sys.stderr)
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
|
|
190
|
+
# Determine request type from prompt
|
|
191
|
+
prompt_lower = prompt.lower()
|
|
192
|
+
is_password = 'password' in prompt_lower or 'passphrase' in prompt_lower
|
|
193
|
+
is_confirmation = (
|
|
194
|
+
'confirm' in prompt_lower or
|
|
195
|
+
'touch' in prompt_lower or
|
|
196
|
+
'presence' in prompt_lower or
|
|
197
|
+
'yubikey' in prompt_lower or
|
|
198
|
+
'security key' in prompt_lower or
|
|
199
|
+
'tap' in prompt_lower
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Connect to server
|
|
203
|
+
try:
|
|
204
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
205
|
+
sock.connect(sock_path)
|
|
206
|
+
|
|
207
|
+
# Send request
|
|
208
|
+
request = {{
|
|
209
|
+
'prompt': prompt,
|
|
210
|
+
'is_password': is_password,
|
|
211
|
+
'is_confirmation': is_confirmation,
|
|
212
|
+
}}
|
|
213
|
+
sock.sendall(json.dumps(request).encode() + b'\\n')
|
|
214
|
+
|
|
215
|
+
# Read response
|
|
216
|
+
data = b''
|
|
217
|
+
while True:
|
|
218
|
+
chunk = sock.recv(4096)
|
|
219
|
+
if not chunk:
|
|
220
|
+
break
|
|
221
|
+
data += chunk
|
|
222
|
+
if b'\\n' in data:
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
sock.close()
|
|
226
|
+
|
|
227
|
+
response = json.loads(data.decode().strip())
|
|
228
|
+
|
|
229
|
+
if response.get('success'):
|
|
230
|
+
# Print value to stdout (SSH reads this)
|
|
231
|
+
print(response.get('value', ''))
|
|
232
|
+
sys.exit(0)
|
|
233
|
+
else:
|
|
234
|
+
print(response.get('error', 'Authentication cancelled'), file=sys.stderr)
|
|
235
|
+
sys.exit(1)
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
print(f"Askpass error: {{e}}", file=sys.stderr)
|
|
239
|
+
sys.exit(1)
|
|
240
|
+
|
|
241
|
+
if __name__ == '__main__':
|
|
242
|
+
main()
|
|
243
|
+
'''
|
|
244
|
+
|
|
245
|
+
with open(self._helper_path, 'w') as f:
|
|
246
|
+
f.write(script)
|
|
247
|
+
|
|
248
|
+
# Make executable
|
|
249
|
+
os.chmod(self._helper_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP)
|
|
250
|
+
|
|
251
|
+
logger.debug(f"Created askpass helper: {self._helper_path}")
|
|
252
|
+
|
|
253
|
+
def _server_loop(self) -> None:
|
|
254
|
+
"""Main server loop - accepts connections and handles requests."""
|
|
255
|
+
while self._running:
|
|
256
|
+
try:
|
|
257
|
+
client, _ = self._server_socket.accept()
|
|
258
|
+
except socket.timeout:
|
|
259
|
+
continue
|
|
260
|
+
except OSError:
|
|
261
|
+
break
|
|
262
|
+
|
|
263
|
+
# Handle client in same thread (requests are sequential)
|
|
264
|
+
try:
|
|
265
|
+
self._handle_client(client)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.exception(f"Error handling askpass client: {e}")
|
|
268
|
+
finally:
|
|
269
|
+
try:
|
|
270
|
+
client.close()
|
|
271
|
+
except:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
def _handle_client(self, client: socket.socket) -> None:
|
|
275
|
+
"""Handle a single askpass request."""
|
|
276
|
+
# Read request
|
|
277
|
+
data = b''
|
|
278
|
+
client.settimeout(30.0) # Timeout for reading
|
|
279
|
+
while True:
|
|
280
|
+
chunk = client.recv(4096)
|
|
281
|
+
if not chunk:
|
|
282
|
+
return
|
|
283
|
+
data += chunk
|
|
284
|
+
if b'\n' in data:
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
# Parse request
|
|
288
|
+
try:
|
|
289
|
+
req_data = json.loads(data.decode().strip())
|
|
290
|
+
request = AskpassRequest(
|
|
291
|
+
prompt=req_data.get('prompt', 'Password: '),
|
|
292
|
+
is_password=req_data.get('is_password', True),
|
|
293
|
+
is_confirmation=req_data.get('is_confirmation', False),
|
|
294
|
+
)
|
|
295
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
296
|
+
logger.error(f"Invalid askpass request: {e}")
|
|
297
|
+
response = AskpassResponse(success=False, error="Invalid request")
|
|
298
|
+
client.sendall(json.dumps({'success': False, 'error': str(e)}).encode() + b'\n')
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
logger.info(f"Askpass request: {request.prompt}")
|
|
302
|
+
|
|
303
|
+
# Call handler
|
|
304
|
+
if self._handler:
|
|
305
|
+
try:
|
|
306
|
+
response = self._handler(request)
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.exception(f"Askpass handler error: {e}")
|
|
309
|
+
response = AskpassResponse(success=False, error=str(e))
|
|
310
|
+
else:
|
|
311
|
+
logger.warning("No askpass handler set")
|
|
312
|
+
response = AskpassResponse(success=False, error="No handler")
|
|
313
|
+
|
|
314
|
+
# Send response
|
|
315
|
+
resp_data = {
|
|
316
|
+
'success': response.success,
|
|
317
|
+
'value': response.value,
|
|
318
|
+
'error': response.error,
|
|
319
|
+
}
|
|
320
|
+
client.sendall(json.dumps(resp_data).encode() + b'\n')
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class BlockingAskpassHandler:
|
|
324
|
+
"""
|
|
325
|
+
Simple blocking handler that waits for GUI to provide response.
|
|
326
|
+
|
|
327
|
+
Usage with Qt:
|
|
328
|
+
handler = BlockingAskpassHandler()
|
|
329
|
+
server.set_handler(handler.handle_request)
|
|
330
|
+
|
|
331
|
+
# In your Qt code, connect to handler.request_received signal
|
|
332
|
+
# and call handler.provide_response() when user responds
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
def __init__(self):
|
|
336
|
+
self._pending_request: Optional[AskpassRequest] = None
|
|
337
|
+
self._response: Optional[AskpassResponse] = None
|
|
338
|
+
self._event = threading.Event()
|
|
339
|
+
self._lock = threading.Lock()
|
|
340
|
+
|
|
341
|
+
# Callback for when request is received (call from Qt signal)
|
|
342
|
+
self.on_request: Optional[Callable[[AskpassRequest], None]] = None
|
|
343
|
+
|
|
344
|
+
def handle_request(self, request: AskpassRequest) -> AskpassResponse:
|
|
345
|
+
"""
|
|
346
|
+
Handle an askpass request. Blocks until provide_response() is called.
|
|
347
|
+
Called from askpass server thread.
|
|
348
|
+
"""
|
|
349
|
+
with self._lock:
|
|
350
|
+
self._pending_request = request
|
|
351
|
+
self._response = None
|
|
352
|
+
self._event.clear()
|
|
353
|
+
|
|
354
|
+
# Notify GUI (if callback set)
|
|
355
|
+
if self.on_request:
|
|
356
|
+
try:
|
|
357
|
+
self.on_request(request)
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.exception(f"on_request callback error: {e}")
|
|
360
|
+
|
|
361
|
+
# Wait for response (with timeout)
|
|
362
|
+
if not self._event.wait(timeout=120.0): # 2 minute timeout
|
|
363
|
+
return AskpassResponse(success=False, error="Timeout waiting for response")
|
|
364
|
+
|
|
365
|
+
with self._lock:
|
|
366
|
+
response = self._response or AskpassResponse(success=False, error="No response")
|
|
367
|
+
self._pending_request = None
|
|
368
|
+
self._response = None
|
|
369
|
+
return response
|
|
370
|
+
|
|
371
|
+
def provide_response(self, success: bool, value: str = "", error: str = "") -> None:
|
|
372
|
+
"""
|
|
373
|
+
Provide response to pending request. Call from GUI thread.
|
|
374
|
+
"""
|
|
375
|
+
with self._lock:
|
|
376
|
+
self._response = AskpassResponse(success=success, value=value, error=error)
|
|
377
|
+
self._event.set()
|
|
378
|
+
|
|
379
|
+
def cancel(self) -> None:
|
|
380
|
+
"""Cancel pending request."""
|
|
381
|
+
self.provide_response(success=False, error="Cancelled by user")
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def has_pending_request(self) -> bool:
|
|
385
|
+
"""Check if there's a pending request."""
|
|
386
|
+
with self._lock:
|
|
387
|
+
return self._pending_request is not None
|
|
388
|
+
|
|
389
|
+
@property
|
|
390
|
+
def pending_prompt(self) -> Optional[str]:
|
|
391
|
+
"""Get the pending request prompt, if any."""
|
|
392
|
+
with self._lock:
|
|
393
|
+
return self._pending_request.prompt if self._pending_request else None
|
nterm/config.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistent application settings for nterm.
|
|
3
|
+
Stored in ~/.nterm/config.json
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass, field, asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Default config location
|
|
16
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".nterm"
|
|
17
|
+
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AppSettings:
|
|
22
|
+
"""
|
|
23
|
+
Application settings that persist across sessions.
|
|
24
|
+
"""
|
|
25
|
+
# Appearance
|
|
26
|
+
theme_name: str = "catppuccin_mocha"
|
|
27
|
+
font_size: int = 14
|
|
28
|
+
|
|
29
|
+
# Terminal behavior
|
|
30
|
+
multiline_paste_threshold: int = 1
|
|
31
|
+
scrollback_lines: int = 10000
|
|
32
|
+
|
|
33
|
+
# Connection defaults
|
|
34
|
+
default_term_type: str = "xterm-256color"
|
|
35
|
+
default_keepalive_interval: int = 30
|
|
36
|
+
auto_reconnect: bool = True
|
|
37
|
+
|
|
38
|
+
# Window state
|
|
39
|
+
window_width: int = 1200
|
|
40
|
+
window_height: int = 800
|
|
41
|
+
window_x: Optional[int] = None
|
|
42
|
+
window_y: Optional[int] = None
|
|
43
|
+
window_maximized: bool = False
|
|
44
|
+
|
|
45
|
+
# Session tree state
|
|
46
|
+
tree_width: int = 250
|
|
47
|
+
|
|
48
|
+
# Recent connections (just names/refs, not credentials)
|
|
49
|
+
recent_profiles: list[str] = field(default_factory=list)
|
|
50
|
+
max_recent: int = 10
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
"""Serialize to dict."""
|
|
54
|
+
return asdict(self)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, data: dict) -> AppSettings:
|
|
58
|
+
"""Deserialize from dict, ignoring unknown keys."""
|
|
59
|
+
valid_fields = {f.name for f in cls.__dataclass_fields__.values()}
|
|
60
|
+
filtered = {k: v for k, v in data.items() if k in valid_fields}
|
|
61
|
+
return cls(**filtered)
|
|
62
|
+
|
|
63
|
+
def add_recent_profile(self, profile_name: str) -> None:
|
|
64
|
+
"""Add a profile to recent list (moves to front if exists)."""
|
|
65
|
+
if profile_name in self.recent_profiles:
|
|
66
|
+
self.recent_profiles.remove(profile_name)
|
|
67
|
+
self.recent_profiles.insert(0, profile_name)
|
|
68
|
+
self.recent_profiles = self.recent_profiles[:self.max_recent]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SettingsManager:
|
|
72
|
+
"""
|
|
73
|
+
Manages loading and saving application settings.
|
|
74
|
+
|
|
75
|
+
Usage:
|
|
76
|
+
manager = SettingsManager()
|
|
77
|
+
settings = manager.settings
|
|
78
|
+
|
|
79
|
+
# Modify settings
|
|
80
|
+
settings.theme_name = "dracula"
|
|
81
|
+
settings.font_size = 16
|
|
82
|
+
|
|
83
|
+
# Save
|
|
84
|
+
manager.save()
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, config_path: Path = None):
|
|
88
|
+
self._config_path = config_path or DEFAULT_CONFIG_FILE
|
|
89
|
+
self._settings: Optional[AppSettings] = None
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def settings(self) -> AppSettings:
|
|
93
|
+
"""Get current settings, loading from disk if needed."""
|
|
94
|
+
if self._settings is None:
|
|
95
|
+
self._settings = self.load()
|
|
96
|
+
return self._settings
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def config_dir(self) -> Path:
|
|
100
|
+
"""Get the config directory path."""
|
|
101
|
+
return self._config_path.parent
|
|
102
|
+
|
|
103
|
+
def load(self) -> AppSettings:
|
|
104
|
+
"""Load settings from disk, or return defaults."""
|
|
105
|
+
if self._config_path.exists():
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(self._config_path.read_text())
|
|
108
|
+
logger.debug(f"Loaded settings from {self._config_path}")
|
|
109
|
+
return AppSettings.from_dict(data)
|
|
110
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
111
|
+
logger.warning(f"Failed to load settings: {e}, using defaults")
|
|
112
|
+
return AppSettings()
|
|
113
|
+
else:
|
|
114
|
+
logger.debug("No settings file found, using defaults")
|
|
115
|
+
return AppSettings()
|
|
116
|
+
|
|
117
|
+
def save(self) -> None:
|
|
118
|
+
"""Save current settings to disk."""
|
|
119
|
+
if self._settings is None:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# Ensure directory exists
|
|
123
|
+
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
self._config_path.write_text(
|
|
127
|
+
json.dumps(self._settings.to_dict(), indent=2)
|
|
128
|
+
)
|
|
129
|
+
logger.debug(f"Saved settings to {self._config_path}")
|
|
130
|
+
except OSError as e:
|
|
131
|
+
logger.error(f"Failed to save settings: {e}")
|
|
132
|
+
|
|
133
|
+
def reset(self) -> AppSettings:
|
|
134
|
+
"""Reset to default settings (does not save automatically)."""
|
|
135
|
+
self._settings = AppSettings()
|
|
136
|
+
return self._settings
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Global instance for convenience
|
|
140
|
+
_manager: Optional[SettingsManager] = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_settings_manager() -> SettingsManager:
|
|
144
|
+
"""Get the global settings manager instance."""
|
|
145
|
+
global _manager
|
|
146
|
+
if _manager is None:
|
|
147
|
+
_manager = SettingsManager()
|
|
148
|
+
return _manager
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_settings() -> AppSettings:
|
|
152
|
+
"""Convenience function to get current settings."""
|
|
153
|
+
return get_settings_manager().settings
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def save_settings() -> None:
|
|
157
|
+
"""Convenience function to save current settings."""
|
|
158
|
+
get_settings_manager().save()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connection profiles and authentication configuration.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .profile import (
|
|
6
|
+
AuthMethod,
|
|
7
|
+
AuthConfig,
|
|
8
|
+
JumpHostConfig,
|
|
9
|
+
ConnectionProfile,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AuthMethod",
|
|
14
|
+
"AuthConfig",
|
|
15
|
+
"JumpHostConfig",
|
|
16
|
+
"ConnectionProfile",
|
|
17
|
+
]
|