xshell-like 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.
- xshell/__init__.py +3 -0
- xshell/__main__.py +22 -0
- xshell/core/__init__.py +1 -0
- xshell/core/batch.py +59 -0
- xshell/core/keydeploy.py +127 -0
- xshell/core/serial_transport.py +98 -0
- xshell/core/session.py +199 -0
- xshell/core/sftp.py +123 -0
- xshell/core/ssh_transport.py +125 -0
- xshell/core/storage.py +135 -0
- xshell/core/terminal.py +164 -0
- xshell/core/transport.py +64 -0
- xshell/py.typed +0 -0
- xshell/ui/__init__.py +1 -0
- xshell/ui/dialogs.py +313 -0
- xshell/ui/main_window.py +404 -0
- xshell/ui/session_tree.py +95 -0
- xshell/ui/sftp_dialog.py +160 -0
- xshell/ui/terminal_widget.py +437 -0
- xshell_like-0.1.0.dist-info/METADATA +109 -0
- xshell_like-0.1.0.dist-info/RECORD +25 -0
- xshell_like-0.1.0.dist-info/WHEEL +5 -0
- xshell_like-0.1.0.dist-info/entry_points.txt +2 -0
- xshell_like-0.1.0.dist-info/licenses/LICENSE +21 -0
- xshell_like-0.1.0.dist-info/top_level.txt +1 -0
xshell/__init__.py
ADDED
xshell/__main__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Entry point: python -m xshell (GUI; use QT_QPA_PLATFORM=offscreen for headless)."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def main() -> int:
|
|
6
|
+
from PySide6.QtWidgets import QApplication
|
|
7
|
+
|
|
8
|
+
from xshell.core.storage import SessionStore
|
|
9
|
+
from xshell.ui.main_window import MainWindow
|
|
10
|
+
|
|
11
|
+
app = QApplication(sys.argv)
|
|
12
|
+
app.setApplicationName("xshell-like")
|
|
13
|
+
app.setOrganizationName("xshell-like")
|
|
14
|
+
|
|
15
|
+
store = SessionStore()
|
|
16
|
+
window = MainWindow(store=store)
|
|
17
|
+
window.show()
|
|
18
|
+
return app.exec()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
sys.exit(main())
|
xshell/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core layer: pure Python, no Qt dependency (fully headless-testable)."""
|
xshell/core/batch.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Batch orchestration: connect many sessions at once, isolate failures."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Callable, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from .session import SessionConfig
|
|
8
|
+
from .transport import TransportBase, default_transport_factory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class BatchResult:
|
|
13
|
+
opened: List[Tuple[SessionConfig, TransportBase]] = field(default_factory=list)
|
|
14
|
+
failed: List[Tuple[SessionConfig, str]] = field(default_factory=list)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def ok_count(self) -> int:
|
|
18
|
+
return len(self.opened)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def fail_count(self) -> int:
|
|
22
|
+
return len(self.failed)
|
|
23
|
+
|
|
24
|
+
def summary(self) -> str:
|
|
25
|
+
lines = [f"connected {self.ok_count}, failed {self.fail_count}"]
|
|
26
|
+
for cfg, err in self.failed:
|
|
27
|
+
lines.append(f" ✗ {cfg.display_name()}: {err}")
|
|
28
|
+
return "\n".join(lines)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BatchManager:
|
|
32
|
+
"""Opens one transport per config; a bad session never blocks the rest."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, transport_factory: Optional[Callable] = None):
|
|
35
|
+
self._factory = transport_factory or default_transport_factory
|
|
36
|
+
|
|
37
|
+
def connect_one(self, config: SessionConfig, **kwargs) -> TransportBase:
|
|
38
|
+
transport = self._factory(config, **kwargs)
|
|
39
|
+
transport.open()
|
|
40
|
+
return transport
|
|
41
|
+
|
|
42
|
+
def connect_all(self, configs: List[SessionConfig], **kwargs) -> BatchResult:
|
|
43
|
+
result = BatchResult()
|
|
44
|
+
for config in configs:
|
|
45
|
+
try:
|
|
46
|
+
transport = self.connect_one(config, **kwargs)
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
result.failed.append((config, str(exc)))
|
|
49
|
+
continue
|
|
50
|
+
result.opened.append((config, transport))
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
def close_all(self, transports: List[TransportBase]) -> None:
|
|
54
|
+
for transport in transports:
|
|
55
|
+
try:
|
|
56
|
+
if transport.is_open:
|
|
57
|
+
transport.close()
|
|
58
|
+
except Exception:
|
|
59
|
+
pass # closing must never throw back into the UI
|
xshell/core/keydeploy.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Deploy the local public key to a remote host (passwordless setup).
|
|
2
|
+
|
|
3
|
+
Equivalent of `ssh-copy-id`, implemented over paramiko so it also works on
|
|
4
|
+
Windows: connect with the credentials the session already has (password,
|
|
5
|
+
existing key, or agent), then run the append through the remote shell.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import paramiko
|
|
16
|
+
|
|
17
|
+
from .session import SessionConfig
|
|
18
|
+
|
|
19
|
+
_PUBKEY_RE = re.compile(r"^(ssh-(rsa|dss|ed25519)|ecdsa-[a-z0-9-]+) [A-Za-z0-9+/=]+[ @]")
|
|
20
|
+
_CHECK_CMD = "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat ~/.ssh/authorized_keys 2>/dev/null || true"
|
|
21
|
+
_APPEND_CMD = "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo {key} >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class DeployResult:
|
|
26
|
+
ok: bool
|
|
27
|
+
already_present: bool
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_local_public_key(ssh_dir: Path = None) -> tuple | None:
|
|
32
|
+
"""Find an existing default keypair. Returns (private_path, pubkey_text)."""
|
|
33
|
+
ssh_dir = Path(ssh_dir) if ssh_dir else Path.home() / ".ssh"
|
|
34
|
+
for name in ("id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"):
|
|
35
|
+
priv = ssh_dir / name
|
|
36
|
+
pub = ssh_dir / (name + ".pub")
|
|
37
|
+
if pub.exists():
|
|
38
|
+
return str(priv), pub.read_text(encoding="utf-8").strip()
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ensure_local_keypair(ssh_dir: Path = None) -> tuple:
|
|
43
|
+
"""Find or generate a default keypair (RSA 3072, no passphrase).
|
|
44
|
+
Returns (private_path, pubkey_text)."""
|
|
45
|
+
existing = find_local_public_key(ssh_dir)
|
|
46
|
+
if existing:
|
|
47
|
+
return existing
|
|
48
|
+
|
|
49
|
+
ssh_dir = Path(ssh_dir) if ssh_dir else Path.home() / ".ssh"
|
|
50
|
+
ssh_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
key = paramiko.RSAKey.generate(3072)
|
|
52
|
+
priv_path = ssh_dir / "id_rsa"
|
|
53
|
+
key.write_private_key_file(str(priv_path))
|
|
54
|
+
os.chmod(priv_path, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
55
|
+
pub_path = ssh_dir / "id_rsa.pub"
|
|
56
|
+
pub_path.write_text(f"{key.get_name()} {key.get_base64()} xshell-like\n", encoding="utf-8")
|
|
57
|
+
return str(priv_path), pub_path.read_text(encoding="utf-8").strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run(client: paramiko.SSHClient, command: str) -> str:
|
|
61
|
+
_, stdout, _ = client.exec_command(command, timeout=15)
|
|
62
|
+
output = stdout.read().decode("utf-8", errors="replace")
|
|
63
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
64
|
+
if exit_code != 0:
|
|
65
|
+
raise RuntimeError(f"远端命令失败(退出码 {exit_code}): {command[:60]}…")
|
|
66
|
+
return output
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def deploy_public_key(config: SessionConfig, ssh_dir: Path = None) -> DeployResult:
|
|
70
|
+
"""Append the local public key to the remote authorized_keys.
|
|
71
|
+
|
|
72
|
+
Uses the session's own credentials for the first hop: a stored password,
|
|
73
|
+
an explicit key file, or the agent/default keys.
|
|
74
|
+
"""
|
|
75
|
+
priv_path, pub_text = ensure_local_keypair(ssh_dir)
|
|
76
|
+
if not _PUBKEY_RE.match(pub_text):
|
|
77
|
+
return DeployResult(False, False, f"本机公钥格式异常,拒绝部署: {pub_text[:40]}…")
|
|
78
|
+
|
|
79
|
+
cfg = config
|
|
80
|
+
client = paramiko.SSHClient()
|
|
81
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
82
|
+
kwargs = dict(
|
|
83
|
+
hostname=cfg.host,
|
|
84
|
+
port=int(cfg.port),
|
|
85
|
+
username=cfg.username or None,
|
|
86
|
+
timeout=10,
|
|
87
|
+
banner_timeout=10,
|
|
88
|
+
allow_agent=False,
|
|
89
|
+
look_for_keys=False,
|
|
90
|
+
)
|
|
91
|
+
if cfg.auth_method == "key" and cfg.key_path:
|
|
92
|
+
kwargs["key_filename"] = cfg.key_path
|
|
93
|
+
kwargs["allow_agent"] = True
|
|
94
|
+
elif cfg.auth_method == "auto":
|
|
95
|
+
kwargs["allow_agent"] = True
|
|
96
|
+
kwargs["look_for_keys"] = True
|
|
97
|
+
elif cfg.password:
|
|
98
|
+
kwargs["password"] = cfg.password
|
|
99
|
+
elif cfg.auth_method == "key" or cfg.auth_method == "auto":
|
|
100
|
+
kwargs["allow_agent"] = True
|
|
101
|
+
kwargs["look_for_keys"] = True
|
|
102
|
+
else: # password auth but no password stored — caller must prompt first
|
|
103
|
+
return DeployResult(
|
|
104
|
+
False, False, "该会话没有可用凭据:请先在会话里保存密码,或已配置免密/agent"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
client.connect(**kwargs)
|
|
109
|
+
except paramiko.AuthenticationException:
|
|
110
|
+
return DeployResult(False, False, "认证失败:密码错误且无可用密钥")
|
|
111
|
+
except (paramiko.SSHException, OSError) as exc:
|
|
112
|
+
return DeployResult(False, False, f"连接失败: {exc}")
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
existing = _run(client, _CHECK_CMD)
|
|
116
|
+
if pub_text in existing:
|
|
117
|
+
return DeployResult(True, True, "公钥已存在,无需重复部署")
|
|
118
|
+
append_cmd = _APPEND_CMD.format(key="'" + pub_text + "'")
|
|
119
|
+
_run(client, append_cmd)
|
|
120
|
+
confirm = _run(client, _CHECK_CMD)
|
|
121
|
+
if pub_text not in confirm:
|
|
122
|
+
return DeployResult(False, False, "部署后校验失败:远端 authorized_keys 未见公钥")
|
|
123
|
+
return DeployResult(True, False, f"已部署公钥 {Path(priv_path).name} → {cfg.summary()}")
|
|
124
|
+
except (paramiko.SSHException, OSError, RuntimeError) as exc:
|
|
125
|
+
return DeployResult(False, False, f"部署失败: {exc}")
|
|
126
|
+
finally:
|
|
127
|
+
client.close()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Serial transport on top of pyserial (device files / USB adapters)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
import serial
|
|
7
|
+
|
|
8
|
+
from .session import SessionConfig
|
|
9
|
+
from .transport import TransportBase
|
|
10
|
+
|
|
11
|
+
READ_TIMEOUT = 0.2 # seconds — read() returns up to this early
|
|
12
|
+
WRITE_TIMEOUT = 10 # seconds — how long a blocked write may hold the caller
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SerialTransport(TransportBase):
|
|
16
|
+
def __init__(self, config: SessionConfig, **kwargs):
|
|
17
|
+
super().__init__(config, **kwargs)
|
|
18
|
+
self._port: serial.Serial | None = None
|
|
19
|
+
self._reader: threading.Thread | None = None
|
|
20
|
+
self._stop = threading.Event()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_open(self) -> bool:
|
|
24
|
+
return (
|
|
25
|
+
self._port is not None
|
|
26
|
+
and self._port.is_open
|
|
27
|
+
and not self._stop.is_set()
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def open(self) -> None:
|
|
31
|
+
cfg = self.config
|
|
32
|
+
try:
|
|
33
|
+
port = serial.Serial(
|
|
34
|
+
port=cfg.device,
|
|
35
|
+
baudrate=int(cfg.baudrate),
|
|
36
|
+
bytesize=int(cfg.bytesize),
|
|
37
|
+
parity=cfg.parity,
|
|
38
|
+
stopbits=int(cfg.stopbits),
|
|
39
|
+
timeout=READ_TIMEOUT,
|
|
40
|
+
write_timeout=WRITE_TIMEOUT,
|
|
41
|
+
)
|
|
42
|
+
except (serial.SerialException, OSError, ValueError) as exc:
|
|
43
|
+
raise ConnectionError(f"cannot open {cfg.device}: {exc}") from exc
|
|
44
|
+
|
|
45
|
+
self._port = port
|
|
46
|
+
self._stop.clear()
|
|
47
|
+
self._reader = threading.Thread(
|
|
48
|
+
target=self._read_loop, name=f"serial-read-{cfg.name}", daemon=True
|
|
49
|
+
)
|
|
50
|
+
self._reader.start()
|
|
51
|
+
self._emit("connected", cfg.summary())
|
|
52
|
+
|
|
53
|
+
def _read_loop(self) -> None:
|
|
54
|
+
port = self._port
|
|
55
|
+
try:
|
|
56
|
+
while not self._stop.is_set():
|
|
57
|
+
try:
|
|
58
|
+
chunk = port.read(4096)
|
|
59
|
+
except (serial.SerialException, OSError, TypeError):
|
|
60
|
+
if self._stop.is_set():
|
|
61
|
+
break
|
|
62
|
+
self._emit("error", "serial port read failed")
|
|
63
|
+
break
|
|
64
|
+
if chunk:
|
|
65
|
+
cb = self.on_data
|
|
66
|
+
if cb:
|
|
67
|
+
cb(chunk)
|
|
68
|
+
if not self._stop.is_set():
|
|
69
|
+
self._emit("disconnected", "device disappeared")
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
if not self._stop.is_set():
|
|
72
|
+
self._emit("error", f"reader thread: {exc}")
|
|
73
|
+
finally:
|
|
74
|
+
self._stop.set()
|
|
75
|
+
|
|
76
|
+
def write(self, data: bytes) -> None:
|
|
77
|
+
if self.is_open:
|
|
78
|
+
try:
|
|
79
|
+
self._port.write(data)
|
|
80
|
+
if self.config.local_echo and self.on_data:
|
|
81
|
+
self.on_data(data)
|
|
82
|
+
except (serial.SerialException, OSError) as exc:
|
|
83
|
+
self._emit("error", f"write failed: {exc}")
|
|
84
|
+
|
|
85
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
86
|
+
self.cols, self.rows = cols, rows # no pty geometry on a serial line
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
self._stop.set()
|
|
90
|
+
port, self._port = self._port, None
|
|
91
|
+
if port is not None:
|
|
92
|
+
try:
|
|
93
|
+
port.close()
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
if self._reader and self._reader.is_alive():
|
|
97
|
+
self._reader.join(timeout=1.0)
|
|
98
|
+
self._emit("disconnected", "")
|
xshell/core/session.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Session data model: SSH / serial connection config with persistence support."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
|
|
10
|
+
TYPE_SSH = "ssh"
|
|
11
|
+
TYPE_SERIAL = "serial"
|
|
12
|
+
|
|
13
|
+
TYPE_BADGE = {TYPE_SSH: "[SSH]", TYPE_SERIAL: "[串口]"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_ssh_target(text: str) -> SessionConfig:
|
|
17
|
+
"""Parse a quick-connect string into a session config.
|
|
18
|
+
|
|
19
|
+
Accepted forms (leading 'ssh ' optional):
|
|
20
|
+
ts@10.0.28.34 user@host (passwordless key/agent auth)
|
|
21
|
+
ts@10.0.28.34:2222 user@host:port
|
|
22
|
+
ssh -p 2222 ts@10.0.28.34 (-p flag, before or after target)
|
|
23
|
+
"""
|
|
24
|
+
raw = (text or "").strip()
|
|
25
|
+
if raw.lower().startswith("ssh "):
|
|
26
|
+
raw = raw[4:].strip()
|
|
27
|
+
if not raw or raw.lower() == "ssh":
|
|
28
|
+
raise ValueError("请输入连接目标,如 ssh ts@10.0.28.34")
|
|
29
|
+
|
|
30
|
+
port = 22
|
|
31
|
+
tokens = raw.split()
|
|
32
|
+
# pull out an optional -p <port> / -p<port> flag wherever it appears
|
|
33
|
+
i = 0
|
|
34
|
+
targets: list = []
|
|
35
|
+
while i < len(tokens):
|
|
36
|
+
tok = tokens[i]
|
|
37
|
+
if tok == "-p":
|
|
38
|
+
if i + 1 >= len(tokens):
|
|
39
|
+
raise ValueError("-p 缺少端口号")
|
|
40
|
+
port = int(tokens[i + 1])
|
|
41
|
+
i += 2
|
|
42
|
+
continue
|
|
43
|
+
if tok.startswith("-p") and tok != "-p" and tok[2:].isdigit():
|
|
44
|
+
port = int(tok[2:])
|
|
45
|
+
i += 1
|
|
46
|
+
continue
|
|
47
|
+
if tok.startswith("-"):
|
|
48
|
+
raise ValueError(f"暂不支持参数: {tok}")
|
|
49
|
+
targets.append(tok)
|
|
50
|
+
i += 1
|
|
51
|
+
if not targets:
|
|
52
|
+
raise ValueError("缺少主机地址,如 ssh ts@10.0.28.34")
|
|
53
|
+
if len(targets) > 1:
|
|
54
|
+
raise ValueError(f"一次只能连接一个目标,收到: {' '.join(targets)}")
|
|
55
|
+
|
|
56
|
+
target = targets[0]
|
|
57
|
+
if ":" in target:
|
|
58
|
+
target, _, port_part = target.rpartition(":")
|
|
59
|
+
if not port_part.isdigit():
|
|
60
|
+
raise ValueError(f"端口不合法: {port_part}")
|
|
61
|
+
port = int(port_part)
|
|
62
|
+
target = target.strip("[]") # bare [ipv6]:port
|
|
63
|
+
if "@" in target:
|
|
64
|
+
username, _, host = target.partition("@")
|
|
65
|
+
else:
|
|
66
|
+
username, host = "", target
|
|
67
|
+
if not host:
|
|
68
|
+
raise ValueError("缺少主机地址")
|
|
69
|
+
if not username:
|
|
70
|
+
import getpass
|
|
71
|
+
|
|
72
|
+
username = getpass.getuser()
|
|
73
|
+
|
|
74
|
+
return SessionConfig(
|
|
75
|
+
name=f"{username}@{host}" if port == 22 else f"{username}@{host}:{port}",
|
|
76
|
+
type=TYPE_SSH,
|
|
77
|
+
host=host,
|
|
78
|
+
port=port,
|
|
79
|
+
username=username,
|
|
80
|
+
auth_method="auto", # passwordless first: agent + default keys
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _encode_secret(plain: str) -> str:
|
|
85
|
+
"""Light obfuscation (base64) — not encryption; use key auth for real security."""
|
|
86
|
+
return base64.b64encode(plain.encode("utf-8")).decode("ascii") if plain else ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _decode_secret(stored: str) -> str:
|
|
90
|
+
try:
|
|
91
|
+
return base64.b64decode(stored.encode("ascii")).decode("utf-8") if stored else ""
|
|
92
|
+
except (ValueError, UnicodeDecodeError):
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class SessionConfig:
|
|
98
|
+
"""One saved connection (SSH or serial). All fields JSON-serializable."""
|
|
99
|
+
|
|
100
|
+
name: str = ""
|
|
101
|
+
type: str = TYPE_SSH
|
|
102
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
103
|
+
|
|
104
|
+
# --- SSH specific ---
|
|
105
|
+
host: str = ""
|
|
106
|
+
port: int = 22
|
|
107
|
+
username: str = ""
|
|
108
|
+
auth_method: str = "password" # "password" | "key"
|
|
109
|
+
password_enc: str = "" # base64-obfuscated password, never plain in JSON
|
|
110
|
+
key_path: str = ""
|
|
111
|
+
|
|
112
|
+
# --- Serial specific ---
|
|
113
|
+
device: str = "/dev/ttyUSB0"
|
|
114
|
+
baudrate: int = 115200
|
|
115
|
+
bytesize: int = 8
|
|
116
|
+
parity: str = "N" # N / E / O
|
|
117
|
+
stopbits: int = 1
|
|
118
|
+
|
|
119
|
+
# --- Common ---
|
|
120
|
+
encoding: str = "utf-8"
|
|
121
|
+
local_echo: bool = False # serial devices often need it; SSH remote echoes
|
|
122
|
+
group: str = ""
|
|
123
|
+
created_at: float = field(default_factory=time.time)
|
|
124
|
+
last_used: float = 0.0
|
|
125
|
+
|
|
126
|
+
# ---------- persistence ----------
|
|
127
|
+
|
|
128
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
129
|
+
d = asdict(self)
|
|
130
|
+
return d
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_dict(cls, d: Dict[str, Any]) -> "SessionConfig":
|
|
134
|
+
known = {f.name for f in fields(cls)}
|
|
135
|
+
filtered = {k: v for k, v in d.items() if k in known}
|
|
136
|
+
# tolerate hand-edited files: coerce types, drop fields that stay garbage
|
|
137
|
+
type_hints = {f.name: f.type for f in fields(cls)}
|
|
138
|
+
for k, v in list(filtered.items()):
|
|
139
|
+
hint = str(type_hints.get(k, ""))
|
|
140
|
+
try:
|
|
141
|
+
if "bool" in hint:
|
|
142
|
+
filtered[k] = bool(v)
|
|
143
|
+
elif "int" in hint:
|
|
144
|
+
filtered[k] = int(v)
|
|
145
|
+
elif "float" in hint:
|
|
146
|
+
filtered[k] = float(v)
|
|
147
|
+
elif "str" in hint:
|
|
148
|
+
filtered[k] = "" if v is None else str(v)
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
del filtered[k] # fall back to the dataclass default
|
|
151
|
+
stype = filtered.get("type", TYPE_SSH)
|
|
152
|
+
if stype not in (TYPE_SSH, TYPE_SERIAL):
|
|
153
|
+
raise ValueError(f"unknown session type: {stype!r}")
|
|
154
|
+
return cls(**filtered)
|
|
155
|
+
|
|
156
|
+
# ---------- duplication avoidance (R6) ----------
|
|
157
|
+
|
|
158
|
+
def same_identity(self, other: "SessionConfig") -> bool:
|
|
159
|
+
"""Two sessions are 'the same connection' if their identity matches."""
|
|
160
|
+
if self is other:
|
|
161
|
+
return True
|
|
162
|
+
if self.type != other.type:
|
|
163
|
+
return False
|
|
164
|
+
if self.type == TYPE_SSH:
|
|
165
|
+
return (
|
|
166
|
+
self.host == other.host
|
|
167
|
+
and int(self.port) == int(other.port)
|
|
168
|
+
and self.username == other.username
|
|
169
|
+
)
|
|
170
|
+
return self.device == other.device and int(self.baudrate) == int(other.baudrate)
|
|
171
|
+
|
|
172
|
+
# ---------- convenience ----------
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def password(self) -> str:
|
|
176
|
+
return _decode_secret(self.password_enc)
|
|
177
|
+
|
|
178
|
+
@password.setter
|
|
179
|
+
def password(self, plain: str) -> None:
|
|
180
|
+
self.password_enc = _encode_secret(plain)
|
|
181
|
+
|
|
182
|
+
def display_name(self) -> str:
|
|
183
|
+
if self.type == TYPE_SSH:
|
|
184
|
+
return f"{self.name or self.host}"
|
|
185
|
+
return f"{self.name or self.device}"
|
|
186
|
+
|
|
187
|
+
def summary(self) -> str:
|
|
188
|
+
if self.type == TYPE_SSH:
|
|
189
|
+
return f"ssh://{self.username}@{self.host}:{self.port}"
|
|
190
|
+
return f"{self.device}@{self.baudrate} {self.parity}{self.bytesize}{self.stopbits}"
|
|
191
|
+
|
|
192
|
+
def clone(self, new_name: str | None = None) -> "SessionConfig":
|
|
193
|
+
"""Copy with a fresh id — batch-creating similar devices only edits host/IP."""
|
|
194
|
+
data = self.to_dict()
|
|
195
|
+
data["id"] = uuid.uuid4().hex
|
|
196
|
+
data["name"] = new_name if new_name is not None else f"{self.name} copy"
|
|
197
|
+
data["created_at"] = time.time()
|
|
198
|
+
data["last_used"] = 0.0
|
|
199
|
+
return SessionConfig.from_dict(data)
|
xshell/core/sftp.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""SFTP file transfer over an established SSH connection (paramiko SFTPClient)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import posixpath
|
|
6
|
+
import stat as statmod
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Callable, List, Optional
|
|
10
|
+
|
|
11
|
+
import paramiko
|
|
12
|
+
|
|
13
|
+
CHUNK = 32768
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FileEntry:
|
|
18
|
+
name: str
|
|
19
|
+
path: str
|
|
20
|
+
size: int
|
|
21
|
+
is_dir: bool
|
|
22
|
+
mtime: float
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def size_human(self) -> str:
|
|
26
|
+
if self.is_dir:
|
|
27
|
+
return "<dir>"
|
|
28
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
29
|
+
if self.size < 1024 or unit == "GB":
|
|
30
|
+
return f"{self.size:.0f}{unit}" if unit == "B" else f"{self.size / 1:.1f}{unit}"
|
|
31
|
+
self.size /= 1024
|
|
32
|
+
return f"{self.size}B"
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def mtime_human(self) -> str:
|
|
36
|
+
return datetime.fromtimestamp(self.mtime).strftime("%Y-%m-%d %H:%M")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SftpClient:
|
|
40
|
+
"""Reuses the SSH connection of an open SshTransport."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, ssh_transport) -> None:
|
|
43
|
+
client = getattr(ssh_transport, "_client", None)
|
|
44
|
+
if client is None:
|
|
45
|
+
raise ConnectionError("SSH transport is not open")
|
|
46
|
+
self._sftp: paramiko.SFTPClient = client.open_sftp()
|
|
47
|
+
|
|
48
|
+
# ---------- browsing ----------
|
|
49
|
+
|
|
50
|
+
def listdir(self, path: str = ".") -> List[FileEntry]:
|
|
51
|
+
entries: List[FileEntry] = []
|
|
52
|
+
for attr in self._sftp.listdir_attr(path):
|
|
53
|
+
full = posixpath.join(path, attr.filename)
|
|
54
|
+
entries.append(
|
|
55
|
+
FileEntry(
|
|
56
|
+
name=attr.filename,
|
|
57
|
+
path=full,
|
|
58
|
+
size=attr.st_size or 0,
|
|
59
|
+
is_dir=statmod.S_ISDIR(attr.st_mode or 0),
|
|
60
|
+
mtime=attr.st_mtime or 0,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
dirs = [e for e in entries if e.is_dir]
|
|
64
|
+
files = [e for e in entries if not e.is_dir]
|
|
65
|
+
return sorted(dirs, key=lambda e: e.name.lower()) + sorted(
|
|
66
|
+
files, key=lambda e: e.name.lower()
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def join(path: str, name: str) -> str:
|
|
71
|
+
return posixpath.join(path, name)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def parent(path: str) -> str:
|
|
75
|
+
parent = posixpath.dirname(path.rstrip("/"))
|
|
76
|
+
return parent or "/"
|
|
77
|
+
|
|
78
|
+
# ---------- transfer ----------
|
|
79
|
+
|
|
80
|
+
def upload(self, local_path: str, remote_path: str,
|
|
81
|
+
progress: Optional[Callable[[int, int], None]] = None) -> None:
|
|
82
|
+
total = os.path.getsize(local_path)
|
|
83
|
+
done = 0
|
|
84
|
+
with open(local_path, "rb") as lf, self._sftp.open(remote_path, "wb") as rf:
|
|
85
|
+
while True:
|
|
86
|
+
chunk = lf.read(CHUNK)
|
|
87
|
+
if not chunk:
|
|
88
|
+
break
|
|
89
|
+
rf.write(chunk)
|
|
90
|
+
done += len(chunk)
|
|
91
|
+
if progress:
|
|
92
|
+
progress(done, total)
|
|
93
|
+
|
|
94
|
+
def download(self, remote_path: str, local_path: str,
|
|
95
|
+
progress: Optional[Callable[[int, int], None]] = None) -> None:
|
|
96
|
+
total = self._sftp.stat(remote_path).st_size
|
|
97
|
+
done = 0
|
|
98
|
+
with self._sftp.open(remote_path, "rb") as rf, open(local_path, "wb") as lf:
|
|
99
|
+
while True:
|
|
100
|
+
chunk = rf.read(CHUNK)
|
|
101
|
+
if not chunk:
|
|
102
|
+
break
|
|
103
|
+
lf.write(chunk)
|
|
104
|
+
done += len(chunk)
|
|
105
|
+
if progress:
|
|
106
|
+
progress(done, total)
|
|
107
|
+
|
|
108
|
+
# ---------- maintenance ----------
|
|
109
|
+
|
|
110
|
+
def mkdir(self, remote_path: str) -> None:
|
|
111
|
+
self._sftp.mkdir(remote_path)
|
|
112
|
+
|
|
113
|
+
def remove_file(self, remote_path: str) -> None:
|
|
114
|
+
self._sftp.remove(remote_path)
|
|
115
|
+
|
|
116
|
+
def remove_dir(self, remote_path: str) -> None:
|
|
117
|
+
self._sftp.rmdir(remote_path)
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
try:
|
|
121
|
+
self._sftp.close()
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|