Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e78ad55b4e | ||
|
|
9daa64f81a | ||
|
|
964b8a470a | ||
|
|
79027122e1 | ||
|
|
c05e1d84d2 | ||
|
|
f431716bbd | ||
|
|
39e963d6d2 |
No files matched your search
@@ -13,6 +13,7 @@ venv/
|
||||
build/
|
||||
dist/
|
||||
release/
|
||||
*.spec
|
||||
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
@@ -1,23 +1,43 @@
|
||||
# PGU-NetFileSyncWin 项目维护说明
|
||||
|
||||
当前应用版本:`v1.0.6`(2026-09-27)
|
||||
|
||||
本版本变更:下载完成结果弹窗、失败项目明细、用时和平均速度统计、完成后速度条清零;并保留极速服务 V3、CPU 自适应并发、HTTP 长连接、服务版本显示与旧版升级、连接断开后的 user linger。
|
||||
|
||||
## 项目定位
|
||||
|
||||
PGU-NetFileSyncWin 是一个面向 Windows 的 PyQt5 远程文件管理与高速下载工具。它通过 SSH/SFTP 连接 Ubuntu 设备,浏览远端目录,并支持把远端文件或文件夹拖拽到 Windows 资源管理器中下载。项目特别针对“大量小文件,少量大文件”的数据结构优化。
|
||||
PGU-NetFileSyncWin 是一个面向 Windows 的 PyQt5 远程文件管理与高速下载工具。它用于连接 PGU 设备、浏览远程文件,并将文件或文件夹下载到 Windows 本机。
|
||||
|
||||
主程序文件:
|
||||
项目重点优化:
|
||||
|
||||
- `pgu4_sync.py`:全部 GUI、SSH/SFTP、极速 HTTP 服务安装、下载调度逻辑。
|
||||
- 大量小文件下载。
|
||||
- 从 GUI 拖拽到 Windows 资源管理器下载。
|
||||
- 远端极速服务的安装、升级、停止和开机自启。
|
||||
- 便携式 ini 配置。
|
||||
|
||||
## 文件结构
|
||||
|
||||
- `pgu4_sync.py`:主程序,包含 GUI、连接、扫描、下载、极速服务管理逻辑。
|
||||
- `requirements.txt`:运行依赖。
|
||||
- `run_pgu4_sync.bat`:源码运行入口。
|
||||
- `assets/pgu_netfilesync_icon.ico`:Windows exe 图标。
|
||||
- `assets/pgu_netfilesync_icon.png`:Qt GUI 窗口图标。
|
||||
- `README.md`:仓库概览。
|
||||
- `assets/pgu_netfilesync_icon.png`:Qt 窗口图标。
|
||||
- `docs/images/main-window.png`:README 和 USER 使用的主界面截图。
|
||||
- `tests/test_delete_worker.py`:永久删除的路径安全、分批和失败回退测试。
|
||||
- `tests/test_commercial_ui.py`:商业化主界面、DPI、扫描默认值和状态栏集成测试。
|
||||
- `tests/test_fast_service_recovery.py`:极速服务版本识别、重启恢复和修复状态测试。
|
||||
- `tests/test_password_storage.py`:DPAPI 密码加密、迁移和清除测试。
|
||||
- `tests/test_root_access.py`:`/root` 二次密码验证和符号链接绕过测试。
|
||||
- `tests/test_unicode_paths.py`:中文远程路径、中文本地路径和极速 Range 协议测试。
|
||||
- `tests/test_update.py`:版本比较、Release 解析、配置合并和更新脚本测试。
|
||||
- `README.md`:项目概览。
|
||||
- `USER.md`:用户说明书。
|
||||
- `PGU-NetFileSyncWin.ini`:本机便携配置文件,包含连接信息;该文件被 `.gitignore` 忽略,不提交仓库。
|
||||
- `AGENTS.md`:维护说明。
|
||||
- `PGU-NetFileSyncWin.ini`:本机便携配置文件,不提交仓库。
|
||||
|
||||
## Python 环境
|
||||
## 运行环境
|
||||
|
||||
本机默认使用 Miniconda 环境:
|
||||
本机默认 Python:
|
||||
|
||||
```powershell
|
||||
C:\Users\clear\.conda\envs\clear_conda_main\python.exe
|
||||
@@ -29,218 +49,193 @@ C:\Users\clear\.conda\envs\clear_conda_main\python.exe
|
||||
C:\Users\clear\.conda\envs\clear_conda_main\python.exe -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
基本依赖:
|
||||
必须继续使用 PyQt5,不要改为 PyQt6。
|
||||
|
||||
- `PyQt5`
|
||||
- `paramiko`
|
||||
- `pywin32`
|
||||
- `psutil`
|
||||
- `PyInstaller`,仅打包发布时需要。
|
||||
## 配置文件
|
||||
|
||||
## 运行
|
||||
|
||||
源码运行:
|
||||
|
||||
```powershell
|
||||
run_pgu4_sync.bat
|
||||
```
|
||||
|
||||
或者:
|
||||
|
||||
```powershell
|
||||
C:\Users\clear\.conda\envs\clear_conda_main\python.exe pgu4_sync.py
|
||||
```
|
||||
|
||||
发布版路径:
|
||||
|
||||
```text
|
||||
dist/PGU-NetFileSyncWin.exe
|
||||
```
|
||||
|
||||
发布版配置文件位于 exe 同目录:
|
||||
|
||||
```text
|
||||
dist/PGU-NetFileSyncWin.ini
|
||||
```
|
||||
|
||||
源码运行时配置文件位于项目目录:
|
||||
程序使用同目录 ini 保存配置:
|
||||
|
||||
```text
|
||||
PGU-NetFileSyncWin.ini
|
||||
```
|
||||
|
||||
当前默认配置:
|
||||
源码运行时,ini 位于项目目录。exe 运行时,ini 位于 exe 同目录。
|
||||
|
||||
默认内容:
|
||||
|
||||
```ini
|
||||
[General]
|
||||
host=192.168.1.248
|
||||
port=22
|
||||
username=clear
|
||||
password=Bone1644
|
||||
remote_path=/home/clear/Data
|
||||
```
|
||||
|
||||
## GUI 设计要点
|
||||
说明:
|
||||
|
||||
- 必须使用 PyQt5,不要升级到 PyQt6。
|
||||
- 界面以远程文件列表为核心,不显示左侧本地目录树。
|
||||
- 本地下载目标通过“下载到...”临时选择,或从 GUI 拖拽到已打开的 Windows 资源管理器窗口。
|
||||
- 远程文件列表模拟资源管理器交互:
|
||||
- 左侧空白列可作为框选起点。
|
||||
- 右侧和底部空白区域可框选。
|
||||
- 单击空白区域取消当前选择。
|
||||
- 非空白文件行拖动用于拖出 GUI 到资源管理器。
|
||||
- 底部进度条是速度条,不是总进度条:
|
||||
- `100 MB/s` 映射到满格。
|
||||
- 超过 `100 MB/s` 仍按满格显示。
|
||||
- 运行中为蓝色,下载完成后拉满并变绿色。
|
||||
- 密码使用 Windows DPAPI 加密后保存为 `password_protected`。
|
||||
- 旧版 `password` 明文键在启动时自动迁移并删除。
|
||||
- DPAPI 密文绑定当前 Windows 用户,不能作为跨机器默认密码发布。
|
||||
- `PGU-NetFileSyncWin.ini` 已在 `.gitignore` 中忽略,不要提交。
|
||||
- 程序启动时如果 ini 不存在,会自动生成默认配置。
|
||||
|
||||
## 连接与扫描
|
||||
## 关键交互
|
||||
|
||||
连接参数:
|
||||
远程文件列表需要保持接近资源管理器:
|
||||
|
||||
- IP
|
||||
- SSH 端口
|
||||
- 用户名
|
||||
- 密码
|
||||
- 远程路径
|
||||
- 左侧空白列可作为框选起点。
|
||||
- 右侧空白区和底部空白区可框选。
|
||||
- 单击空白区域取消选择。
|
||||
- 从文件行拖出 GUI 表示下载到 Windows 资源管理器。
|
||||
- 进入 `/root` 或其子目录前必须再次输入当前 SSH 密码;离开 `/root` 后授权失效。
|
||||
- `ListWorker` 使用 SFTP `normalize` 解析真实路径,指向 `/root` 的符号链接也必须触发验证。
|
||||
- 右键可永久删除单个或多个远程文件、目录,使用远端 `rm -rf --` 分批处理,不进入回收站。
|
||||
- 删除命令按选中项分批并进行 shell 安全转义,禁止删除远程根目录 `/`;批量失败时逐项定位失败内容。
|
||||
- `rm -rf` 返回失败时最多自动重试 5 次并指数退避,用于处理短暂的 `Directory not empty` 写入竞争。
|
||||
- 每次删除后立即检查顶层选中路径是否仍存在,避免后台程序瞬间重建目录时误报成功。
|
||||
- 确认后在弹窗中显示选中项删除进度,完成后自动刷新目录。
|
||||
- 不显示左侧本地目录树。
|
||||
|
||||
这些连接参数会通过 `QSettings` 记忆。
|
||||
底部条是速度条,不是总进度条:
|
||||
|
||||
当前项目不再使用 Windows 注册表保存配置,而是使用 `QSettings(str(config_path()), QSettings.IniFormat)`,把配置保存到 `PGU-NetFileSyncWin.ini`。密码按用户要求以明文保存。
|
||||
- `100MB/s` 映射到满格。
|
||||
- 下载中蓝色。
|
||||
- 下载完成弹窗关闭后速度条清零,恢复为等待状态。
|
||||
|
||||
“扫描”按钮会扫描本机选定网卡所在局域网中开放 `5901` 或 `5923` 的设备:
|
||||
局域网扫描端口:
|
||||
|
||||
- 如果发现 `5923`,主界面端口自动填 `5922`。
|
||||
- 如果发现 `5901`,主界面端口自动填 `22`。
|
||||
- `5901`
|
||||
- `5923`
|
||||
- `8080`
|
||||
|
||||
## 下载通道
|
||||
设备识别及端口映射规则:
|
||||
|
||||
### 普通通道
|
||||
- 发现 `5923`:显示为 `PGU4`,主界面端口填 `5922`。
|
||||
- 发现 `5901`:显示为 `PGU4`,主界面端口填 `22`。
|
||||
- 仅发现 `8080`:显示为 `PGU5`,主界面端口填 `22`。
|
||||
|
||||
普通通道使用 SSH/SFTP。并发上限为本机逻辑线程数:
|
||||
扫描入口只保留在 `设备与连接设置` 弹窗中。选择设备后:
|
||||
|
||||
```python
|
||||
get_logical_thread_count()
|
||||
```
|
||||
- PGU4 自动填写用户 `clear`、对应密码和 `/home/clear/Data`。
|
||||
- PGU5 自动填写用户 `root`、对应密码和 `/nvme-data/pgu_data`。
|
||||
- 密码始终使用 `QLineEdit.Password` 掩码显示。
|
||||
|
||||
普通 SFTP 对大量小文件速度较差,仅作为极速服务不可用时的兜底。
|
||||
## 极速通道
|
||||
|
||||
### 极速通道
|
||||
|
||||
极速通道是在远端通过 SSH 安装一个轻量 HTTP Range 服务,然后本地通过 HTTP 并发下载。
|
||||
|
||||
关键常量:
|
||||
核心常量:
|
||||
|
||||
```python
|
||||
FAST_HTTP_PORT = 5924
|
||||
FAST_HTTP_WORKERS = 12
|
||||
FAST_HTTP_BIG_FILE_WORKERS = 4
|
||||
FAST_HTTP_WORKERS = 8
|
||||
FAST_HTTP_BIG_FILE_WORKERS = 3
|
||||
FAST_HTTP_TIMEOUT = 45
|
||||
FAST_SPLIT_THRESHOLD = 64 * 1024 * 1024
|
||||
FAST_HTTP_TOKEN = "PGU4_SYNC_FAST_V1"
|
||||
FAST_HTTP_TOKEN = "PGU4_SYNC_FAST_V2"
|
||||
FAST_SERVICE_VERSION = "3"
|
||||
```
|
||||
|
||||
策略:
|
||||
|
||||
- 小文件按文件并发,最多 `12` 路。
|
||||
- 大于 `64MB` 的文件使用 Range 分片。
|
||||
- 单个大文件最多 `4` 路分片并发。
|
||||
- 分片失败自动重试 `3` 次。
|
||||
- 重试期间不会弹窗打断用户。
|
||||
- 最终失败的文件会在任务结束后汇总显示。
|
||||
- 连接时同时检测 Windows 本机与远端设备在线 CPU 线程数,总并发自动取两者较小值且不保存。
|
||||
- 小文件按文件并发,最多 8 路,并复用每个下载线程的 HTTP 连接,优先保证吞吐稳定。
|
||||
- 大文件超过 64MB 后使用 Range 分片。
|
||||
- 单个大文件最多 3 路分片,避免远端磁盘和HTTP线程争用。
|
||||
- 分片失败在块内断点续传并自动重试,避免从块头重复下载。
|
||||
- 极速HTTP使用45秒读取超时,避免短暂磁盘/网络抖动被过早判定失败。
|
||||
- 失败汇总只在任务结束后提示。
|
||||
- V3 按远端逻辑 CPU 数量建立并发槽位,上限为 `min(16, CPU线程数)` 且至少 2 路;同时扩大连接等待队列并启用 HTTP/1.1 长连接。路径仍使用 UTF-8 URL-safe Base64,支持中文目录、中文文件名和空格。
|
||||
|
||||
## 远端极速服务
|
||||
|
||||
安装/升级极速服务时会执行以下动作:
|
||||
|
||||
- 上传服务脚本到:
|
||||
安装位置:
|
||||
|
||||
```text
|
||||
~/.local/share/pgu4_sync/pgu4_fast_http.py
|
||||
```
|
||||
|
||||
- 写入日志到:
|
||||
日志位置:
|
||||
|
||||
```text
|
||||
~/.local/state/pgu4_sync/pgu4_fast_http.log
|
||||
```
|
||||
|
||||
- 创建用户级 systemd 服务:
|
||||
自启配置:
|
||||
|
||||
```text
|
||||
~/.config/systemd/user/pgu4-sync-fast.service
|
||||
crontab @reboot
|
||||
```
|
||||
|
||||
- 尝试执行:
|
||||
极速服务脚本带持久版本标记,并在权限允许时启用 systemd user linger,避免 SSH 会话结束后服务退出。连接设备时:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable pgu4-sync-fast.service
|
||||
systemctl --user restart pgu4-sync-fast.service
|
||||
- 当前 V3 服务正常:直接启用极速通道并刷新自启配置,GUI 显示 `极速 v3`。
|
||||
- 检测到旧版本时显示实际版本,例如 `极速 v2 · 可升级`,按钮提供 `升级极速`。
|
||||
- 当前 V3 已安装但未运行:自动清理残留进程并重启,不显示升级。
|
||||
- 自动恢复失败:显示 `修复极速`。
|
||||
- 仅脚本确实为旧版本时显示 `升级极速`。
|
||||
- crontab 清理必须匹配 `pgu4_fast_http.py.*--port 5924`,避免带引号的旧命令累积。
|
||||
|
||||
关闭 Windows GUI 不会停止 PGU 设备上的极速服务。
|
||||
|
||||
## 自动更新
|
||||
|
||||
当前应用版本由 `APP_VERSION` 定义。打包后的 EXE 启动后会静默访问 Gitea `releases/latest`:
|
||||
|
||||
- 没有新版本、请求超时或接口异常时不提示。
|
||||
- 左侧底部提供 `检查更新` 按钮;手动检查会明确提示最新版或连接失败。
|
||||
- 仅接受 `107.173.157.41:3000` 上名为 `PGU-NetFileSyncWin.exe` 的附件。
|
||||
- 发现更高版本后询问用户,确认后显示下载进度。
|
||||
- 新版 INI 只补充当前 INI 缺失的键,绝不覆盖已有连接配置。
|
||||
- EXE 下载后校验发布尺寸、`MZ` 文件头和最终下载主机。
|
||||
- 退出当前程序后由隐藏 PowerShell 脚本替换 EXE 并重启;新版启动失败时恢复旧 EXE。
|
||||
|
||||
发布新版本时必须同步修改 `APP_VERSION`,否则新版仍会被判断为旧版本。
|
||||
|
||||
## 打包
|
||||
|
||||
验证:
|
||||
|
||||
```powershell
|
||||
C:\Users\clear\.conda\envs\clear_conda_main\python.exe -m py_compile pgu4_sync.py
|
||||
C:\Users\clear\.conda\envs\clear_conda_main\python.exe -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
- 同时写入 `crontab @reboot` 作为无 root 权限时的自启兜底。
|
||||
|
||||
如果连接时检测到远端已有健康极速服务,也会自动补写自启配置。
|
||||
|
||||
“停止极速”会:
|
||||
|
||||
- `systemctl --user disable --now pgu4-sync-fast.service`
|
||||
- 删除相关 `crontab @reboot` 行
|
||||
- 杀掉当前 `pgu4_fast_http.py --port 5924` 进程
|
||||
|
||||
关闭 Windows GUI 不会停止远端极速服务。
|
||||
|
||||
## 打包发布
|
||||
|
||||
打包命令:
|
||||
打包:
|
||||
|
||||
```powershell
|
||||
C:\Users\clear\.conda\envs\clear_conda_main\python.exe -m PyInstaller --clean --noconfirm --onefile --windowed --name PGU-NetFileSyncWin --icon assets\pgu_netfilesync_icon.ico --add-data "assets\pgu_netfilesync_icon.ico;assets" --add-data "assets\pgu_netfilesync_icon.png;assets" --add-binary "C:\Users\clear\.conda\envs\clear_conda_main\Library\bin\libssl-3-x64.dll;." --add-binary "C:\Users\clear\.conda\envs\clear_conda_main\Library\bin\libbz2.dll;." --add-binary "C:\Users\clear\.conda\envs\clear_conda_main\Library\bin\ffi-8.dll;." --exclude-module PyQt5.QtWebEngine --exclude-module PyQt5.QtWebEngineWidgets --exclude-module PyQt5.QtWebKit --exclude-module PyQt5.QtWebKitWidgets --exclude-module matplotlib --exclude-module numpy --exclude-module pandas --exclude-module scipy --exclude-module IPython --exclude-module jupyter --exclude-module notebook --exclude-module pytest --exclude-module tkinter pgu4_sync.py
|
||||
```
|
||||
|
||||
发布前验证:
|
||||
发布时创建不含任何密码字段的配置文件:
|
||||
|
||||
```powershell
|
||||
C:\Users\clear\.conda\envs\clear_conda_main\python.exe -m py_compile pgu4_sync.py
|
||||
@"
|
||||
[General]
|
||||
host=192.168.1.248
|
||||
port=22
|
||||
username=clear
|
||||
remote_path=/home/clear/Data
|
||||
"@ | Set-Content dist\PGU-NetFileSyncWin.ini -Encoding UTF8
|
||||
```
|
||||
|
||||
发布 exe 应满足:
|
||||
## 发布
|
||||
|
||||
- 单文件。
|
||||
- 无命令行窗口,PE 子系统为 `WINDOWS_GUI`。
|
||||
- GUI 能正常启动。
|
||||
- 标题栏、任务栏、exe 文件均显示项目图标。
|
||||
|
||||
## Git 与发布
|
||||
|
||||
远程仓库:
|
||||
Gitea 仓库:
|
||||
|
||||
```text
|
||||
http://107.173.157.41:3000/clear/PGU-NetFileSyncWin
|
||||
```
|
||||
|
||||
默认分支:
|
||||
|
||||
```text
|
||||
main
|
||||
```
|
||||
|
||||
发布版本使用 Gitea Release。建议 tag 格式:
|
||||
|
||||
```text
|
||||
v1.0.0
|
||||
```
|
||||
|
||||
发布附件至少包含:
|
||||
Release 附件建议只上传:
|
||||
|
||||
- `PGU-NetFileSyncWin.exe`
|
||||
- `PGU-NetFileSyncWin-1.0.0-source.zip`
|
||||
- `PGU-NetFileSyncWin.ini`
|
||||
|
||||
不要额外上传源码 zip,因为 Gitea 会自动提供源码 ZIP 和 TAR.GZ。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 不要把远端设备密码、Gitea 密码、服务器密码写入仓库。
|
||||
- `PGU-NetFileSyncWin.ini` 含明文密码,不要提交仓库。
|
||||
- `.idea/`、`__pycache__/`、`build/`、`dist/` 默认不提交。
|
||||
- `dist/` 中的 exe 是发布产物,不在 git 中跟踪,通过 Gitea Release 附件发布。
|
||||
- Windows 图标可能被系统缓存;测试图标时应关闭旧进程,必要时取消任务栏固定后重新固定新版 exe。
|
||||
- 不要提交 `PGU-NetFileSyncWin.ini`。
|
||||
- 不要提交 `build/`、`dist/`、`release/`。
|
||||
- 发布前确认 exe 为 `WINDOWS_GUI` 子系统,不显示命令行窗口。
|
||||
- Windows 可能缓存旧图标,验证图标时需要关闭旧进程,必要时取消任务栏固定。
|
||||
@@ -1,10 +1,49 @@
|
||||
# PGU4-Sync
|
||||
# PGU-NetFileSyncWin
|
||||
|
||||
基于 PyQt5 的 Ubuntu 远程文件管理与高速下载工具。界面以远程文件列表为主,支持连接记忆、远程路径记忆、局域网扫描、拖拽到 Windows 资源管理器、多线程下载、暂停/继续、取消、实时速度条和极速 HTTP Range 通道。
|
||||
PGU-NetFileSyncWin 是一个 Windows 桌面文件同步工具,用于连接 PGU 设备、浏览远程目录,并把远程文件或文件夹高速下载到本机。
|
||||
|
||||
连接配置保存在程序同目录的 `PGU-NetFileSyncWin.ini` 中,便于和 exe 一起复制使用。
|
||||

|
||||
|
||||
## 安装
|
||||
## 主要功能
|
||||
|
||||
- 连接 PGU 设备并浏览远程文件。
|
||||
- 支持在连接设置中扫描局域网,并区分 PGU4 与 PGU5 设备。
|
||||
- 扫描选择设备后自动填写对应账号、密码、SSH 端口和默认远程路径。
|
||||
- 支持多选、框选、空白区域取消选择。
|
||||
- 支持目录前进、后退、搜索和常用键盘快捷键。
|
||||
- 支持拖拽已选文件到 Windows 资源管理器下载。
|
||||
- 支持“下载”选择本地目录。
|
||||
- 支持远端批量永久删除文件及非空目录,不经过回收站,并显示删除进度。
|
||||
- 支持暂停、继续、取消和失败自动重试。
|
||||
- 支持极速通道,提高大量小文件下载速度。
|
||||
- 支持中文远程目录、中文文件名和中文 Windows 目标目录。
|
||||
- 支持远端极速服务安装、升级、修复、停止和开机自启。
|
||||
- 重启 PGU 设备后会自动恢复当前版本极速服务,清理重复旧自启项。
|
||||
- 启动后静默检查 Gitea 新版本,并支持自动升级和重启。
|
||||
- 使用速度条显示实时速度,下载完成后显示用时和平均速度。
|
||||
- 使用同目录 `PGU-NetFileSyncWin.ini` 保存连接配置,便于和 exe 一起复制使用。
|
||||
|
||||
## 当前版本
|
||||
|
||||
当前应用版本:`v1.0.6`
|
||||
|
||||
本版本重点修复极速下载稳定性:客户端会同时考虑 Windows 本机和 PGU 设备的 CPU 线程数;极速服务按远端 CPU 建立并发槽位,支持 HTTP 长连接、连接队列扩容、服务版本显示和旧版一键升级。
|
||||
|
||||
- 高速通道区域显示已安装的极速服务版本,例如 `极速 v3`。
|
||||
- 检测到旧版时显示实际版本,并提供 `升级极速`。
|
||||
- 大量小文件下载复用连接,减少速度波动和重复重试。
|
||||
- 下载结束后弹窗显示成功/失败状态、已传输大小、用时、平均速度和失败项目;底部速度条自动清零。
|
||||
|
||||
## 发布版
|
||||
|
||||
推荐使用发布页中的文件:
|
||||
|
||||
- `PGU-NetFileSyncWin.exe`
|
||||
- `PGU-NetFileSyncWin.ini`
|
||||
|
||||
两个文件建议放在同一目录。`PGU-NetFileSyncWin.ini` 保存默认连接信息。
|
||||
|
||||
## 源码运行
|
||||
|
||||
建议使用本机 `clear_conda_main` 环境:
|
||||
|
||||
@@ -12,7 +51,7 @@
|
||||
C:\Users\clear\.conda\envs\clear_conda_main\python.exe -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 启动
|
||||
启动:
|
||||
|
||||
```powershell
|
||||
run_pgu4_sync.bat
|
||||
@@ -24,31 +63,44 @@ run_pgu4_sync.bat
|
||||
C:\Users\clear\.conda\envs\clear_conda_main\python.exe pgu4_sync.py
|
||||
```
|
||||
|
||||
## 主要功能
|
||||
## 配置文件
|
||||
|
||||
- 通过 SSH/SFTP 连接 Ubuntu 设备,浏览远程目录。
|
||||
- 支持局域网扫描开放 `5901` 或 `5923` 的设备,并自动映射主界面 SSH 端口。
|
||||
- 远程文件列表支持空白区域框选、多选、单击空白取消选择。
|
||||
- 可将已选远程文件或文件夹拖到已打开的 Windows 资源管理器窗口中下载。
|
||||
- 下载过程显示当前通道、实时速度、已下载量、完成用时和平均速度。
|
||||
- 下载支持暂停、继续、取消和失败重试。
|
||||
- 连接后自动检测远端极速服务,优先使用极速 HTTP Range 通道。
|
||||
- 可一键安装、升级或停止远端极速服务;安装后会写入远端用户级开机自启配置,关闭 GUI 不会停止远端常驻极速服务。
|
||||
配置文件名:
|
||||
|
||||
## 传输策略
|
||||
```text
|
||||
PGU-NetFileSyncWin.ini
|
||||
```
|
||||
|
||||
- 普通 SFTP 通道使用本机逻辑线程数作为并发上限。
|
||||
- 极速通道针对大量小文件优化,默认使用 `12` 路 HTTP 文件并发。
|
||||
- 超过 `64MB` 的大文件使用 Range 分片,单个大文件最多 `4` 路分片并发。
|
||||
- 目录扫描和下载同时进行,发现文件后立即投入下载队列,不等待完整目录树预扫描。
|
||||
- 单个分片失败会自动重试,重试耗尽后才记录为失败文件,并在任务结束后汇总提示。
|
||||
默认示例:
|
||||
|
||||
## 发布
|
||||
```ini
|
||||
[General]
|
||||
host=192.168.1.248
|
||||
port=22
|
||||
username=clear
|
||||
remote_path=/home/clear/Data
|
||||
```
|
||||
|
||||
单文件 Windows GUI 版本位于:
|
||||
说明:
|
||||
|
||||
- 配置文件位于 exe 同目录。
|
||||
- 密码使用 Windows DPAPI 加密后保存为 `password_protected`,仅当前 Windows 用户可解密。
|
||||
- 旧版 `password=` 明文配置会在启动时自动迁移并删除。
|
||||
- 删除 ini 后,程序会自动重新生成默认配置。
|
||||
- `PGU-NetFileSyncWin.ini` 已加入 `.gitignore`,不会提交到仓库。
|
||||
|
||||
## 打包
|
||||
|
||||
使用 PyInstaller 打包为单文件 Windows GUI 程序:
|
||||
|
||||
```powershell
|
||||
C:\Users\clear\.conda\envs\clear_conda_main\python.exe -m PyInstaller --clean --noconfirm --onefile --windowed --name PGU-NetFileSyncWin --icon assets\pgu_netfilesync_icon.ico --add-data "assets\pgu_netfilesync_icon.ico;assets" --add-data "assets\pgu_netfilesync_icon.png;assets" --add-binary "C:\Users\clear\.conda\envs\clear_conda_main\Library\bin\libssl-3-x64.dll;." --add-binary "C:\Users\clear\.conda\envs\clear_conda_main\Library\bin\libbz2.dll;." --add-binary "C:\Users\clear\.conda\envs\clear_conda_main\Library\bin\ffi-8.dll;." --exclude-module PyQt5.QtWebEngine --exclude-module PyQt5.QtWebEngineWidgets --exclude-module PyQt5.QtWebKit --exclude-module PyQt5.QtWebKitWidgets --exclude-module matplotlib --exclude-module numpy --exclude-module pandas --exclude-module scipy --exclude-module IPython --exclude-module jupyter --exclude-module notebook --exclude-module pytest --exclude-module tkinter pgu4_sync.py
|
||||
```
|
||||
|
||||
输出文件:
|
||||
|
||||
```text
|
||||
dist/PGU-NetFileSyncWin.exe
|
||||
```
|
||||
|
||||
该版本使用 PyInstaller `--onefile --windowed` 打包,不显示命令行窗口,并嵌入应用图标。
|
||||
发布版 INI 不应包含 `password` 或 `password_protected`。用户首次连接后,密码会在其本机生成 DPAPI 密文。
|
||||
@@ -1,273 +1,238 @@
|
||||
# PGU-NetFileSyncWin 用户说明书
|
||||
|
||||
## 1. 软件用途
|
||||
PGU-NetFileSyncWin 用于在 Windows 上连接 PGU 设备,查看设备中的文件,并把文件或文件夹下载到本机。
|
||||
|
||||
PGU-NetFileSyncWin 是一个 Windows 桌面工具,用于连接 Ubuntu 设备、浏览远端文件,并把远端文件或文件夹高速下载到 Windows 本机。它的使用方式类似精简版 WinSCP,但重点优化了大量小文件下载和拖拽到 Windows 资源管理器的工作流。
|
||||

|
||||
|
||||
## 2. 启动软件
|
||||
## 1. 准备文件
|
||||
|
||||
推荐直接运行发布版:
|
||||
|
||||
```text
|
||||
PGU-NetFileSyncWin.exe
|
||||
```
|
||||
|
||||
发布版是单文件程序,不需要打开命令行窗口。
|
||||
|
||||
配置文件建议和 exe 放在同一目录:
|
||||
请把下面两个文件放在同一个文件夹中:
|
||||
|
||||
```text
|
||||
PGU-NetFileSyncWin.exe
|
||||
PGU-NetFileSyncWin.ini
|
||||
```
|
||||
|
||||
默认配置文件内容:
|
||||
双击运行:
|
||||
|
||||
```text
|
||||
PGU-NetFileSyncWin.exe
|
||||
```
|
||||
|
||||
程序不会打开命令行窗口。
|
||||
|
||||
## 2. 配置连接信息
|
||||
|
||||
连接信息保存在 `PGU-NetFileSyncWin.ini` 中。默认内容如下:
|
||||
|
||||
```ini
|
||||
[General]
|
||||
host=192.168.1.248
|
||||
port=22
|
||||
username=clear
|
||||
password=Bone1644
|
||||
remote_path=/home/clear/Data
|
||||
```
|
||||
|
||||
密码按当前版本设计为明文保存。如果要清空连接记录,删除或编辑这个 ini 文件即可。
|
||||
IP、端口、用户名和远程路径可以直接用记事本修改。密码请在软件界面中输入。
|
||||
|
||||
如果使用源码运行:
|
||||
常用字段说明:
|
||||
|
||||
```powershell
|
||||
run_pgu4_sync.bat
|
||||
```
|
||||
- `host`:PGU 设备 IP。
|
||||
- `port`:连接端口。
|
||||
- `username`:用户名。
|
||||
- `remote_path`:打开软件后默认进入的远程路径。
|
||||
|
||||
## 3. 主界面区域
|
||||
密码保存后会写入 `password_protected`,内容由 Windows DPAPI 加密,仅当前 Windows 用户可以解密。旧版 INI 中的明文 `password` 会在启动时自动迁移并删除。
|
||||
|
||||
### 顶部连接区
|
||||
## 3. 连接 PGU 设备
|
||||
|
||||
顶部包含:
|
||||
1. 打开软件。
|
||||
2. 点击左侧的 `设备与连接设置`。
|
||||
3. 检查 IP、端口、用户名和密码,点击 `保存并连接`。
|
||||
4. 也可以直接点击左侧 `连接设备`,使用已经保存的连接信息。
|
||||
5. 连接成功后,按钮会变成 `断开连接`。
|
||||
6. 文件列表会显示 PGU 设备中的远程目录。
|
||||
|
||||
- `扫描`:扫描局域网设备。
|
||||
- `IP`:Ubuntu 设备 IP 地址。
|
||||
- `端口`:SSH 端口。
|
||||
- `用户`:Ubuntu 用户名。
|
||||
- `密码`:Ubuntu 密码。
|
||||
- `连接` / `连接中...` / `已连接`:连接状态按钮。
|
||||
如果不知道 PGU 设备 IP,请进入 `设备与连接设置` 后点击 `扫描局域网设备`。
|
||||
|
||||
连接按钮颜色含义:
|
||||
## 4. 扫描 PGU 设备
|
||||
|
||||
- 蓝色:未连接,可点击连接。
|
||||
- 灰蓝色:正在连接。
|
||||
- 绿色:已连接。
|
||||
点击 `设备与连接设置`,再点击 `扫描局域网设备`。
|
||||
|
||||
### 远程路径区
|
||||
操作步骤:
|
||||
|
||||
远程路径区包含:
|
||||
1. 选择本机网卡。
|
||||
2. 开始扫描。
|
||||
3. 在结果列表中选择 PGU 设备。
|
||||
4. 点击使用选中的 IP。
|
||||
5. 返回连接设置后点击 `保存并连接`。
|
||||
|
||||
- `远程`:当前远端目录路径。
|
||||
- `上级`:返回上一级目录。
|
||||
- `刷新`:刷新当前远端目录。
|
||||
- 路径输入框:可直接输入远端路径,例如 `/home/clear/Data`。
|
||||
- `本地`
|
||||
- `下载到...`:选择一个本地目录下载当前选中的远端文件或文件夹。
|
||||
扫描会检查 `5901`、`5923` 和 `8080` 端口,并在结果中标明设备类型:
|
||||
|
||||
### 远程文件列表
|
||||
- 开放 `5901` 或 `5923`:显示为 `PGU4`。
|
||||
- 开放 `8080`:显示为 `PGU5`。
|
||||
|
||||
文件列表显示:
|
||||
选择设备后,软件会自动填写:
|
||||
|
||||
- PGU4:用户 `clear`,密码自动填写,默认路径 `/home/clear/Data`。
|
||||
- PGU5:用户 `root`,密码自动填写,默认路径 `/nvme-data/pgu_data`。
|
||||
|
||||
密码始终使用掩码显示,保存后由 Windows DPAPI 加密。
|
||||
|
||||
## 5. 浏览远程文件
|
||||
|
||||
文件列表中会显示:
|
||||
|
||||
- 名称
|
||||
- 大小
|
||||
- 修改时间
|
||||
- 类型
|
||||
|
||||
操作方式:
|
||||
常用操作:
|
||||
|
||||
- 双击文件夹:进入文件夹。
|
||||
- 单击文件或文件夹:选中。
|
||||
- `Ctrl` 或 `Shift` 配合鼠标:多选。
|
||||
- 从左侧空白列、右侧空白区或底部空白区拖动:框选多项。
|
||||
- 双击文件夹:进入该文件夹。
|
||||
- 点击向上箭头:返回上一层。
|
||||
- 点击刷新图标:重新读取当前目录。
|
||||
- 点击前进、后退图标:切换访问过的目录。
|
||||
- 在远程路径输入框中输入路径后按回车:进入指定目录。
|
||||
- 使用右上角搜索框:筛选当前目录。
|
||||
|
||||
常用快捷键:
|
||||
|
||||
- `Ctrl+F`:搜索。
|
||||
- `F5`:刷新。
|
||||
- `Alt+Left` / `Alt+Right`:后退、前进。
|
||||
- `Alt+Up`:上级目录。
|
||||
- `Enter`:打开选中的文件夹。
|
||||
- `Delete`:永久删除选中项。
|
||||
- `Esc`:清除搜索或选择。
|
||||
|
||||
访问 `/root` 或其子目录时,软件会要求再次输入当前 SSH 连接密码。密码错误或取消验证时不会进入该目录。离开 `/root` 后,再次进入需要重新验证。
|
||||
|
||||
## 6. 选择文件
|
||||
|
||||
支持以下选择方式:
|
||||
|
||||
- 单击一行:选择一个文件或文件夹。
|
||||
- 按住 `Ctrl` 单击:选择多个项目。
|
||||
- 按住 `Shift` 单击:连续选择。
|
||||
- 从左侧空白列、右侧空白区或底部空白区拖动:框选多个项目。
|
||||
- 单击空白区域:取消当前选择。
|
||||
- 从已选中文件行拖出 GUI:拖到已打开的 Windows 资源管理器窗口中下载。
|
||||
|
||||
### 底部传输区
|
||||
## 7. 下载文件
|
||||
|
||||
底部包含:
|
||||
### 方法一:下载到指定目录
|
||||
|
||||
- 速度条:显示实时下载速度,`100MB/s` 为满格。
|
||||
- 速度文本:显示当前通道和实时速度。
|
||||
- `安装极速` / `升级极速` / `停止极速`:远端极速服务控制按钮。
|
||||
- `暂停`:暂停当前下载。
|
||||
- `取消`:取消当前下载。
|
||||
|
||||
极速按钮颜色含义:
|
||||
|
||||
- 绿色:可安装极速服务。
|
||||
- 黄色:检测到旧服务,可升级。
|
||||
- 红色:极速服务已运行,可停止。
|
||||
- 灰蓝色:正在检测、安装、升级或停止。
|
||||
|
||||
下载完成后,速度条会拉满并变为绿色,显示:
|
||||
|
||||
- 已下载总量
|
||||
- 用时
|
||||
- 平均速度
|
||||
- 失败文件数量,如果有
|
||||
|
||||
## 4. 连接远端设备
|
||||
|
||||
1. 输入设备 IP。
|
||||
2. 输入 SSH 端口,常见为 `22`。
|
||||
3. 输入用户名。
|
||||
4. 输入密码。
|
||||
5. 点击 `连接`。
|
||||
|
||||
连接成功后:
|
||||
|
||||
- 按钮变为 `已连接`。
|
||||
- 软件自动读取远程路径。
|
||||
- 软件自动检测远端极速服务。
|
||||
|
||||
连接信息会自动记忆,下次打开软件会自动填入。
|
||||
|
||||
这些连接信息保存在程序同目录的 `PGU-NetFileSyncWin.ini`,不是写入 exe 内部,也不是保存到注册表。
|
||||
|
||||
## 5. 扫描局域网设备
|
||||
|
||||
点击 `扫描` 后会弹出扫描窗口。
|
||||
|
||||
使用步骤:
|
||||
|
||||
1. 在下拉框中选择本机网卡。
|
||||
2. 点击开始扫描。
|
||||
3. 等待列表出现在线设备。
|
||||
4. 选择设备。
|
||||
5. 点击使用选中 IP。
|
||||
|
||||
端口映射规则:
|
||||
|
||||
- 如果设备开放 `5923`,主界面 SSH 端口自动设置为 `5922`。
|
||||
- 如果设备开放 `5901`,主界面 SSH 端口自动设置为 `22`。
|
||||
|
||||
## 6. 下载文件
|
||||
|
||||
### 方式一:下载到指定目录
|
||||
|
||||
1. 在远程文件列表中选择一个或多个文件/文件夹。
|
||||
2. 点击 `下载到...`。
|
||||
3. 选择本地目录。
|
||||
1. 在文件列表中选择文件或文件夹。
|
||||
2. 点击 `下载`。
|
||||
3. 选择本机目录。
|
||||
4. 软件开始下载。
|
||||
|
||||
### 方式二:拖拽到 Windows 资源管理器
|
||||
### 方法二:拖拽到 Windows 资源管理器
|
||||
|
||||
1. 打开 Windows 资源管理器,并进入目标文件夹。
|
||||
2. 在软件中选中远端文件或文件夹。
|
||||
3. 从选中文件行拖出软件窗口。
|
||||
1. 先打开 Windows 资源管理器,并进入目标文件夹。
|
||||
2. 在软件中选择要下载的远程文件或文件夹。
|
||||
3. 从选中的文件行拖出软件窗口。
|
||||
4. 拖到资源管理器窗口中松开鼠标。
|
||||
5. 软件识别目标目录并开始下载。
|
||||
5. 软件会自动开始下载到该目录。
|
||||
|
||||
注意:请先打开目标资源管理器窗口,再从软件中拖出。
|
||||
注意:框选空白区是用来选择文件的,不是拖出下载。请从已选中的文件行拖出。
|
||||
|
||||
## 7. 极速服务
|
||||
## 8. 删除远程内容
|
||||
|
||||
极速服务是安装在远端 Ubuntu 设备上的轻量 HTTP Range 下载服务,用于提高大量小文件和大文件下载速度。
|
||||
选中一个或多个文件、目录后点击鼠标右键,选择 `永久删除选中项`。
|
||||
|
||||
### 安装极速
|
||||
软件会先弹窗要求二次确认。确认后,PGU 设备会在本机文件系统中批量永久删除选中内容,非空目录会连同内部文件一起删除,不会进入远程回收站。弹窗会显示选中项删除进度,完成后远程目录会自动刷新。
|
||||
|
||||
连接远端后,如果未检测到极速服务,按钮显示 `安装极速`。
|
||||
如果目录正在被其他程序写入,软件会自动进行多次重试。持续写入仍可能导致删除失败,此时应先停止占用该目录的程序,再重新删除。
|
||||
|
||||
点击后软件会通过 SSH 自动安装并启动远端服务。
|
||||
删除操作无法撤销,请在确认前仔细检查选中的内容。
|
||||
|
||||
### 升级极速
|
||||
## 9. 极速下载
|
||||
|
||||
如果检测到旧版本或旧 token 服务,按钮显示 `升级极速`。
|
||||
左侧高速通道区域会显示当前极速服务状态:
|
||||
|
||||
点击后软件会替换旧服务并启动新服务。
|
||||
- `极速 v3`:当前已安装并运行 V3 极速服务。
|
||||
- `安装极速`:PGU 设备上还没有极速服务,点击后安装。
|
||||
- `极速 v2 · 可升级`:检测到旧版本,点击 `升级极速` 后更新。
|
||||
- `升级极速`:检测到旧服务,点击后升级。
|
||||
- `修复极速`:当前版本已安装但自动启动失败,点击后修复。
|
||||
- `停止极速`:极速服务正在运行,点击后停止。
|
||||
|
||||
### 停止极速
|
||||
一般情况下,连接 PGU 设备后,如果极速服务可用,软件会自动使用极速通道下载。
|
||||
|
||||
如果极速服务正在运行,按钮显示 `停止极速`。
|
||||
安装极速服务后,PGU 设备重启时会通过 systemd 和单条 crontab 备用项自动启动。软件会清理重复旧自启命令;当前版本没有运行时会先自动恢复,不会误报为旧版本。
|
||||
|
||||
点击后会停止远端极速服务,并取消开机自启。
|
||||
极速 V3 支持中文远程目录、中文文件名、空格以及中文 Windows 下载目录;同时按远端 CPU 数量限制服务并发,使用 HTTP 长连接减少大量小文件下载时的波动。
|
||||
|
||||
### 开机自启
|
||||
## 10. 下载过程
|
||||
|
||||
安装或升级极速服务后,软件会配置远端开机自启:
|
||||
下载时可以使用:
|
||||
|
||||
- 优先使用用户级 `systemd --user` 服务。
|
||||
- 同时写入 `crontab @reboot` 作为兜底。
|
||||
- `暂停`:暂停当前下载。
|
||||
- `继续`:恢复下载。
|
||||
- `取消`:取消当前下载。
|
||||
|
||||
关闭 Windows GUI 不会停止远端极速服务。下次连接时如果服务正常运行,软件会自动启用极速通道。
|
||||
下载过程中如果某个文件短暂超时,软件会自动重试,不会立即弹窗打断下载。
|
||||
|
||||
## 8. 下载过程控制
|
||||
如果最终仍有文件失败,软件会在下载结束后统一提示。
|
||||
|
||||
### 暂停
|
||||
下载结束后会弹出结果窗口,显示:
|
||||
|
||||
点击 `暂停` 后,下载任务会暂停。按钮文字变为 `继续`。
|
||||
- 是否全部成功或被取消。
|
||||
- 已传输大小和计划大小。
|
||||
- 总用时和平均速度。
|
||||
- 失败项目数量及失败原因。
|
||||
|
||||
再次点击可继续下载。
|
||||
结果窗口关闭后,底部速度条会自动恢复为 0,等待下一次下载。
|
||||
|
||||
### 取消
|
||||
## 11. 速度条
|
||||
|
||||
点击 `取消` 后,当前下载任务会尽快停止。
|
||||
底部进度条是速度条,不是总进度条。
|
||||
|
||||
### 失败重试
|
||||
|
||||
单个文件或分片出现 timeout、连接中断等问题时,软件会自动重试。
|
||||
|
||||
重试期间不会弹窗打断下载。
|
||||
|
||||
如果重试后仍失败,软件会继续下载其他文件,并在任务结束后汇总失败文件。
|
||||
|
||||
## 9. 速度显示
|
||||
|
||||
速度条不是总进度条,而是实时速度条:
|
||||
|
||||
- `100MB/s` 显示为满格。
|
||||
- 超过 `100MB/s` 仍按满格显示。
|
||||
- `100MB/s` 对应满格。
|
||||
- 下载过程中为蓝色。
|
||||
- 下载完成后变为绿色并拉满。
|
||||
- 下载结果弹窗关闭后自动清零,恢复为等待下一次下载的状态。
|
||||
|
||||
完成后会显示:
|
||||
下载完成后会显示:
|
||||
|
||||
```text
|
||||
下载完成 1.7 GB 用时 34s 平均 51.2 MB/s
|
||||
```
|
||||
|
||||
## 10. 常见问题
|
||||
## 12. 自动更新
|
||||
|
||||
### 图标没有立刻变化
|
||||
软件启动后会在后台静默检查 Gitea 是否有新版本:
|
||||
|
||||
Windows 可能缓存旧 exe 图标。请确认运行的是新版 exe。如果任务栏固定了旧版本,请取消固定后重新固定新版 exe。
|
||||
- 没有新版本或网络不可用:不弹窗,正常使用。
|
||||
- 发现新版本:弹窗询问是否立即升级。
|
||||
- 确认升级:自动下载新版并重启软件。
|
||||
- 也可以点击左侧底部的 `检查更新` 手动检查。
|
||||
|
||||
升级时会保留现有 IP、端口、账号、密码和远程路径。新版配置文件中新增的配置项可以自动补入,但已有配置值不会被覆盖。
|
||||
|
||||
## 13. 常见问题
|
||||
|
||||
### 打开后连接信息不对
|
||||
|
||||
请检查 exe 同目录的 `PGU-NetFileSyncWin.ini`。
|
||||
|
||||
### 想清空连接信息
|
||||
|
||||
删除 `PGU-NetFileSyncWin.ini`,再重新打开软件。软件会生成默认配置。
|
||||
|
||||
### 图标没有变化
|
||||
|
||||
Windows 可能缓存旧图标。请确认运行的是新版 exe。如果任务栏固定了旧版本,请取消固定后重新固定。
|
||||
|
||||
### 拖拽没有开始下载
|
||||
|
||||
请确认:
|
||||
请确认已经打开 Windows 资源管理器目标文件夹,并且是从已选中的文件行拖出软件窗口。
|
||||
|
||||
- 已经选中了远程文件或文件夹。
|
||||
- 已经打开 Windows 资源管理器目标文件夹。
|
||||
- 是从选中文件行拖出 GUI,而不是从空白框选区域拖出。
|
||||
### 想停止 PGU 设备上的极速服务
|
||||
|
||||
### 下载过程中出现 timeout
|
||||
连接 PGU 设备后,点击左侧高速通道区域的 `停止极速`。
|
||||
|
||||
软件会自动重试。只有重试耗尽后,才会在下载结束时汇总失败。
|
||||
### 软件关闭后 PGU 设备上的极速服务还在
|
||||
|
||||
### 点击右上角关闭后进程仍在
|
||||
|
||||
新版已处理关闭清理。关闭 GUI 会取消本地下载任务并退出本地进程,但不会停止远端极速服务。
|
||||
|
||||
### 想停止远端极速服务
|
||||
|
||||
连接远端后,点击底部 `停止极速`。这会停止远端服务并取消开机自启。
|
||||
|
||||
## 11. 卸载远端极速服务
|
||||
|
||||
在 GUI 中连接远端,然后点击 `停止极速` 即可。
|
||||
|
||||
如果需要手工清理远端文件,可在 Ubuntu 上执行:
|
||||
|
||||
```bash
|
||||
systemctl --user disable --now pgu4-sync-fast.service 2>/dev/null || true
|
||||
crontab -l 2>/dev/null | grep -v 'pgu4_fast_http.py --host 0.0.0.0 --port 5924' | crontab -
|
||||
pkill -f '[p]gu4_fast_http.py --port 5924' || true
|
||||
rm -f ~/.config/systemd/user/pgu4-sync-fast.service
|
||||
rm -rf ~/.local/share/pgu4_sync ~/.local/state/pgu4_sync
|
||||
```
|
||||
这是正常行为。关闭 Windows 软件不会停止 PGU 设备上的极速服务。需要停止时请点击 `停止极速`。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
+3240
-266
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,195 @@
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt5.QtCore import QItemSelectionModel
|
||||
from PyQt5.QtWidgets import QApplication, QMessageBox, QStatusBar
|
||||
|
||||
from pgu4_sync import ConnectionDialog, MainWindow, RemoteEntry, adaptive_worker_count
|
||||
|
||||
|
||||
class CommercialUiTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def test_filtered_selection_maps_to_source_entry(self):
|
||||
window = MainWindow()
|
||||
self.addCleanup(window.close)
|
||||
window.remote_model.set_entries(
|
||||
[
|
||||
RemoteEntry(
|
||||
"alpha",
|
||||
"/data/alpha",
|
||||
0,
|
||||
1,
|
||||
stat.S_IFDIR | 0o755,
|
||||
),
|
||||
RemoteEntry(
|
||||
"beta.txt",
|
||||
"/data/beta.txt",
|
||||
100,
|
||||
1,
|
||||
stat.S_IFREG | 0o644,
|
||||
),
|
||||
RemoteEntry(
|
||||
"gamma.txt",
|
||||
"/data/gamma.txt",
|
||||
200,
|
||||
1,
|
||||
stat.S_IFREG | 0o644,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
window.search_edit.setText("gamma")
|
||||
self.assertEqual(1, window.remote_proxy.rowCount())
|
||||
index = window.remote_proxy.index(0, 1)
|
||||
window.remote_table.selectionModel().select(
|
||||
index,
|
||||
QItemSelectionModel.Select | QItemSelectionModel.Rows,
|
||||
)
|
||||
|
||||
selected = window.selected_remote_entries()
|
||||
self.assertEqual(["/data/gamma.txt"], [entry.path for entry in selected])
|
||||
|
||||
def test_disconnect_resets_browser_state(self):
|
||||
window = MainWindow()
|
||||
self.addCleanup(window.close)
|
||||
window._connected = True
|
||||
window.remote_model.set_entries(
|
||||
[
|
||||
RemoteEntry(
|
||||
"file.txt",
|
||||
"/data/file.txt",
|
||||
100,
|
||||
1,
|
||||
stat.S_IFREG | 0o644,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
window.disconnect_remote()
|
||||
|
||||
self.assertFalse(window._connected)
|
||||
self.assertEqual(0, window.remote_model.rowCount())
|
||||
self.assertEqual("尚未连接", window.item_count_label.text())
|
||||
self.assertEqual("连接设备", window.connect_btn.text())
|
||||
|
||||
def test_adaptive_font_and_disconnected_controls(self):
|
||||
window = MainWindow()
|
||||
self.addCleanup(window.close)
|
||||
|
||||
self.assertGreaterEqual(window.font().pointSizeF(), 10.5)
|
||||
self.assertFalse(window.remote_path_edit.isEnabled())
|
||||
self.assertFalse(window.search_edit.isEnabled())
|
||||
self.assertFalse(window.refresh_btn.isEnabled())
|
||||
self.assertFalse(window.download_btn.isEnabled())
|
||||
|
||||
def test_path_history_truncates_forward_branch(self):
|
||||
window = MainWindow()
|
||||
self.addCleanup(window.close)
|
||||
window._connected = True
|
||||
window.record_history_path("/home")
|
||||
window.record_history_path("/home/clear")
|
||||
window.record_history_path("/home/clear/Data")
|
||||
window._history_index = 1
|
||||
|
||||
window.record_history_path("/tmp")
|
||||
|
||||
self.assertEqual(["/home", "/home/clear", "/tmp"], window._path_history)
|
||||
self.assertEqual(2, window._history_index)
|
||||
|
||||
def test_connection_password_cannot_be_revealed(self):
|
||||
dialog = ConnectionDialog(
|
||||
"192.168.1.248",
|
||||
22,
|
||||
"clear",
|
||||
"secret-password",
|
||||
)
|
||||
self.addCleanup(dialog.close)
|
||||
|
||||
self.assertEqual(dialog.pass_edit.Password, dialog.pass_edit.echoMode())
|
||||
self.assertFalse(hasattr(dialog, "show_password"))
|
||||
|
||||
def test_main_window_has_single_scan_entry_point(self):
|
||||
window = MainWindow()
|
||||
self.addCleanup(window.close)
|
||||
|
||||
self.assertFalse(hasattr(window, "scan_btn"))
|
||||
self.assertEqual("PGU NetFileSync", window.windowTitle())
|
||||
|
||||
dialog = ConnectionDialog("", 22, "", "", window)
|
||||
self.addCleanup(dialog.close)
|
||||
self.assertTrue(dialog.scan_btn.isVisibleTo(dialog))
|
||||
|
||||
def test_status_messages_are_integrated_into_file_footer(self):
|
||||
window = MainWindow()
|
||||
self.addCleanup(window.close)
|
||||
|
||||
self.assertEqual([], window.findChildren(QStatusBar))
|
||||
window.statusBar().showMessage("Unicode 状态:中文目录", 0)
|
||||
self.assertEqual("Unicode 状态:中文目录", window.activity_label.text())
|
||||
|
||||
def test_scanned_pgu4_fills_connection_defaults(self):
|
||||
dialog = ConnectionDialog("", 22, "", "")
|
||||
self.addCleanup(dialog.close)
|
||||
|
||||
dialog.set_scanned_device("192.168.1.40", 5922, "PGU4")
|
||||
|
||||
self.assertEqual("192.168.1.40", dialog.host_edit.text())
|
||||
self.assertEqual(5922, dialog.port_spin.value())
|
||||
self.assertEqual("clear", dialog.user_edit.text())
|
||||
self.assertEqual("Bone1644", dialog.pass_edit.text())
|
||||
self.assertEqual("/home/clear/Data", dialog.selected_remote_path)
|
||||
|
||||
def test_scanned_pgu5_fills_connection_defaults(self):
|
||||
dialog = ConnectionDialog("", 22, "", "")
|
||||
self.addCleanup(dialog.close)
|
||||
|
||||
dialog.set_scanned_device("192.168.1.50", 22, "PGU5")
|
||||
|
||||
self.assertEqual("192.168.1.50", dialog.host_edit.text())
|
||||
self.assertEqual(22, dialog.port_spin.value())
|
||||
self.assertEqual("root", dialog.user_edit.text())
|
||||
self.assertEqual("Clear8492..", dialog.pass_edit.text())
|
||||
self.assertEqual("/nvme-data/pgu_data", dialog.selected_remote_path)
|
||||
|
||||
def test_worker_count_uses_smaller_local_or_remote_cpu_count(self):
|
||||
self.assertEqual(8, adaptive_worker_count(32, 8))
|
||||
self.assertEqual(6, adaptive_worker_count(6, 8))
|
||||
self.assertEqual(32, adaptive_worker_count(32, 0))
|
||||
|
||||
def test_fast_service_version_is_visible_and_upgradeable(self):
|
||||
window = MainWindow()
|
||||
self.addCleanup(window.close)
|
||||
|
||||
window.set_fast_button_state("stop", "3")
|
||||
self.assertEqual("极速 v3", window.fast_status_badge.text())
|
||||
|
||||
window.set_fast_button_state("upgrade", "2")
|
||||
self.assertEqual("极速 v2 · 可升级", window.fast_status_badge.text())
|
||||
|
||||
def test_download_completion_reports_metrics_and_clears_speed_meter(self):
|
||||
window = MainWindow()
|
||||
self.addCleanup(window.close)
|
||||
window.controller = object()
|
||||
window._active_download = True
|
||||
window._download_start_time = time.monotonic() - 2
|
||||
window._total_bytes = 2048
|
||||
window._done_bytes = 2048
|
||||
window._download_errors = []
|
||||
|
||||
with patch.object(QMessageBox, "information") as information:
|
||||
window.on_download_all_done(False)
|
||||
|
||||
information.assert_called_once()
|
||||
message = information.call_args.args[2]
|
||||
self.assertIn("用时", message)
|
||||
self.assertIn("平均", message)
|
||||
self.assertEqual(0, window.progress.value())
|
||||
self.assertEqual("等待下载", window.transfer_detail_label.text())
|
||||
@@ -0,0 +1,204 @@
|
||||
import stat
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pgu4_sync import (
|
||||
DELETE_BATCH_ITEMS,
|
||||
DELETE_RETRIES,
|
||||
DeleteWorker,
|
||||
RemoteEntry,
|
||||
)
|
||||
|
||||
|
||||
class FakeChannel:
|
||||
def __init__(self, status):
|
||||
self.status = status
|
||||
|
||||
def recv_exit_status(self):
|
||||
return self.status
|
||||
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self, status=0, data=b""):
|
||||
self.channel = FakeChannel(status)
|
||||
self.data = data
|
||||
|
||||
def read(self):
|
||||
return self.data
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, responder=None):
|
||||
self.commands = []
|
||||
self.responder = responder or (lambda _command: (0, b""))
|
||||
|
||||
def exec_command(self, command):
|
||||
self.commands.append(command)
|
||||
status, error = self.responder(command)
|
||||
return None, FakeStream(status), FakeStream(status, error)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeFactory:
|
||||
def __init__(self, client=None, error=None):
|
||||
self.client = client
|
||||
self.error = error
|
||||
|
||||
def open_client(self):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.client
|
||||
|
||||
|
||||
def entry(path, is_dir=False):
|
||||
mode = stat.S_IFDIR if is_dir else stat.S_IFREG
|
||||
return RemoteEntry(path.rsplit("/", 1)[-1], path, 0, 0, mode)
|
||||
|
||||
|
||||
class DeleteWorkerTests(unittest.TestCase):
|
||||
def run_worker(self, worker):
|
||||
failures = []
|
||||
progress = []
|
||||
finished = []
|
||||
worker.failed.connect(lambda path, message: failures.append((path, message)))
|
||||
worker.progress.connect(lambda done, total, path: progress.append((done, total, path)))
|
||||
worker.finished.connect(lambda deleted, failed: finished.append((deleted, failed)))
|
||||
worker.run()
|
||||
return failures, progress, finished
|
||||
|
||||
def test_quotes_paths_and_uses_permanent_rm(self):
|
||||
client = FakeClient()
|
||||
worker = DeleteWorker(
|
||||
FakeFactory(client),
|
||||
[entry("/tmp/space dir", True), entry("/tmp/quote'file")],
|
||||
)
|
||||
|
||||
failures, progress, finished = self.run_worker(worker)
|
||||
|
||||
self.assertEqual([], failures)
|
||||
self.assertEqual([(2, 0)], finished)
|
||||
self.assertEqual((2, 2), progress[-1][:2])
|
||||
self.assertEqual(1, len(client.commands))
|
||||
self.assertTrue(
|
||||
client.commands[0].startswith(
|
||||
"rm -rf -- '/tmp/space dir' '/tmp/quote'\"'\"'file'"
|
||||
)
|
||||
)
|
||||
self.assertIn("Path still exists after removal", client.commands[0])
|
||||
|
||||
def test_rejects_root_and_noncanonical_paths(self):
|
||||
unsafe_paths = [
|
||||
"/",
|
||||
"//",
|
||||
"////",
|
||||
"/tmp/../etc",
|
||||
"/tmp//item",
|
||||
"relative",
|
||||
"",
|
||||
"/tmp/item\x00tail",
|
||||
]
|
||||
factory = FakeFactory(error=AssertionError("SSH must not be opened"))
|
||||
|
||||
for path in unsafe_paths:
|
||||
with self.subTest(path=path):
|
||||
failures, progress, finished = self.run_worker(
|
||||
DeleteWorker(factory, [entry(path, True)])
|
||||
)
|
||||
self.assertEqual(1, len(failures))
|
||||
self.assertEqual([(0, 1)], finished)
|
||||
self.assertEqual((1, 1), progress[-1][:2])
|
||||
|
||||
def test_splits_large_selections_into_batches(self):
|
||||
client = FakeClient()
|
||||
entries = [entry(f"/tmp/item_{index:04d}") for index in range(300)]
|
||||
worker = DeleteWorker(FakeFactory(client), entries)
|
||||
|
||||
failures, progress, finished = self.run_worker(worker)
|
||||
|
||||
self.assertEqual([], failures)
|
||||
self.assertEqual([(300, 0)], finished)
|
||||
self.assertEqual(3, len(client.commands))
|
||||
self.assertEqual((300, 300), progress[-1][:2])
|
||||
self.assertEqual(
|
||||
[DELETE_BATCH_ITEMS, DELETE_BATCH_ITEMS, 44],
|
||||
[
|
||||
command.split(" && if ", 1)[0].count("/tmp/item_")
|
||||
for command in client.commands
|
||||
],
|
||||
)
|
||||
|
||||
@patch("pgu4_sync.time.sleep")
|
||||
def test_batch_failure_falls_back_to_individual_paths(self, _sleep_mock):
|
||||
def responder(command):
|
||||
remove_command = command.split(" && if ", 1)[0]
|
||||
if remove_command.count("/tmp/") > 1:
|
||||
return 1, b"batch failed"
|
||||
if "/tmp/bad" in remove_command:
|
||||
return 1, b"permission denied"
|
||||
return 0, b""
|
||||
|
||||
client = FakeClient(responder)
|
||||
worker = DeleteWorker(
|
||||
FakeFactory(client),
|
||||
[entry("/tmp/good"), entry("/tmp/bad")],
|
||||
)
|
||||
|
||||
failures, _progress, finished = self.run_worker(worker)
|
||||
|
||||
self.assertEqual(1, len(failures))
|
||||
self.assertEqual("/tmp/bad", failures[0][0])
|
||||
self.assertIn("permission denied", failures[0][1])
|
||||
self.assertIn(f"重试 {DELETE_RETRIES} 次", failures[0][1])
|
||||
self.assertEqual([(1, 1)], finished)
|
||||
self.assertGreaterEqual(len(client.commands), DELETE_RETRIES + 2)
|
||||
|
||||
@patch("pgu4_sync.time.sleep")
|
||||
def test_transient_directory_not_empty_is_retried(self, sleep_mock):
|
||||
attempts = {"count": 0}
|
||||
|
||||
def responder(_command):
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] < 3:
|
||||
return 1, b"Directory not empty"
|
||||
return 0, b""
|
||||
|
||||
client = FakeClient(responder)
|
||||
worker = DeleteWorker(FakeFactory(client), [entry("/tmp/busy", True)])
|
||||
|
||||
failures, progress, finished = self.run_worker(worker)
|
||||
|
||||
self.assertEqual([], failures)
|
||||
self.assertEqual([(1, 0)], finished)
|
||||
self.assertEqual((1, 1), progress[-1][:2])
|
||||
self.assertEqual(3, attempts["count"])
|
||||
self.assertEqual(2, sleep_mock.call_count)
|
||||
|
||||
@patch("pgu4_sync.time.sleep")
|
||||
def test_persistent_directory_not_empty_has_clear_message(self, _sleep_mock):
|
||||
client = FakeClient(lambda _command: (1, b"Directory not empty"))
|
||||
worker = DeleteWorker(FakeFactory(client), [entry("/tmp/busy", True)])
|
||||
|
||||
failures, _progress, finished = self.run_worker(worker)
|
||||
|
||||
self.assertEqual([(0, 1)], finished)
|
||||
self.assertEqual(1, len(failures))
|
||||
self.assertIn("其他程序正在持续写入", failures[0][1])
|
||||
self.assertIn(f"重试 {DELETE_RETRIES} 次", failures[0][1])
|
||||
|
||||
def test_connection_failure_reports_every_selected_item(self):
|
||||
worker = DeleteWorker(
|
||||
FakeFactory(error=TimeoutError("connect timeout")),
|
||||
[entry("/tmp/a"), entry("/tmp/b")],
|
||||
)
|
||||
|
||||
failures, progress, finished = self.run_worker(worker)
|
||||
|
||||
self.assertEqual({"/tmp/a", "/tmp/b"}, {path for path, _message in failures})
|
||||
self.assertEqual((2, 2), progress[-1][:2])
|
||||
self.assertEqual([(0, 2)], finished)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
import inspect
|
||||
import unittest
|
||||
|
||||
from pgu4_sync import (
|
||||
FAST_HTTP_SERVER_SCRIPT,
|
||||
CheckFastServiceWorker,
|
||||
FastServiceWorker,
|
||||
)
|
||||
|
||||
|
||||
class FastServiceRecoveryTests(unittest.TestCase):
|
||||
def make_worker(self):
|
||||
return CheckFastServiceWorker(None, "192.0.2.1", 5924, "token")
|
||||
|
||||
def test_current_service_is_auto_restarted_after_reboot(self):
|
||||
worker = self.make_worker()
|
||||
worker._healthcheck = lambda: False
|
||||
worker._installed_service_state = lambda: "current"
|
||||
worker._restart_current_service = lambda: True
|
||||
available = []
|
||||
legacy = []
|
||||
repair = []
|
||||
worker.available.connect(lambda *args: available.append(args))
|
||||
worker.legacy.connect(legacy.append)
|
||||
worker.repair.connect(repair.append)
|
||||
|
||||
worker.run()
|
||||
|
||||
self.assertEqual(1, len(available))
|
||||
self.assertEqual([], legacy)
|
||||
self.assertEqual([], repair)
|
||||
|
||||
def test_current_service_reports_repair_not_upgrade_when_restart_fails(self):
|
||||
worker = self.make_worker()
|
||||
worker._healthcheck = lambda: False
|
||||
worker._installed_service_state = lambda: "current"
|
||||
worker._restart_current_service = lambda: False
|
||||
legacy = []
|
||||
repair = []
|
||||
worker.legacy.connect(legacy.append)
|
||||
worker.repair.connect(repair.append)
|
||||
|
||||
worker.run()
|
||||
|
||||
self.assertEqual([], legacy)
|
||||
self.assertEqual(1, len(repair))
|
||||
|
||||
def test_only_old_script_reports_upgrade(self):
|
||||
worker = self.make_worker()
|
||||
worker._healthcheck = lambda: False
|
||||
worker._installed_service_state = lambda: "legacy"
|
||||
legacy = []
|
||||
repair = []
|
||||
worker.legacy.connect(legacy.append)
|
||||
worker.repair.connect(repair.append)
|
||||
|
||||
worker.run()
|
||||
|
||||
self.assertEqual(1, len(legacy))
|
||||
self.assertEqual([], repair)
|
||||
|
||||
def test_service_script_has_persistent_version_marker(self):
|
||||
self.assertIn('PGU_FAST_SERVICE_VERSION = "3"', FAST_HTTP_SERVER_SCRIPT)
|
||||
self.assertIn('protocol_version = "HTTP/1.1"', FAST_HTTP_SERVER_SCRIPT)
|
||||
self.assertIn("request_queue_size", FAST_HTTP_SERVER_SCRIPT)
|
||||
self.assertIn("BoundedSemaphore", FAST_HTTP_SERVER_SCRIPT)
|
||||
self.assertIn("X-PGU-Worker-Limit", FAST_HTTP_SERVER_SCRIPT)
|
||||
source = inspect.getsource(FastServiceWorker)
|
||||
self.assertIn("recv_exit_status", source)
|
||||
self.assertIn("pgu4_fast_http.py.*--port", source)
|
||||
self.assertIn("enable-linger", source)
|
||||
@@ -0,0 +1,63 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import QSettings
|
||||
|
||||
from pgu4_sync import (
|
||||
PASSWORD_LEGACY_KEY,
|
||||
PASSWORD_PROTECTED_KEY,
|
||||
load_password_setting,
|
||||
protect_password,
|
||||
save_password_setting,
|
||||
unprotect_password,
|
||||
)
|
||||
|
||||
|
||||
class PasswordStorageTests(unittest.TestCase):
|
||||
def test_dpapi_round_trip_is_not_plaintext(self):
|
||||
password = "test-password-123"
|
||||
|
||||
protected = protect_password(password)
|
||||
|
||||
self.assertNotEqual(password, protected)
|
||||
self.assertNotIn(password, protected)
|
||||
self.assertEqual(password, unprotect_password(protected))
|
||||
|
||||
def test_legacy_plaintext_is_migrated_and_removed(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "config.ini"
|
||||
settings = QSettings(str(path), QSettings.IniFormat)
|
||||
settings.setValue(PASSWORD_LEGACY_KEY, "legacy-secret")
|
||||
settings.sync()
|
||||
|
||||
password = load_password_setting(settings)
|
||||
migrated = QSettings(str(path), QSettings.IniFormat)
|
||||
file_text = path.read_text(encoding="utf-8-sig")
|
||||
|
||||
self.assertEqual("legacy-secret", password)
|
||||
self.assertFalse(migrated.contains(PASSWORD_LEGACY_KEY))
|
||||
self.assertTrue(migrated.contains(PASSWORD_PROTECTED_KEY))
|
||||
self.assertNotIn("legacy-secret", file_text)
|
||||
self.assertEqual(
|
||||
"legacy-secret",
|
||||
load_password_setting(migrated),
|
||||
)
|
||||
|
||||
def test_empty_password_removes_all_password_keys(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "config.ini"
|
||||
settings = QSettings(str(path), QSettings.IniFormat)
|
||||
settings.setValue(PASSWORD_LEGACY_KEY, "plaintext")
|
||||
settings.setValue(PASSWORD_PROTECTED_KEY, protect_password("encrypted"))
|
||||
settings.sync()
|
||||
|
||||
self.assertTrue(save_password_setting(settings, ""))
|
||||
cleared = QSettings(str(path), QSettings.IniFormat)
|
||||
|
||||
self.assertFalse(cleared.contains(PASSWORD_LEGACY_KEY))
|
||||
self.assertFalse(cleared.contains(PASSWORD_PROTECTED_KEY))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,160 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt5.QtWidgets import QApplication, QInputDialog, QMessageBox
|
||||
|
||||
from pgu4_sync import (
|
||||
ConnectionProfile,
|
||||
ListWorker,
|
||||
MainWindow,
|
||||
is_protected_root_path,
|
||||
)
|
||||
|
||||
|
||||
class FakeSftp:
|
||||
def __init__(self, resolved_path):
|
||||
self.resolved_path = resolved_path
|
||||
self.listdir_calls = []
|
||||
|
||||
def normalize(self, _path):
|
||||
return self.resolved_path
|
||||
|
||||
def listdir_attr(self, path):
|
||||
self.listdir_calls.append(path)
|
||||
return []
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, sftp):
|
||||
self.sftp = sftp
|
||||
|
||||
def open_sftp(self):
|
||||
return self.sftp
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeFactory:
|
||||
def __init__(self, sftp):
|
||||
self.sftp = sftp
|
||||
|
||||
def open_client(self):
|
||||
return FakeClient(self.sftp)
|
||||
|
||||
|
||||
class RootAccessTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def test_root_and_children_are_protected(self):
|
||||
self.assertTrue(is_protected_root_path("/root"))
|
||||
self.assertTrue(is_protected_root_path("/root/"))
|
||||
self.assertTrue(is_protected_root_path("/root/private/data"))
|
||||
self.assertFalse(is_protected_root_path("/"))
|
||||
self.assertFalse(is_protected_root_path("/rooted"))
|
||||
self.assertFalse(is_protected_root_path("/home/root"))
|
||||
|
||||
def test_symlink_resolving_to_root_requires_auth_before_listing(self):
|
||||
sftp = FakeSftp("/root")
|
||||
worker = ListWorker(FakeFactory(sftp), "/home/root-link", allow_root=False)
|
||||
required = []
|
||||
listed = []
|
||||
failed = []
|
||||
worker.root_auth_required.connect(
|
||||
lambda requested, resolved: required.append((requested, resolved))
|
||||
)
|
||||
worker.listed.connect(lambda path, entries: listed.append((path, entries)))
|
||||
worker.failed.connect(failed.append)
|
||||
|
||||
worker.run()
|
||||
|
||||
self.assertEqual([("/home/root-link", "/root")], required)
|
||||
self.assertEqual([], listed)
|
||||
self.assertEqual([], failed)
|
||||
self.assertEqual([], sftp.listdir_calls)
|
||||
|
||||
def test_authorized_worker_lists_resolved_root(self):
|
||||
sftp = FakeSftp("/root/private")
|
||||
worker = ListWorker(FakeFactory(sftp), "/home/root-link", allow_root=True)
|
||||
listed = []
|
||||
worker.listed.connect(lambda path, entries: listed.append((path, entries)))
|
||||
|
||||
worker.run()
|
||||
|
||||
self.assertEqual([("/root/private", [])], listed)
|
||||
self.assertEqual(["/root/private"], sftp.listdir_calls)
|
||||
|
||||
def test_second_password_must_match_current_connection(self):
|
||||
window = MainWindow()
|
||||
window.factory.set_profile(
|
||||
ConnectionProfile("192.0.2.1", 22, "root", "correct-secret")
|
||||
)
|
||||
original_get_text = QInputDialog.getText
|
||||
original_warning = QMessageBox.warning
|
||||
warnings = []
|
||||
QMessageBox.warning = lambda *args, **kwargs: warnings.append(args) or QMessageBox.Ok
|
||||
try:
|
||||
QInputDialog.getText = lambda *args, **kwargs: ("wrong-secret", True)
|
||||
self.assertFalse(window.request_root_access("/root"))
|
||||
self.assertFalse(window._root_access_authorized)
|
||||
self.assertEqual(1, len(warnings))
|
||||
|
||||
QInputDialog.getText = lambda *args, **kwargs: ("correct-secret", True)
|
||||
self.assertTrue(window.request_root_access("/root"))
|
||||
self.assertTrue(window._root_access_authorized)
|
||||
finally:
|
||||
QInputDialog.getText = original_get_text
|
||||
QMessageBox.warning = original_warning
|
||||
window.close()
|
||||
self.app.processEvents()
|
||||
|
||||
def test_cancelled_second_password_denies_access(self):
|
||||
window = MainWindow()
|
||||
window.factory.set_profile(
|
||||
ConnectionProfile("192.0.2.1", 22, "root", "correct-secret")
|
||||
)
|
||||
original_get_text = QInputDialog.getText
|
||||
try:
|
||||
QInputDialog.getText = lambda *args, **kwargs: ("", False)
|
||||
self.assertFalse(window.request_root_access("/root"))
|
||||
self.assertFalse(window._root_access_authorized)
|
||||
finally:
|
||||
QInputDialog.getText = original_get_text
|
||||
window.close()
|
||||
self.app.processEvents()
|
||||
|
||||
def test_denied_direct_root_path_does_not_start_list_worker(self):
|
||||
window = MainWindow()
|
||||
window.current_remote_path = "/home"
|
||||
window.remote_path_edit.setText("/root")
|
||||
window.factory.set_profile(
|
||||
ConnectionProfile("192.0.2.1", 22, "root", "correct-secret")
|
||||
)
|
||||
original_get_text = QInputDialog.getText
|
||||
original_warning = QMessageBox.warning
|
||||
QMessageBox.warning = lambda *args, **kwargs: QMessageBox.Ok
|
||||
try:
|
||||
QInputDialog.getText = lambda *args, **kwargs: ("wrong-secret", True)
|
||||
thread_count = len(window._threads)
|
||||
|
||||
window.load_remote("/root")
|
||||
|
||||
self.assertEqual(thread_count, len(window._threads))
|
||||
self.assertEqual("/home", window.remote_path_edit.text())
|
||||
self.assertFalse(window._root_access_authorized)
|
||||
finally:
|
||||
QInputDialog.getText = original_get_text
|
||||
QMessageBox.warning = original_warning
|
||||
window.close()
|
||||
self.app.processEvents()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,190 @@
|
||||
import io
|
||||
import http.client
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from pgu4_sync import (
|
||||
DownloadJob,
|
||||
DownloadSignals,
|
||||
FAST_HTTP_SERVER_SCRIPT,
|
||||
FileTask,
|
||||
TransferController,
|
||||
decode_fast_remote_path,
|
||||
encode_fast_remote_path,
|
||||
normalize_local_filename,
|
||||
)
|
||||
|
||||
|
||||
class RemoteBytes(io.BytesIO):
|
||||
def set_pipelined(self, _enabled):
|
||||
return None
|
||||
|
||||
|
||||
class FakeSftp:
|
||||
def __init__(self, expected_path, payload):
|
||||
self.expected_path = expected_path
|
||||
self.payload = payload
|
||||
|
||||
def open(self, path, mode):
|
||||
if mode != "rb":
|
||||
raise AssertionError(mode)
|
||||
if path != self.expected_path:
|
||||
raise AssertionError(path)
|
||||
return RemoteBytes(self.payload)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def set_keepalive(self, _seconds):
|
||||
return None
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, sftp):
|
||||
self.sftp = sftp
|
||||
|
||||
def get_transport(self):
|
||||
return FakeTransport()
|
||||
|
||||
def open_sftp(self):
|
||||
return self.sftp
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeFactory:
|
||||
def __init__(self, sftp):
|
||||
self.sftp = sftp
|
||||
|
||||
def open_client(self):
|
||||
return FakeClient(self.sftp)
|
||||
|
||||
|
||||
class UnicodePathTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def test_fast_remote_path_round_trip(self):
|
||||
remote_path = "/home/clear/中文源目录/图像 01.jpg"
|
||||
|
||||
encoded = encode_fast_remote_path(remote_path)
|
||||
|
||||
self.assertTrue(encoded.isascii())
|
||||
self.assertNotIn("中文", encoded)
|
||||
self.assertEqual(remote_path, decode_fast_remote_path(encoded))
|
||||
|
||||
def test_local_filename_preserves_chinese_and_normalizes_unicode(self):
|
||||
decomposed = "Cafe\u0301_中文数据"
|
||||
|
||||
normalized = normalize_local_filename(decomposed)
|
||||
|
||||
self.assertEqual("Café_中文数据", normalized)
|
||||
|
||||
def test_sftp_download_supports_chinese_source_and_target(self):
|
||||
payload = "中文路径传输正常".encode("utf-8")
|
||||
remote_path = "/home/clear/中文源目录/实验数据.bin"
|
||||
sftp = FakeSftp(remote_path, payload)
|
||||
signals = DownloadSignals()
|
||||
controller = TransferController()
|
||||
job = DownloadJob(
|
||||
FakeFactory(sftp),
|
||||
[],
|
||||
Path("."),
|
||||
1,
|
||||
signals,
|
||||
controller,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="PGU-中文目标-") as temp_dir:
|
||||
local_path = Path(temp_dir) / "目标文件夹" / "实验数据.bin"
|
||||
local_path.parent.mkdir(parents=True)
|
||||
local_path.write_bytes(b"\x00" * len(payload))
|
||||
file_task = FileTask(remote_path, local_path, len(payload))
|
||||
job._file_locks[local_path] = threading.Lock()
|
||||
|
||||
job._download_chunk_sftp_once(file_task, 0, len(payload))
|
||||
|
||||
self.assertEqual(payload, local_path.read_bytes())
|
||||
|
||||
def test_fast_http_service_reads_chinese_path_with_range(self):
|
||||
payload = "极速中文路径".encode("utf-8")
|
||||
token = "unicode-test-token"
|
||||
with tempfile.TemporaryDirectory(prefix="PGU-极速中文-") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
source = root / "中文源文件夹" / "图像 数据.bin"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_bytes(payload)
|
||||
script = root / "fast_service.py"
|
||||
script.write_text(FAST_HTTP_SERVER_SCRIPT, encoding="utf-8")
|
||||
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = probe.getsockname()[1]
|
||||
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
str(script),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--token",
|
||||
token,
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
self.addCleanup(process.kill)
|
||||
deadline = time.time() + 4
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=0.3)
|
||||
conn.request(
|
||||
"GET",
|
||||
"/health?token=" + urllib.parse.quote(token, safe=""),
|
||||
)
|
||||
response = conn.getresponse()
|
||||
if response.status == 200 and response.read() == b"ok-v3":
|
||||
conn.close()
|
||||
break
|
||||
conn.close()
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
self.fail("fast HTTP test service did not start")
|
||||
|
||||
encoded_path = encode_fast_remote_path(str(source))
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=2)
|
||||
request_path = (
|
||||
"/file?token="
|
||||
+ urllib.parse.quote(token, safe="")
|
||||
+ "&path_b64="
|
||||
+ urllib.parse.quote(encoded_path, safe="")
|
||||
)
|
||||
conn.request("GET", request_path, headers={"Range": "bytes=3-8"})
|
||||
response = conn.getresponse()
|
||||
body = response.read()
|
||||
conn.close()
|
||||
|
||||
self.assertEqual(206, response.status)
|
||||
self.assertEqual(payload[3:9], body)
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
@@ -0,0 +1,129 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PyQt5.QtCore import QSettings
|
||||
|
||||
from pgu4_sync import (
|
||||
APP_VERSION,
|
||||
UPDATE_EXE_ASSET,
|
||||
UPDATE_INI_ASSET,
|
||||
UpdateCheckWorker,
|
||||
create_updater_script,
|
||||
is_newer_version,
|
||||
merge_ini_defaults,
|
||||
update_release_from_payload,
|
||||
)
|
||||
|
||||
|
||||
class UpdateTests(unittest.TestCase):
|
||||
@patch("pgu4_sync.urllib.request.urlopen", side_effect=TimeoutError("offline"))
|
||||
def test_update_check_is_silent_when_network_fails(self, _urlopen_mock):
|
||||
worker = UpdateCheckWorker()
|
||||
checked = []
|
||||
failed = []
|
||||
finished = []
|
||||
worker.checked.connect(checked.append)
|
||||
worker.failed.connect(failed.append)
|
||||
worker.finished.connect(lambda: finished.append(True))
|
||||
|
||||
worker.run()
|
||||
|
||||
self.assertEqual([], checked)
|
||||
self.assertEqual(["offline"], failed)
|
||||
self.assertEqual([True], finished)
|
||||
|
||||
def test_version_comparison(self):
|
||||
self.assertTrue(is_newer_version("1.0.7", APP_VERSION))
|
||||
self.assertTrue(is_newer_version("v1.1.0", "1.0.9"))
|
||||
self.assertTrue(is_newer_version("1.0.10", "1.0.2"))
|
||||
self.assertFalse(is_newer_version("v1.0.4", APP_VERSION))
|
||||
self.assertFalse(is_newer_version("1.0", "1.0.0"))
|
||||
self.assertFalse(is_newer_version("invalid", APP_VERSION))
|
||||
|
||||
def test_release_payload_requires_expected_assets_and_host(self):
|
||||
payload = {
|
||||
"tag_name": "v1.0.3",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
"html_url": "http://107.173.157.41:3000/release",
|
||||
"assets": [
|
||||
{
|
||||
"name": UPDATE_EXE_ASSET,
|
||||
"size": 1234567,
|
||||
"browser_download_url": (
|
||||
"http://107.173.157.41:3000/clear/repo/download/app.exe"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": UPDATE_INI_ASSET,
|
||||
"size": 120,
|
||||
"browser_download_url": (
|
||||
"http://107.173.157.41:3000/clear/repo/download/app.ini"
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
release = update_release_from_payload(payload)
|
||||
|
||||
self.assertIsNotNone(release)
|
||||
self.assertEqual("1.0.3", release.version)
|
||||
self.assertEqual(1234567, release.exe_size)
|
||||
self.assertTrue(release.ini_url.endswith("app.ini"))
|
||||
|
||||
payload["assets"][0]["browser_download_url"] = "https://example.com/app.exe"
|
||||
self.assertIsNone(update_release_from_payload(payload))
|
||||
|
||||
def test_merge_ini_adds_missing_keys_without_overwriting_values(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
source_path = temp / "new.ini"
|
||||
target_path = temp / "current.ini"
|
||||
source = QSettings(str(source_path), QSettings.IniFormat)
|
||||
source.setValue("host", "new-host")
|
||||
source.setValue("port", 5922)
|
||||
source.setValue("update/channel", "stable")
|
||||
source.setValue("password", "new-plaintext-password")
|
||||
source.setValue("password_protected", "new-encrypted-password")
|
||||
source.sync()
|
||||
target = QSettings(str(target_path), QSettings.IniFormat)
|
||||
target.setValue("host", "existing-host")
|
||||
target.setValue("port", 22)
|
||||
target.setValue("password_protected", "existing-encrypted-password")
|
||||
target.sync()
|
||||
|
||||
added = merge_ini_defaults(source_path, target_path)
|
||||
merged = QSettings(str(target_path), QSettings.IniFormat)
|
||||
|
||||
self.assertEqual("existing-host", merged.value("host"))
|
||||
self.assertEqual(22, int(merged.value("port")))
|
||||
self.assertFalse(merged.contains("password"))
|
||||
self.assertEqual(
|
||||
"existing-encrypted-password",
|
||||
merged.value("password_protected"),
|
||||
)
|
||||
self.assertEqual("stable", merged.value("update/channel"))
|
||||
self.assertEqual(["update/channel"], added)
|
||||
|
||||
def test_updater_script_contains_wait_replace_rollback_and_restart(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
update_exe = temp / "downloaded update.exe"
|
||||
target_exe = temp / "PGU-NetFileSyncWin.exe"
|
||||
update_exe.write_bytes(b"MZ")
|
||||
target_exe.write_bytes(b"MZ-old")
|
||||
|
||||
script_path = create_updater_script(update_exe, target_exe, 12345)
|
||||
script = script_path.read_text(encoding="utf-8-sig")
|
||||
|
||||
self.assertIn("Wait-Process -Id 12345", script)
|
||||
self.assertIn("Move-Item -LiteralPath $update -Destination $target", script)
|
||||
self.assertIn("Move-Item -LiteralPath $backup -Destination $target", script)
|
||||
self.assertIn("Start-Process -FilePath $target", script)
|
||||
self.assertIn("Updated application exited during startup", script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user