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.
Files changed (38) hide show
  1. xshell_like-0.1.0/LICENSE +21 -0
  2. xshell_like-0.1.0/PKG-INFO +109 -0
  3. xshell_like-0.1.0/README.md +84 -0
  4. xshell_like-0.1.0/pyproject.toml +41 -0
  5. xshell_like-0.1.0/setup.cfg +4 -0
  6. xshell_like-0.1.0/tests/test_batch.py +82 -0
  7. xshell_like-0.1.0/tests/test_gui_smoke.py +236 -0
  8. xshell_like-0.1.0/tests/test_keydeploy.py +84 -0
  9. xshell_like-0.1.0/tests/test_serial_transport.py +115 -0
  10. xshell_like-0.1.0/tests/test_session_storage.py +186 -0
  11. xshell_like-0.1.0/tests/test_ssh_transport.py +176 -0
  12. xshell_like-0.1.0/tests/test_terminal.py +141 -0
  13. xshell_like-0.1.0/tests/test_terminal_widget.py +505 -0
  14. xshell_like-0.1.0/xshell/__init__.py +3 -0
  15. xshell_like-0.1.0/xshell/__main__.py +22 -0
  16. xshell_like-0.1.0/xshell/core/__init__.py +1 -0
  17. xshell_like-0.1.0/xshell/core/batch.py +59 -0
  18. xshell_like-0.1.0/xshell/core/keydeploy.py +127 -0
  19. xshell_like-0.1.0/xshell/core/serial_transport.py +98 -0
  20. xshell_like-0.1.0/xshell/core/session.py +199 -0
  21. xshell_like-0.1.0/xshell/core/sftp.py +123 -0
  22. xshell_like-0.1.0/xshell/core/ssh_transport.py +125 -0
  23. xshell_like-0.1.0/xshell/core/storage.py +135 -0
  24. xshell_like-0.1.0/xshell/core/terminal.py +164 -0
  25. xshell_like-0.1.0/xshell/core/transport.py +64 -0
  26. xshell_like-0.1.0/xshell/py.typed +0 -0
  27. xshell_like-0.1.0/xshell/ui/__init__.py +1 -0
  28. xshell_like-0.1.0/xshell/ui/dialogs.py +313 -0
  29. xshell_like-0.1.0/xshell/ui/main_window.py +404 -0
  30. xshell_like-0.1.0/xshell/ui/session_tree.py +95 -0
  31. xshell_like-0.1.0/xshell/ui/sftp_dialog.py +160 -0
  32. xshell_like-0.1.0/xshell/ui/terminal_widget.py +437 -0
  33. xshell_like-0.1.0/xshell_like.egg-info/PKG-INFO +109 -0
  34. xshell_like-0.1.0/xshell_like.egg-info/SOURCES.txt +36 -0
  35. xshell_like-0.1.0/xshell_like.egg-info/dependency_links.txt +1 -0
  36. xshell_like-0.1.0/xshell_like.egg-info/entry_points.txt +2 -0
  37. xshell_like-0.1.0/xshell_like.egg-info/requires.txt +4 -0
  38. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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