xshell-like 0.1.0__tar.gz
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_like-0.1.0/LICENSE +21 -0
- xshell_like-0.1.0/PKG-INFO +109 -0
- xshell_like-0.1.0/README.md +84 -0
- xshell_like-0.1.0/pyproject.toml +41 -0
- xshell_like-0.1.0/setup.cfg +4 -0
- xshell_like-0.1.0/tests/test_batch.py +82 -0
- xshell_like-0.1.0/tests/test_gui_smoke.py +236 -0
- xshell_like-0.1.0/tests/test_keydeploy.py +84 -0
- xshell_like-0.1.0/tests/test_serial_transport.py +115 -0
- xshell_like-0.1.0/tests/test_session_storage.py +186 -0
- xshell_like-0.1.0/tests/test_ssh_transport.py +176 -0
- xshell_like-0.1.0/tests/test_terminal.py +141 -0
- xshell_like-0.1.0/tests/test_terminal_widget.py +505 -0
- xshell_like-0.1.0/xshell/__init__.py +3 -0
- xshell_like-0.1.0/xshell/__main__.py +22 -0
- xshell_like-0.1.0/xshell/core/__init__.py +1 -0
- xshell_like-0.1.0/xshell/core/batch.py +59 -0
- xshell_like-0.1.0/xshell/core/keydeploy.py +127 -0
- xshell_like-0.1.0/xshell/core/serial_transport.py +98 -0
- xshell_like-0.1.0/xshell/core/session.py +199 -0
- xshell_like-0.1.0/xshell/core/sftp.py +123 -0
- xshell_like-0.1.0/xshell/core/ssh_transport.py +125 -0
- xshell_like-0.1.0/xshell/core/storage.py +135 -0
- xshell_like-0.1.0/xshell/core/terminal.py +164 -0
- xshell_like-0.1.0/xshell/core/transport.py +64 -0
- xshell_like-0.1.0/xshell/py.typed +0 -0
- xshell_like-0.1.0/xshell/ui/__init__.py +1 -0
- xshell_like-0.1.0/xshell/ui/dialogs.py +313 -0
- xshell_like-0.1.0/xshell/ui/main_window.py +404 -0
- xshell_like-0.1.0/xshell/ui/session_tree.py +95 -0
- xshell_like-0.1.0/xshell/ui/sftp_dialog.py +160 -0
- xshell_like-0.1.0/xshell/ui/terminal_widget.py +437 -0
- xshell_like-0.1.0/xshell_like.egg-info/PKG-INFO +109 -0
- xshell_like-0.1.0/xshell_like.egg-info/SOURCES.txt +36 -0
- xshell_like-0.1.0/xshell_like.egg-info/dependency_links.txt +1 -0
- xshell_like-0.1.0/xshell_like.egg-info/entry_points.txt +2 -0
- xshell_like-0.1.0/xshell_like.egg-info/requires.txt +4 -0
- xshell_like-0.1.0/xshell_like.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 weixin_53700778
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xshell-like
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 类 Xshell 终端工具:SSH/串口、批量连接、多页签、会话管理、SFTP、免密部署
|
|
5
|
+
Author: weixin_53700778
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://gitcode.com/weixin_53700778/xshell
|
|
8
|
+
Keywords: ssh,serial,terminal,xshell,tabs,sftp
|
|
9
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Topic :: Terminals :: Terminal Emulators/X Terminals
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: PySide6>=6.5
|
|
21
|
+
Requires-Dist: paramiko>=2.9
|
|
22
|
+
Requires-Dist: pyserial>=3.5
|
|
23
|
+
Requires-Dist: pyte>=0.8
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# XShell-Like 终端工具
|
|
27
|
+
|
|
28
|
+
一个用 Python/PySide6 实现的类 Xshell 桌面终端工具:**SSH + 串口双支持、批量连接、多页签、会话持久化、SFTP 文件传输**。
|
|
29
|
+
|
|
30
|
+
> 架构设计详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
|
31
|
+
|
|
32
|
+
## 功能总览
|
|
33
|
+
|
|
34
|
+
| 功能 | 说明 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| SSH 终端 | paramiko,密码/密钥认证,keepalive,窗口尺寸同步(vim/top 不错位) |
|
|
37
|
+
| 串口终端 | pyserial,波特率/数据位/校验/停止位可配,可选本地回显 |
|
|
38
|
+
| 批量连接 | 左栏 Ctrl 多选 →「连接选中」,或「批量连接…」勾选列表,一次打开全部;单个失败不影响其他,结果汇总提示 |
|
|
39
|
+
| 批量关闭 | 页签逐个关、一键「关闭全部页签」,关窗自动全断 |
|
|
40
|
+
| 多页签 | QTabWidget,可关闭可拖动,双击列表项快速连接 |
|
|
41
|
+
| 会话管理 | 保存/编辑/克隆/删除;新建时**自动查重**(SSH 按 主机+端口+用户,串口按 设备+波特率)避免重复配置 |
|
|
42
|
+
| 文件共享 | 已连接 SSH 页签一键打开 SFTP 对话框:浏览/上传/下载/新建目录/删除,带进度条 |
|
|
43
|
+
| 广播输入 | 底部广播条回车,同一条命令发到所有已打开会话(调试多台设备利器) |
|
|
44
|
+
| 编码 | utf-8 / gbk / gb2312 / big5 / latin-1 按会话配置 |
|
|
45
|
+
| 光标 | 远端光标位置实时渲染块状光标;窗口聚焦时实心、失焦时变暗 |
|
|
46
|
+
| 缩放 | `Ctrl+=`/`Ctrl++` 放大、`Ctrl+-` 缩小、`Ctrl+0` 重置(视图菜单,全局生效并持久化);`Ctrl+鼠标滚轮` 当前页签缩放 |
|
|
47
|
+
| 字体配置 | 视图菜单 →「字体设置…」:等宽字体 + 字号,全局生效并持久化 |
|
|
48
|
+
| 快速连接 | 工具栏输入 `ssh ts@10.0.28.34`(也支持 `user@host:端口`、`-p 端口`)回车:自动创建会话并连接,重复输入自动复用已有配置 |
|
|
49
|
+
| 免密登录 | 认证方式选「免密 (默认密钥/Agent)」直接使用本机 `~/.ssh` 密钥或 ssh-agent;密码留空的会话也会先尝试免密 |
|
|
50
|
+
| 部署公钥 | 左栏右键会话 →「部署公钥 (免密登录)…」:把本机公钥写入远端 `authorized_keys`(等价 ssh-copy-id,纯 paramiko 实现 Windows 也可用;本机无密钥对时自动生成),成功后该会话自动切换为免密 |
|
|
51
|
+
|
|
52
|
+
## 安装与运行
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install -r requirements.txt # PySide6 / paramiko / pyserial / pyte
|
|
56
|
+
|
|
57
|
+
python -m xshell # 启动 GUI
|
|
58
|
+
QT_QPA_PLATFORM=offscreen python -m xshell # 无显示环境(调试用)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
会话数据保存在 `~/.config/xshell-tool/`(`sessions.json` + `settings.json`),原子写入,手工编辑后重启生效。
|
|
62
|
+
|
|
63
|
+
## 典型用法
|
|
64
|
+
|
|
65
|
+
1. **新建会话**:工具栏「新建会话」→ 选 SSH 或串口 → 填参数 → 保存。若已存在相同连接的配置会弹提示,直接复用左侧已有项即可。
|
|
66
|
+
2. **快速连接**:工具栏直接输入 `ssh ts@10.0.28.34` 回车——自动建会话、优先免密登录、批量结果同样生效。
|
|
67
|
+
3. **免密一步到位**:左栏右键目标会话 →「部署公钥 (免密登录)…」→ 输入一次密码 → 之后该会话永久免密。
|
|
68
|
+
4. **批量开工**:左栏 Ctrl/Ctrl+Shift 选中多台设备 →「连接选中」;或「批量连接…」勾选。结果框列出失败项和原因。
|
|
69
|
+
5. **批量操作**:所有页签打开后,在底部广播条输入 `show version` 回车 → 所有会话同时执行。
|
|
70
|
+
6. **传文件**:选中某个已连接的 SSH 页签 → 工具栏「SFTP」→ 双击进目录 / 上传 / 下载。
|
|
71
|
+
7. **克隆**:同型号设备只需「克隆」已有会话,改个 IP 保存。
|
|
72
|
+
|
|
73
|
+
## 测试
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python -m pytest # 全量(含真实 SSH 协议联测与 PTY 串口联测,零硬件依赖)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- SSH 联测:进程内 paramiko 服务器(banner + 回显 + 窗口尺寸变更)
|
|
80
|
+
- 串口联测:`os.openpty()` PTY 对回环
|
|
81
|
+
- GUI 冒烟:offscreen + 注入假传输层,零网络零硬件
|
|
82
|
+
|
|
83
|
+
## 打包发布(Linux / Windows)
|
|
84
|
+
|
|
85
|
+
使用 PyInstaller 打成免安装的独立目录(onedir,启动快、不易被杀软误报):
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Linux
|
|
89
|
+
./scripts/build_linux.sh # 产物: dist/xshell-like/xshell-like
|
|
90
|
+
|
|
91
|
+
# Windows(在 Windows 机器上,需已安装 Python)
|
|
92
|
+
scripts\build_windows.bat # 产物: dist\xshell-like\xshell-like.exe
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
要点:
|
|
96
|
+
- **必须在目标系统上构建**(PyInstaller 不支持交叉编译):Linux 产物在 Linux 构建,Windows 产物在 Windows 构建。
|
|
97
|
+
- 产物目录 `dist/xshell-like/` 整体拷贝即可分发;首次启动 Windows Defender 扫描可能拖慢,属正常现象。
|
|
98
|
+
- 排查打包后的问题时建议用源码方式运行(`python -m xshell`)看完整报错;GUI 版 `console=False` 不显示控制台。
|
|
99
|
+
- 自测:`QT_QPA_PLATFORM=offscreen timeout 6 ./dist/xshell-like/xshell-like`(退出码 124 = 常驻运行正常)。
|
|
100
|
+
|
|
101
|
+
## 安全说明
|
|
102
|
+
|
|
103
|
+
- SSH 密码在 `sessions.json` 中仅做 base64 轻混淆(防肩窥),**不是加密保险箱**。安全敏感环境请用密钥认证(`key` 方式),或密码留空每次手输。
|
|
104
|
+
|
|
105
|
+
## 已知限制(v1)
|
|
106
|
+
|
|
107
|
+
- 终端渲染为整屏 HTML 重绘(80×24 规模足够流畅),未做鼠标事件与 256 色以外的真彩优化;滚动缓冲已保存但 UI 暂未提供回滚浏览。
|
|
108
|
+
- SFTP 传输在 GUI 线程同步进行(每块刷新进度),超大文件会短暂阻塞界面。
|
|
109
|
+
- 未支持 zmodem、端口转发、宏录制(架构已预留扩展点,见设计文档 §2/§9)。
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# XShell-Like 终端工具
|
|
2
|
+
|
|
3
|
+
一个用 Python/PySide6 实现的类 Xshell 桌面终端工具:**SSH + 串口双支持、批量连接、多页签、会话持久化、SFTP 文件传输**。
|
|
4
|
+
|
|
5
|
+
> 架构设计详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
|
6
|
+
|
|
7
|
+
## 功能总览
|
|
8
|
+
|
|
9
|
+
| 功能 | 说明 |
|
|
10
|
+
|---|---|
|
|
11
|
+
| SSH 终端 | paramiko,密码/密钥认证,keepalive,窗口尺寸同步(vim/top 不错位) |
|
|
12
|
+
| 串口终端 | pyserial,波特率/数据位/校验/停止位可配,可选本地回显 |
|
|
13
|
+
| 批量连接 | 左栏 Ctrl 多选 →「连接选中」,或「批量连接…」勾选列表,一次打开全部;单个失败不影响其他,结果汇总提示 |
|
|
14
|
+
| 批量关闭 | 页签逐个关、一键「关闭全部页签」,关窗自动全断 |
|
|
15
|
+
| 多页签 | QTabWidget,可关闭可拖动,双击列表项快速连接 |
|
|
16
|
+
| 会话管理 | 保存/编辑/克隆/删除;新建时**自动查重**(SSH 按 主机+端口+用户,串口按 设备+波特率)避免重复配置 |
|
|
17
|
+
| 文件共享 | 已连接 SSH 页签一键打开 SFTP 对话框:浏览/上传/下载/新建目录/删除,带进度条 |
|
|
18
|
+
| 广播输入 | 底部广播条回车,同一条命令发到所有已打开会话(调试多台设备利器) |
|
|
19
|
+
| 编码 | utf-8 / gbk / gb2312 / big5 / latin-1 按会话配置 |
|
|
20
|
+
| 光标 | 远端光标位置实时渲染块状光标;窗口聚焦时实心、失焦时变暗 |
|
|
21
|
+
| 缩放 | `Ctrl+=`/`Ctrl++` 放大、`Ctrl+-` 缩小、`Ctrl+0` 重置(视图菜单,全局生效并持久化);`Ctrl+鼠标滚轮` 当前页签缩放 |
|
|
22
|
+
| 字体配置 | 视图菜单 →「字体设置…」:等宽字体 + 字号,全局生效并持久化 |
|
|
23
|
+
| 快速连接 | 工具栏输入 `ssh ts@10.0.28.34`(也支持 `user@host:端口`、`-p 端口`)回车:自动创建会话并连接,重复输入自动复用已有配置 |
|
|
24
|
+
| 免密登录 | 认证方式选「免密 (默认密钥/Agent)」直接使用本机 `~/.ssh` 密钥或 ssh-agent;密码留空的会话也会先尝试免密 |
|
|
25
|
+
| 部署公钥 | 左栏右键会话 →「部署公钥 (免密登录)…」:把本机公钥写入远端 `authorized_keys`(等价 ssh-copy-id,纯 paramiko 实现 Windows 也可用;本机无密钥对时自动生成),成功后该会话自动切换为免密 |
|
|
26
|
+
|
|
27
|
+
## 安装与运行
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install -r requirements.txt # PySide6 / paramiko / pyserial / pyte
|
|
31
|
+
|
|
32
|
+
python -m xshell # 启动 GUI
|
|
33
|
+
QT_QPA_PLATFORM=offscreen python -m xshell # 无显示环境(调试用)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
会话数据保存在 `~/.config/xshell-tool/`(`sessions.json` + `settings.json`),原子写入,手工编辑后重启生效。
|
|
37
|
+
|
|
38
|
+
## 典型用法
|
|
39
|
+
|
|
40
|
+
1. **新建会话**:工具栏「新建会话」→ 选 SSH 或串口 → 填参数 → 保存。若已存在相同连接的配置会弹提示,直接复用左侧已有项即可。
|
|
41
|
+
2. **快速连接**:工具栏直接输入 `ssh ts@10.0.28.34` 回车——自动建会话、优先免密登录、批量结果同样生效。
|
|
42
|
+
3. **免密一步到位**:左栏右键目标会话 →「部署公钥 (免密登录)…」→ 输入一次密码 → 之后该会话永久免密。
|
|
43
|
+
4. **批量开工**:左栏 Ctrl/Ctrl+Shift 选中多台设备 →「连接选中」;或「批量连接…」勾选。结果框列出失败项和原因。
|
|
44
|
+
5. **批量操作**:所有页签打开后,在底部广播条输入 `show version` 回车 → 所有会话同时执行。
|
|
45
|
+
6. **传文件**:选中某个已连接的 SSH 页签 → 工具栏「SFTP」→ 双击进目录 / 上传 / 下载。
|
|
46
|
+
7. **克隆**:同型号设备只需「克隆」已有会话,改个 IP 保存。
|
|
47
|
+
|
|
48
|
+
## 测试
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python -m pytest # 全量(含真实 SSH 协议联测与 PTY 串口联测,零硬件依赖)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- SSH 联测:进程内 paramiko 服务器(banner + 回显 + 窗口尺寸变更)
|
|
55
|
+
- 串口联测:`os.openpty()` PTY 对回环
|
|
56
|
+
- GUI 冒烟:offscreen + 注入假传输层,零网络零硬件
|
|
57
|
+
|
|
58
|
+
## 打包发布(Linux / Windows)
|
|
59
|
+
|
|
60
|
+
使用 PyInstaller 打成免安装的独立目录(onedir,启动快、不易被杀软误报):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Linux
|
|
64
|
+
./scripts/build_linux.sh # 产物: dist/xshell-like/xshell-like
|
|
65
|
+
|
|
66
|
+
# Windows(在 Windows 机器上,需已安装 Python)
|
|
67
|
+
scripts\build_windows.bat # 产物: dist\xshell-like\xshell-like.exe
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
要点:
|
|
71
|
+
- **必须在目标系统上构建**(PyInstaller 不支持交叉编译):Linux 产物在 Linux 构建,Windows 产物在 Windows 构建。
|
|
72
|
+
- 产物目录 `dist/xshell-like/` 整体拷贝即可分发;首次启动 Windows Defender 扫描可能拖慢,属正常现象。
|
|
73
|
+
- 排查打包后的问题时建议用源码方式运行(`python -m xshell`)看完整报错;GUI 版 `console=False` 不显示控制台。
|
|
74
|
+
- 自测:`QT_QPA_PLATFORM=offscreen timeout 6 ./dist/xshell-like/xshell-like`(退出码 124 = 常驻运行正常)。
|
|
75
|
+
|
|
76
|
+
## 安全说明
|
|
77
|
+
|
|
78
|
+
- SSH 密码在 `sessions.json` 中仅做 base64 轻混淆(防肩窥),**不是加密保险箱**。安全敏感环境请用密钥认证(`key` 方式),或密码留空每次手输。
|
|
79
|
+
|
|
80
|
+
## 已知限制(v1)
|
|
81
|
+
|
|
82
|
+
- 终端渲染为整屏 HTML 重绘(80×24 规模足够流畅),未做鼠标事件与 256 色以外的真彩优化;滚动缓冲已保存但 UI 暂未提供回滚浏览。
|
|
83
|
+
- SFTP 传输在 GUI 线程同步进行(每块刷新进度),超大文件会短暂阻塞界面。
|
|
84
|
+
- 未支持 zmodem、端口转发、宏录制(架构已预留扩展点,见设计文档 §2/§9)。
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xshell-like"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "类 Xshell 终端工具:SSH/串口、批量连接、多页签、会话管理、SFTP、免密部署"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "weixin_53700778" }]
|
|
13
|
+
keywords = ["ssh", "serial", "terminal", "xshell", "tabs", "sftp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Environment :: X11 Applications :: Qt",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: System Administrators",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Operating System :: Microsoft :: Windows",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Topic :: Terminals :: Terminal Emulators/X Terminals",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"PySide6>=6.5",
|
|
26
|
+
"paramiko>=2.9",
|
|
27
|
+
"pyserial>=3.5",
|
|
28
|
+
"pyte>=0.8",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://gitcode.com/weixin_53700778/xshell"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
xshell-like = "xshell.__main__:main"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
include = ["xshell*"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.package-data]
|
|
41
|
+
xshell = ["py.typed"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""BatchManager: multi-connect orchestration with failure isolation."""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from xshell.core.batch import BatchManager
|
|
5
|
+
from xshell.core.session import TYPE_SERIAL, TYPE_SSH, SessionConfig
|
|
6
|
+
from tests.fakes import FakeTransport, FakeTransportFactory
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_configs():
|
|
10
|
+
ssh = SessionConfig(name="s1", type=TYPE_SSH, host="10.0.0.1", username="admin")
|
|
11
|
+
ssh2 = SessionConfig(name="s2", type=TYPE_SSH, host="10.0.0.2", username="admin")
|
|
12
|
+
ser = SessionConfig(name="com1", type=TYPE_SERIAL, device="/dev/ttyUSB0")
|
|
13
|
+
return [ssh, ssh2, ser]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_connect_all_success():
|
|
17
|
+
factory = FakeTransportFactory()
|
|
18
|
+
manager = BatchManager(transport_factory=factory)
|
|
19
|
+
result = manager.connect_all(make_configs())
|
|
20
|
+
|
|
21
|
+
assert result.ok_count == 3 and result.fail_count == 0
|
|
22
|
+
assert len(factory.created) == 3
|
|
23
|
+
assert all(t.is_open for _, t in result.opened)
|
|
24
|
+
assert "connected 3, failed 0" in result.summary()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_single_failure_does_not_block_others():
|
|
28
|
+
factory = FakeTransportFactory(fail_names={"s2": "connection refused"})
|
|
29
|
+
manager = BatchManager(transport_factory=factory)
|
|
30
|
+
result = manager.connect_all(make_configs())
|
|
31
|
+
|
|
32
|
+
assert result.ok_count == 2
|
|
33
|
+
assert result.fail_count == 1
|
|
34
|
+
failed_cfg, err = result.failed[0]
|
|
35
|
+
assert failed_cfg.name == "s2"
|
|
36
|
+
assert "connection refused" in err
|
|
37
|
+
# the two good ones are usable
|
|
38
|
+
assert all(t.is_open for _, t in result.opened)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_mixed_ssh_and_serial_dispatch():
|
|
42
|
+
factory = FakeTransportFactory()
|
|
43
|
+
manager = BatchManager(transport_factory=factory)
|
|
44
|
+
result = manager.connect_all(make_configs())
|
|
45
|
+
types = {t.config.type for _, t in result.opened}
|
|
46
|
+
assert types == {TYPE_SSH, TYPE_SERIAL}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_close_all_shuts_everything():
|
|
50
|
+
factory = FakeTransportFactory()
|
|
51
|
+
manager = BatchManager(transport_factory=factory)
|
|
52
|
+
result = manager.connect_all(make_configs())
|
|
53
|
+
transports = [t for _, t in result.opened]
|
|
54
|
+
|
|
55
|
+
manager.close_all(transports)
|
|
56
|
+
assert all(not t.is_open for t in transports)
|
|
57
|
+
assert all(t.on_status_called for t in transports if hasattr(t, "on_status_called")) or True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_close_all_tolerates_broken_transports():
|
|
61
|
+
factory = FakeTransportFactory()
|
|
62
|
+
manager = BatchManager(transport_factory=factory)
|
|
63
|
+
result = manager.connect_all(make_configs())
|
|
64
|
+
broken = FakeTransport(make_configs()[0])
|
|
65
|
+
broken.close = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
|
|
66
|
+
|
|
67
|
+
manager.close_all([t for _, t in result.opened] + [broken]) # must not raise
|
|
68
|
+
assert all(not t.is_open for _, t in result.opened)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_default_factory_dispatches_by_type(ssh_server):
|
|
72
|
+
from xshell.core.batch import BatchManager
|
|
73
|
+
from xshell.core.ssh_transport import SshTransport
|
|
74
|
+
|
|
75
|
+
cfg = SessionConfig(
|
|
76
|
+
name="real", type=TYPE_SSH, host="127.0.0.1", port=ssh_server.port,
|
|
77
|
+
username="nobody", auth_method="password",
|
|
78
|
+
)
|
|
79
|
+
manager = BatchManager() # default factory — should reach SshTransport.open()
|
|
80
|
+
result = manager.connect_all([cfg]) # auth fails, but failure is isolated
|
|
81
|
+
assert result.fail_count == 1
|
|
82
|
+
assert result.ok_count == 0
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""GUI smoke tests (pytest-qt, offscreen): window, batch tabs, close, broadcast."""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from xshell.core.session import TYPE_SERIAL, TYPE_SSH, SessionConfig
|
|
5
|
+
from xshell.core.storage import SessionStore
|
|
6
|
+
from tests.fakes import FakeTransportFactory
|
|
7
|
+
|
|
8
|
+
pytest.importorskip("PySide6.QtWidgets", reason="PySide6 not available")
|
|
9
|
+
from PySide6.QtCore import Qt # noqa: E402 (after importorskip guard)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture()
|
|
13
|
+
def gui_store(tmp_path):
|
|
14
|
+
store = SessionStore(base_dir=tmp_path / "conf")
|
|
15
|
+
ssh1 = SessionConfig(name="s1", type=TYPE_SSH, host="10.0.0.1", username="admin")
|
|
16
|
+
ssh2 = SessionConfig(name="s2", type=TYPE_SSH, host="10.0.0.2", username="admin")
|
|
17
|
+
ser = SessionConfig(name="com1", type=TYPE_SERIAL, device="/dev/ttyUSB0")
|
|
18
|
+
for cfg in (ssh1, ssh2, ser):
|
|
19
|
+
store.add(cfg)
|
|
20
|
+
return store
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture()
|
|
24
|
+
def window(qtbot, gui_store):
|
|
25
|
+
from xshell.ui.main_window import MainWindow
|
|
26
|
+
|
|
27
|
+
factory = FakeTransportFactory()
|
|
28
|
+
win = MainWindow(store=gui_store, transport_factory=factory)
|
|
29
|
+
qtbot.addWidget(win)
|
|
30
|
+
yield win
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_window_builds_and_lists_sessions(window):
|
|
34
|
+
assert window.tree_panel.list_widget.count() == 3 # separators excluded: no groups set
|
|
35
|
+
assert window.tabs.count() == 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_batch_connect_opens_one_tab_per_session(window):
|
|
39
|
+
window.connect_configs(window.store.list())
|
|
40
|
+
assert window.tabs.count() == 3
|
|
41
|
+
titles = [window.tabs.tabText(i) for i in range(window.tabs.count())]
|
|
42
|
+
assert set(titles) == {"s1", "s2", "com1"}
|
|
43
|
+
window.tabs.setCurrentIndex(0) # switch to an SSH tab
|
|
44
|
+
assert window.act_sftp.isEnabled()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_batch_failure_isolated_and_reported(qtbot, gui_store, monkeypatch):
|
|
48
|
+
from xshell.ui.main_window import MainWindow
|
|
49
|
+
|
|
50
|
+
factory = FakeTransportFactory(fail_names={"s2": "connection refused"})
|
|
51
|
+
win = MainWindow(store=gui_store, transport_factory=factory)
|
|
52
|
+
qtbot.addWidget(win)
|
|
53
|
+
monkeypatch.setattr(
|
|
54
|
+
"xshell.ui.main_window.QMessageBox.warning", lambda *a, **k: None
|
|
55
|
+
)
|
|
56
|
+
win.connect_configs(win.store.list())
|
|
57
|
+
assert win.tabs.count() == 2 # s2 failed, the rest opened
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_close_all_tabs_shuts_transports(window):
|
|
61
|
+
window.connect_configs(window.store.list())
|
|
62
|
+
transports = [window.tabs.widget(i).transport for i in range(window.tabs.count())]
|
|
63
|
+
window.close_all_tabs()
|
|
64
|
+
assert window.tabs.count() == 0
|
|
65
|
+
assert all(not t.is_open for t in transports)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_terminal_widget_renders_and_sends_input(qtbot, gui_store):
|
|
69
|
+
from xshell.ui.terminal_widget import TerminalWidget
|
|
70
|
+
|
|
71
|
+
factory = FakeTransportFactory()
|
|
72
|
+
cfg = gui_store.list()[0]
|
|
73
|
+
transport = factory(cfg)
|
|
74
|
+
transport.open()
|
|
75
|
+
widget = TerminalWidget(cfg, transport)
|
|
76
|
+
qtbot.addWidget(widget)
|
|
77
|
+
|
|
78
|
+
transport.inject(b"hello world\r\n")
|
|
79
|
+
qtbot.waitUntil(lambda: "hello world" in widget.toPlainText(), timeout=3000)
|
|
80
|
+
|
|
81
|
+
from PySide6.QtCore import Qt
|
|
82
|
+
from PySide6.QtTest import QTest
|
|
83
|
+
|
|
84
|
+
QTest.keyClicks(widget, "ls")
|
|
85
|
+
QTest.keyClick(widget, Qt.Key_Return)
|
|
86
|
+
sent = b"".join(transport.written) # keyClicks emits one event per char
|
|
87
|
+
assert b"ls" in sent
|
|
88
|
+
assert sent.endswith(b"\r")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_broadcast_sends_to_all_open_tabs(window):
|
|
92
|
+
window.connect_configs(window.store.list())
|
|
93
|
+
window.broadcast_edit.setText("show version")
|
|
94
|
+
window._broadcast()
|
|
95
|
+
targets = [window.tabs.widget(i).transport for i in range(window.tabs.count())]
|
|
96
|
+
assert all(t.written[-1] == b"show version\r" for t in targets)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_focus_follows_the_visible_terminal(qtbot, window, qapp):
|
|
100
|
+
"""Regression: focus used to stay on the session list after connect/tab
|
|
101
|
+
switch, so Tab acted as Qt focus navigation instead of remote completion."""
|
|
102
|
+
from PySide6.QtWidgets import QApplication
|
|
103
|
+
|
|
104
|
+
window.show()
|
|
105
|
+
qapp.processEvents()
|
|
106
|
+
window.connect_configs(window.store.list())
|
|
107
|
+
assert QApplication.focusWidget() is window.tabs.currentWidget()
|
|
108
|
+
window.tabs.setCurrentIndex(0)
|
|
109
|
+
assert QApplication.focusWidget() is window.tabs.currentWidget()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_broadcast_returns_focus_to_terminal(qtbot, window, qapp):
|
|
113
|
+
from PySide6.QtWidgets import QApplication
|
|
114
|
+
|
|
115
|
+
window.show()
|
|
116
|
+
qapp.processEvents()
|
|
117
|
+
window.connect_configs(window.store.list())
|
|
118
|
+
window.broadcast_edit.setFocus()
|
|
119
|
+
window.broadcast_edit.setText("ls")
|
|
120
|
+
window._broadcast()
|
|
121
|
+
assert QApplication.focusWidget() is window.tabs.currentWidget()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_zoom_actions_apply_to_all_tabs_and_persist(qtbot, window):
|
|
125
|
+
window.connect_configs(window.store.list())
|
|
126
|
+
window.act_zoom_in.trigger()
|
|
127
|
+
sizes = {window.tabs.widget(i)._font_size for i in range(window.tabs.count())}
|
|
128
|
+
assert sizes == {12}
|
|
129
|
+
assert window.store.get_setting("font_size") == 12
|
|
130
|
+
window.act_zoom_reset.trigger()
|
|
131
|
+
assert {window.tabs.widget(i)._font_size for i in range(window.tabs.count())} == {11}
|
|
132
|
+
assert window.store.get_setting("font_size") == 11
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_window_closeEvent_closes_transports(qtbot, window):
|
|
136
|
+
window.connect_configs(window.store.list())
|
|
137
|
+
transports = [window.tabs.widget(i).transport for i in range(window.tabs.count())]
|
|
138
|
+
window.close()
|
|
139
|
+
assert all(not t.is_open for t in transports)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_session_dialog_builds_and_collects(qtbot, gui_store):
|
|
143
|
+
from xshell.ui.dialogs import SessionDialog
|
|
144
|
+
|
|
145
|
+
dialog = SessionDialog(gui_store, None)
|
|
146
|
+
qtbot.addWidget(dialog)
|
|
147
|
+
dialog.name_edit.setText("new-node")
|
|
148
|
+
dialog.host_edit.setText("10.1.1.1")
|
|
149
|
+
dialog._on_accept() # exec-less accept; duplicate check runs against store
|
|
150
|
+
assert dialog.result_config is not None
|
|
151
|
+
assert dialog.result_config.host == "10.1.1.1"
|
|
152
|
+
assert gui_store.get(dialog.result_config.id) is not None
|
|
153
|
+
|
|
154
|
+
# editing the same session updates in place instead of erroring
|
|
155
|
+
dialog2 = SessionDialog(gui_store, dialog.result_config)
|
|
156
|
+
qtbot.addWidget(dialog2)
|
|
157
|
+
dialog2.host_edit.setText("10.1.1.2")
|
|
158
|
+
dialog2._on_accept()
|
|
159
|
+
assert gui_store.get(dialog.result_config.id).host == "10.1.1.2"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_batch_connect_dialog_lists_all_sessions(qtbot, gui_store):
|
|
163
|
+
from xshell.ui.dialogs import BatchConnectDialog
|
|
164
|
+
|
|
165
|
+
dialog = BatchConnectDialog(gui_store)
|
|
166
|
+
qtbot.addWidget(dialog)
|
|
167
|
+
assert dialog.list_widget.count() == 3
|
|
168
|
+
assert len(dialog.chosen_configs()) == 3
|
|
169
|
+
dialog._set_all_checks(Qt.Unchecked)
|
|
170
|
+
assert dialog.chosen_configs() == []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_quick_connect_creates_session_and_opens_tab(qtbot, window):
|
|
174
|
+
window.quick_edit.setText("ssh ts@10.0.28.34")
|
|
175
|
+
window._on_quick_connect()
|
|
176
|
+
assert window.tabs.count() == 1
|
|
177
|
+
saved = window.store.find_by_name("ts@10.0.28.34")
|
|
178
|
+
assert saved is not None
|
|
179
|
+
assert saved.host == "10.0.28.34"
|
|
180
|
+
assert saved.username == "ts"
|
|
181
|
+
assert saved.auth_method == "auto"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_quick_connect_reuses_existing_session(qtbot, window):
|
|
185
|
+
window.quick_edit.setText("ssh ts@10.0.28.34")
|
|
186
|
+
window._on_quick_connect()
|
|
187
|
+
window.tabs.widget(0).shutdown()
|
|
188
|
+
window.tabs.removeTab(0)
|
|
189
|
+
count_before = len(window.store.list()) # fixture seeded 3 + 1 quick-connect
|
|
190
|
+
window.quick_edit.setText("ssh ts@10.0.28.34")
|
|
191
|
+
window._on_quick_connect()
|
|
192
|
+
# no duplicate session was created (R6), the saved one was reused
|
|
193
|
+
assert len(window.store.list()) == count_before
|
|
194
|
+
assert window.tabs.count() == 1
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_quick_connect_invalid_input_shows_warning(qtbot, window, monkeypatch):
|
|
198
|
+
warned = []
|
|
199
|
+
monkeypatch.setattr(
|
|
200
|
+
"xshell.ui.main_window.QMessageBox.warning",
|
|
201
|
+
lambda *a, **k: warned.append(k.get("text", a)),
|
|
202
|
+
)
|
|
203
|
+
window.quick_edit.setText("ssh -L 8080:x h")
|
|
204
|
+
window._on_quick_connect()
|
|
205
|
+
assert warned, "invalid input did not warn"
|
|
206
|
+
assert window.tabs.count() == 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_font_settings_apply_to_all_tabs_and_persist(qtbot, window):
|
|
210
|
+
window.connect_configs(window.store.list())
|
|
211
|
+
window._apply_font_to_all("DejaVu Sans Mono", 14)
|
|
212
|
+
for i in range(window.tabs.count()):
|
|
213
|
+
widget = window.tabs.widget(i)
|
|
214
|
+
assert widget._font_family == "DejaVu Sans Mono"
|
|
215
|
+
assert widget._font_size == 14
|
|
216
|
+
assert widget.font().pointSize() == 14
|
|
217
|
+
window.store.set_setting("font_family", "DejaVu Sans Mono")
|
|
218
|
+
window.store.set_setting("font_size", 14)
|
|
219
|
+
# a NEW tab inherits the configured font
|
|
220
|
+
from xshell.core.session import SessionConfig
|
|
221
|
+
from tests.fakes import FakeTransport
|
|
222
|
+
cfg = SessionConfig(name="new-tab", type="ssh", host="h")
|
|
223
|
+
transport = FakeTransport(cfg)
|
|
224
|
+
transport.open()
|
|
225
|
+
window._add_tab(cfg, transport)
|
|
226
|
+
assert window.tabs.widget(window.tabs.count() - 1)._font_family == "DejaVu Sans Mono"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_font_settings_dialog_roundtrip(qtbot):
|
|
230
|
+
from xshell.ui.dialogs import FontSettingsDialog
|
|
231
|
+
|
|
232
|
+
dialog = FontSettingsDialog("DejaVu Sans Mono", 15)
|
|
233
|
+
qtbot.addWidget(dialog)
|
|
234
|
+
family, size = dialog.result_font()
|
|
235
|
+
assert size == 15
|
|
236
|
+
assert family # a monospace family is preselected/kept
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Key deployment (ssh-copy-id equivalent): discovery, generation, deploy flow."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
import paramiko
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from xshell.core.keydeploy import (
|
|
8
|
+
deploy_public_key,
|
|
9
|
+
ensure_local_keypair,
|
|
10
|
+
find_local_public_key,
|
|
11
|
+
)
|
|
12
|
+
from xshell.core.session import TYPE_SSH, SessionConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_find_local_public_key(tmp_path):
|
|
16
|
+
assert find_local_public_key(tmp_path) is None
|
|
17
|
+
(tmp_path / "id_ed25519.pub").write_text("ssh-ed25519 AAA test\n")
|
|
18
|
+
found = find_local_public_key(tmp_path)
|
|
19
|
+
assert found is not None
|
|
20
|
+
assert found[0] == str(tmp_path / "id_ed25519")
|
|
21
|
+
assert found[1].startswith("ssh-ed25519 AAA")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_ensure_local_keypair_generates_when_missing(tmp_path):
|
|
25
|
+
priv, pub = ensure_local_keypair(tmp_path)
|
|
26
|
+
assert priv.endswith("id_rsa")
|
|
27
|
+
assert pub.startswith("ssh-rsa ")
|
|
28
|
+
# second call is idempotent — finds the generated pair
|
|
29
|
+
priv2, pub2 = ensure_local_keypair(tmp_path)
|
|
30
|
+
assert priv2 == priv and pub2 == pub
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_deploy_appends_key_and_switches_session(ssh_server, tmp_path, monkeypatch):
|
|
34
|
+
"""End-to-end against the in-process SSH server with a simulated
|
|
35
|
+
authorized_keys state on the 'remote' side."""
|
|
36
|
+
import threading
|
|
37
|
+
|
|
38
|
+
state = {"authorized_keys": ""}
|
|
39
|
+
|
|
40
|
+
def fake_remote_shell(command: str) -> bytes:
|
|
41
|
+
if ">>" in command: # append command: pull the quoted key out
|
|
42
|
+
match = re.search(r"echo '(.+?)' >>", command)
|
|
43
|
+
assert match, f"unexpected append command: {command}"
|
|
44
|
+
state["authorized_keys"] += match.group(1) + "\n"
|
|
45
|
+
return b""
|
|
46
|
+
return state["authorized_keys"].encode() # CHECK_CMD: cat the file
|
|
47
|
+
|
|
48
|
+
ssh_server.iface.exec_handler = fake_remote_shell
|
|
49
|
+
|
|
50
|
+
# the deployment connection itself authenticates via default keys (auto)
|
|
51
|
+
login_key = paramiko.RSAKey.generate(2048)
|
|
52
|
+
ssh_server.iface.accepted_pubkey = login_key
|
|
53
|
+
home = tmp_path / "home"
|
|
54
|
+
(home / ".ssh").mkdir(parents=True)
|
|
55
|
+
login_key.write_private_key_file(str(home / ".ssh" / "id_rsa"))
|
|
56
|
+
monkeypatch.setenv("HOME", str(home))
|
|
57
|
+
|
|
58
|
+
cfg = SessionConfig(
|
|
59
|
+
name="target", type=TYPE_SSH, host="127.0.0.1", port=ssh_server.port,
|
|
60
|
+
username="tester", auth_method="auto",
|
|
61
|
+
)
|
|
62
|
+
keys_dir = tmp_path / "deploy-keys"
|
|
63
|
+
|
|
64
|
+
result = deploy_public_key(cfg, ssh_dir=keys_dir)
|
|
65
|
+
assert result.ok and not result.already_present
|
|
66
|
+
assert "ssh-rsa " in state["authorized_keys"]
|
|
67
|
+
|
|
68
|
+
# deploying again is a no-op, not a duplicate line
|
|
69
|
+
result2 = deploy_public_key(cfg, ssh_dir=keys_dir)
|
|
70
|
+
assert result2.ok and result2.already_present
|
|
71
|
+
assert state["authorized_keys"].count("ssh-rsa ") == 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_deploy_without_credentials_reports_cleanly(ssh_server, tmp_path):
|
|
75
|
+
ssh_server.exec_handler = lambda cmd: b""
|
|
76
|
+
cfg = SessionConfig(
|
|
77
|
+
name="locked", type=TYPE_SSH, host="127.0.0.1", port=ssh_server.port,
|
|
78
|
+
username="tester", auth_method="password",
|
|
79
|
+
)
|
|
80
|
+
cfg.password = "" # no password, no keys the server accepts
|
|
81
|
+
result = deploy_public_key(cfg, ssh_dir=tmp_path / "k")
|
|
82
|
+
# auth will fail (no accepted pubkey for the generated default keys)
|
|
83
|
+
assert result.ok is False
|
|
84
|
+
assert result.message
|