7 Commits
13 changed files with 4618 additions and 619 deletions

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ venv/
build/
dist/
release/
*.spec
*.log
*.tmp
+139 -144
View File
@@ -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 可能缓存旧图标,验证图标时需要关闭旧进程,必要时取消任务栏固定。
+75 -23
View File
@@ -1,10 +1,49 @@
# PGU4-Sync
# PGU-NetFileSyncWin
基于 PyQt5 的 Ubuntu 远程文件管理与高速下载工具。界面以远程文件列表为主,支持连接记忆、远程路径记忆、局域网扫描、拖拽到 Windows 资源管理器、多线程下载、暂停/继续、取消、实时速度条和极速 HTTP Range 通道。
PGU-NetFileSyncWin 是一个 Windows 桌面文件同步工具,用于连接 PGU 设备、浏览远程目录,并把远程文件或文件夹高速下载到本机。
连接配置保存在程序同目录的 `PGU-NetFileSyncWin.ini` 中,便于和 exe 一起复制使用。
![PGU-NetFileSyncWin 主界面](docs/images/main-window.png)
## 安装
## 主要功能
- 连接 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 密文。
+151 -186
View File
@@ -1,273 +1,238 @@
# PGU-NetFileSyncWin 用户说明书
## 1. 软件用途
PGU-NetFileSyncWin 用于在 Windows 上连接 PGU 设备,查看设备中的文件,并把文件或文件夹下载到本机。
PGU-NetFileSyncWin 是一个 Windows 桌面工具,用于连接 Ubuntu 设备、浏览远端文件,并把远端文件或文件夹高速下载到 Windows 本机。它的使用方式类似精简版 WinSCP,但重点优化了大量小文件下载和拖拽到 Windows 资源管理器的工作流。
![PGU-NetFileSyncWin 主界面](docs/images/main-window.png)
## 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
View File
File diff suppressed because it is too large. Load diff
+195
View File
@@ -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())
+204
View File
@@ -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()
+71
View File
@@ -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)
+63
View File
@@ -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()
+160
View File
@@ -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()
+190
View File
@@ -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)
+129
View File
@@ -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()