Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c05e1d84d2 | ||
|
|
f431716bbd | ||
|
|
39e963d6d2 |
No files matched your search
@@ -2,22 +2,32 @@
|
||||
|
||||
## 项目定位
|
||||
|
||||
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`:永久删除的路径安全、分批和失败回退测试。
|
||||
- `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,47 +39,19 @@ 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]
|
||||
@@ -80,57 +62,48 @@ password=Bone1644
|
||||
remote_path=/home/clear/Data
|
||||
```
|
||||
|
||||
## GUI 设计要点
|
||||
说明:
|
||||
|
||||
- 必须使用 PyQt5,不要升级到 PyQt6。
|
||||
- 界面以远程文件列表为核心,不显示左侧本地目录树。
|
||||
- 本地下载目标通过“下载到...”临时选择,或从 GUI 拖拽到已打开的 Windows 资源管理器窗口。
|
||||
- 远程文件列表模拟资源管理器交互:
|
||||
- 左侧空白列可作为框选起点。
|
||||
- 右侧和底部空白区域可框选。
|
||||
- 单击空白区域取消当前选择。
|
||||
- 非空白文件行拖动用于拖出 GUI 到资源管理器。
|
||||
- 底部进度条是速度条,不是总进度条:
|
||||
- `100 MB/s` 映射到满格。
|
||||
- 超过 `100 MB/s` 仍按满格显示。
|
||||
- 运行中为蓝色,下载完成后拉满并变绿色。
|
||||
- 当前按用户要求明文保存密码。
|
||||
- `PGU-NetFileSyncWin.ini` 已在 `.gitignore` 中忽略,不要提交。
|
||||
- 程序启动时如果 ini 不存在,会自动生成默认配置。
|
||||
|
||||
## 连接与扫描
|
||||
## 关键交互
|
||||
|
||||
连接参数:
|
||||
远程文件列表需要保持接近资源管理器:
|
||||
|
||||
- IP
|
||||
- SSH 端口
|
||||
- 用户名
|
||||
- 密码
|
||||
- 远程路径
|
||||
- 左侧空白列可作为框选起点。
|
||||
- 右侧空白区和底部空白区可框选。
|
||||
- 单击空白区域取消选择。
|
||||
- 从文件行拖出 GUI 表示下载到 Windows 资源管理器。
|
||||
- 右键可永久删除单个或多个远程文件、目录,使用远端 `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()
|
||||
```
|
||||
|
||||
普通 SFTP 对大量小文件速度较差,仅作为极速服务不可用时的兜底。
|
||||
|
||||
### 极速通道
|
||||
|
||||
极速通道是在远端通过 SSH 安装一个轻量 HTTP Range 服务,然后本地通过 HTTP 并发下载。
|
||||
|
||||
关键常量:
|
||||
核心常量:
|
||||
|
||||
```python
|
||||
FAST_HTTP_PORT = 5924
|
||||
@@ -142,105 +115,74 @@ FAST_HTTP_TOKEN = "PGU4_SYNC_FAST_V1"
|
||||
|
||||
策略:
|
||||
|
||||
- 小文件按文件并发,最多 `12` 路。
|
||||
- 大于 `64MB` 的文件使用 Range 分片。
|
||||
- 单个大文件最多 `4` 路分片并发。
|
||||
- 分片失败自动重试 `3` 次。
|
||||
- 重试期间不会弹窗打断用户。
|
||||
- 最终失败的文件会在任务结束后汇总显示。
|
||||
- 小文件按文件并发,最多 12 路。
|
||||
- 大文件超过 64MB 后使用 Range 分片。
|
||||
- 单个大文件最多 4 路分片。
|
||||
- 分片失败自动重试。
|
||||
- 失败汇总只在任务结束后提示。
|
||||
|
||||
## 远端极速服务
|
||||
|
||||
安装/升级极速服务时会执行以下动作:
|
||||
|
||||
- 上传服务脚本到:
|
||||
安装位置:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
- 尝试执行:
|
||||
关闭 Windows GUI 不会停止 PGU 设备上的极速服务。
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable pgu4-sync-fast.service
|
||||
systemctl --user restart pgu4-sync-fast.service
|
||||
## 打包
|
||||
|
||||
验证:
|
||||
|
||||
```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
|
||||
Copy-Item PGU-NetFileSyncWin.ini dist\PGU-NetFileSyncWin.ini -Force
|
||||
```
|
||||
|
||||
发布 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,33 @@
|
||||
# PGU4-Sync
|
||||
# PGU-NetFileSyncWin
|
||||
|
||||
基于 PyQt5 的 Ubuntu 远程文件管理与高速下载工具。界面以远程文件列表为主,支持连接记忆、远程路径记忆、局域网扫描、拖拽到 Windows 资源管理器、多线程下载、暂停/继续、取消、实时速度条和极速 HTTP Range 通道。
|
||||
PGU-NetFileSyncWin 是一个 Windows 桌面文件同步工具,用于连接 PGU 设备、浏览远程目录,并把远程文件或文件夹高速下载到本机。
|
||||
|
||||
连接配置保存在程序同目录的 `PGU-NetFileSyncWin.ini` 中,便于和 exe 一起复制使用。
|
||||

|
||||
|
||||
## 安装
|
||||
## 主要功能
|
||||
|
||||
- 连接 PGU 设备并浏览远程文件。
|
||||
- 支持局域网扫描,并区分 PGU4 与 PGU5 设备。
|
||||
- 支持多选、框选、空白区域取消选择。
|
||||
- 支持拖拽已选文件到 Windows 资源管理器下载。
|
||||
- 支持“下载到...”选择本地目录。
|
||||
- 支持远端批量永久删除文件及非空目录,不经过回收站,并显示删除进度。
|
||||
- 支持暂停、继续、取消和失败自动重试。
|
||||
- 支持极速通道,提高大量小文件下载速度。
|
||||
- 支持远端极速服务安装、升级、停止和开机自启。
|
||||
- 使用速度条显示实时速度,下载完成后显示用时和平均速度。
|
||||
- 使用同目录 `PGU-NetFileSyncWin.ini` 保存连接配置,便于和 exe 一起复制使用。
|
||||
|
||||
## 发布版
|
||||
|
||||
推荐使用发布页中的文件:
|
||||
|
||||
- `PGU-NetFileSyncWin.exe`
|
||||
- `PGU-NetFileSyncWin.ini`
|
||||
|
||||
两个文件建议放在同一目录。`PGU-NetFileSyncWin.ini` 保存默认连接信息。
|
||||
|
||||
## 源码运行
|
||||
|
||||
建议使用本机 `clear_conda_main` 环境:
|
||||
|
||||
@@ -12,7 +35,7 @@
|
||||
C:\Users\clear\.conda\envs\clear_conda_main\python.exe -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 启动
|
||||
启动:
|
||||
|
||||
```powershell
|
||||
run_pgu4_sync.bat
|
||||
@@ -24,31 +47,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
|
||||
password=Bone1644
|
||||
remote_path=/home/clear/Data
|
||||
```
|
||||
|
||||
单文件 Windows GUI 版本位于:
|
||||
说明:
|
||||
|
||||
- 配置文件位于 exe 同目录。
|
||||
- 密码按当前需求明文保存。
|
||||
- 删除 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` 打包,不显示命令行窗口,并嵌入应用图标。
|
||||
发布时将 `PGU-NetFileSyncWin.ini` 复制到 `dist/` 目录即可。
|
||||
@@ -1,27 +1,29 @@
|
||||
# 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]
|
||||
@@ -32,242 +34,162 @@ password=Bone1644
|
||||
remote_path=/home/clear/Data
|
||||
```
|
||||
|
||||
密码按当前版本设计为明文保存。如果要清空连接记录,删除或编辑这个 ini 文件即可。
|
||||
你可以直接用记事本修改这个文件。
|
||||
|
||||
如果使用源码运行:
|
||||
常用字段说明:
|
||||
|
||||
```powershell
|
||||
run_pgu4_sync.bat
|
||||
```
|
||||
- `host`:PGU 设备 IP。
|
||||
- `port`:连接端口。
|
||||
- `username`:用户名。
|
||||
- `password`:密码。
|
||||
- `remote_path`:打开软件后默认进入的远程路径。
|
||||
|
||||
## 3. 主界面区域
|
||||
密码当前为明文保存。不要把带有真实密码的 ini 文件随意发给别人。
|
||||
|
||||
### 顶部连接区
|
||||
## 3. 连接 PGU 设备
|
||||
|
||||
顶部包含:
|
||||
1. 打开软件。
|
||||
2. 检查 IP、端口、用户和密码是否正确。
|
||||
3. 点击 `连接`。
|
||||
4. 连接成功后,按钮会变成 `已连接`。
|
||||
5. 文件列表会显示 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`。
|
||||
|
||||
文件列表显示:
|
||||
选择设备后,软件会自动填写 IP 和对应连接端口。
|
||||
|
||||
## 5. 浏览远程文件
|
||||
|
||||
文件列表中会显示:
|
||||
|
||||
- 名称
|
||||
- 大小
|
||||
- 修改时间
|
||||
- 类型
|
||||
|
||||
操作方式:
|
||||
常用操作:
|
||||
|
||||
- 双击文件夹:进入文件夹。
|
||||
- 单击文件或文件夹:选中。
|
||||
- `Ctrl` 或 `Shift` 配合鼠标:多选。
|
||||
- 从左侧空白列、右侧空白区或底部空白区拖动:框选多项。
|
||||
- 双击文件夹:进入该文件夹。
|
||||
- 点击 `上级`:返回上一层。
|
||||
- 点击 `刷新`:重新读取当前目录。
|
||||
- 在远程路径输入框中输入路径后按回车:进入指定目录。
|
||||
|
||||
## 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. 在远程文件列表中选择一个或多个文件/文件夹。
|
||||
1. 在文件列表中选择文件或文件夹。
|
||||
2. 点击 `下载到...`。
|
||||
3. 选择本地目录。
|
||||
3. 选择本机目录。
|
||||
4. 软件开始下载。
|
||||
|
||||
### 方式二:拖拽到 Windows 资源管理器
|
||||
### 方法二:拖拽到 Windows 资源管理器
|
||||
|
||||
1. 打开 Windows 资源管理器,并进入目标文件夹。
|
||||
2. 在软件中选中远端文件或文件夹。
|
||||
3. 从选中文件行拖出软件窗口。
|
||||
1. 先打开 Windows 资源管理器,并进入目标文件夹。
|
||||
2. 在软件中选择要下载的远程文件或文件夹。
|
||||
3. 从选中的文件行拖出软件窗口。
|
||||
4. 拖到资源管理器窗口中松开鼠标。
|
||||
5. 软件识别目标目录并开始下载。
|
||||
5. 软件会自动开始下载到该目录。
|
||||
|
||||
注意:请先打开目标资源管理器窗口,再从软件中拖出。
|
||||
注意:框选空白区是用来选择文件的,不是拖出下载。请从已选中的文件行拖出。
|
||||
|
||||
## 7. 极速服务
|
||||
## 8. 删除远程内容
|
||||
|
||||
极速服务是安装在远端 Ubuntu 设备上的轻量 HTTP Range 下载服务,用于提高大量小文件和大文件下载速度。
|
||||
选中一个或多个文件、目录后点击鼠标右键,选择 `删除选中项`。
|
||||
|
||||
### 安装极速
|
||||
软件会先弹窗要求二次确认。确认后,PGU 设备会在本机文件系统中批量永久删除选中内容,非空目录会连同内部文件一起删除,不会进入远程回收站。弹窗会显示选中项删除进度,完成后远程目录会自动刷新。
|
||||
|
||||
连接远端后,如果未检测到极速服务,按钮显示 `安装极速`。
|
||||
如果目录正在被其他程序写入,软件会自动进行多次重试。持续写入仍可能导致删除失败,此时应先停止占用该目录的程序,再重新删除。
|
||||
|
||||
点击后软件会通过 SSH 自动安装并启动远端服务。
|
||||
删除操作无法撤销,请在确认前仔细检查选中的内容。
|
||||
|
||||
### 升级极速
|
||||
## 9. 极速下载
|
||||
|
||||
如果检测到旧版本或旧 token 服务,按钮显示 `升级极速`。
|
||||
底部按钮会显示当前极速服务状态:
|
||||
|
||||
点击后软件会替换旧服务并启动新服务。
|
||||
- `安装极速`:PGU 设备上还没有极速服务,点击后安装。
|
||||
- `升级极速`:检测到旧服务,点击后升级。
|
||||
- `停止极速`:极速服务正在运行,点击后停止。
|
||||
|
||||
### 停止极速
|
||||
一般情况下,连接 PGU 设备后,如果极速服务可用,软件会自动使用极速通道下载。
|
||||
|
||||
如果极速服务正在运行,按钮显示 `停止极速`。
|
||||
安装极速服务后,PGU 设备重启后也会尽量自动启动该服务。关闭 Windows 软件不会停止 PGU 设备上的极速服务。
|
||||
|
||||
点击后会停止远端极速服务,并取消开机自启。
|
||||
## 10. 下载过程
|
||||
|
||||
### 开机自启
|
||||
下载时可以使用:
|
||||
|
||||
安装或升级极速服务后,软件会配置远端开机自启:
|
||||
- `暂停`:暂停当前下载。
|
||||
- `继续`:恢复下载。
|
||||
- `取消`:取消当前下载。
|
||||
|
||||
- 优先使用用户级 `systemd --user` 服务。
|
||||
- 同时写入 `crontab @reboot` 作为兜底。
|
||||
下载过程中如果某个文件短暂超时,软件会自动重试,不会立即弹窗打断下载。
|
||||
|
||||
关闭 Windows GUI 不会停止远端极速服务。下次连接时如果服务正常运行,软件会自动启用极速通道。
|
||||
如果最终仍有文件失败,软件会在下载结束后统一提示。
|
||||
|
||||
## 8. 下载过程控制
|
||||
## 11. 速度条
|
||||
|
||||
### 暂停
|
||||
底部进度条是速度条,不是总进度条。
|
||||
|
||||
点击 `暂停` 后,下载任务会暂停。按钮文字变为 `继续`。
|
||||
|
||||
再次点击可继续下载。
|
||||
|
||||
### 取消
|
||||
|
||||
点击 `取消` 后,当前下载任务会尽快停止。
|
||||
|
||||
### 失败重试
|
||||
|
||||
单个文件或分片出现 timeout、连接中断等问题时,软件会自动重试。
|
||||
|
||||
重试期间不会弹窗打断下载。
|
||||
|
||||
如果重试后仍失败,软件会继续下载其他文件,并在任务结束后汇总失败文件。
|
||||
|
||||
## 9. 速度显示
|
||||
|
||||
速度条不是总进度条,而是实时速度条:
|
||||
|
||||
- `100MB/s` 显示为满格。
|
||||
- 超过 `100MB/s` 仍按满格显示。
|
||||
- `100MB/s` 对应满格。
|
||||
- 下载过程中为蓝色。
|
||||
- 下载完成后变为绿色并拉满。
|
||||
- 下载完成后会拉满并变为绿色。
|
||||
|
||||
完成后会显示:
|
||||
下载完成后会显示:
|
||||
|
||||
```text
|
||||
下载完成 1.7 GB 用时 34s 平均 51.2 MB/s
|
||||
```
|
||||
|
||||
## 10. 常见问题
|
||||
## 12. 常见问题
|
||||
|
||||
### 图标没有立刻变化
|
||||
### 打开后连接信息不对
|
||||
|
||||
Windows 可能缓存旧 exe 图标。请确认运行的是新版 exe。如果任务栏固定了旧版本,请取消固定后重新固定新版 exe。
|
||||
请检查 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: 57 KiB |
+388
-8
@@ -6,6 +6,7 @@ import http.client
|
||||
import ipaddress
|
||||
import os
|
||||
import queue
|
||||
import shlex
|
||||
import socket
|
||||
import stat
|
||||
import sys
|
||||
@@ -92,6 +93,10 @@ FAST_REMOTE_SCRIPT_NAME = "pgu4_fast_http.py"
|
||||
FAST_REMOTE_SERVICE_NAME = "pgu4-sync-fast.service"
|
||||
SPEED_METER_MAX = 100 * 1024 * 1024
|
||||
REMOTE_LEFT_SELECT_GUTTER = 12
|
||||
DELETE_BATCH_ITEMS = 128
|
||||
DELETE_COMMAND_MAX_CHARS = 24 * 1024
|
||||
DELETE_RETRIES = 5
|
||||
DELETE_RETRY_BACKOFF = 0.15
|
||||
FAST_HTTP_SERVER_SCRIPT = r'''#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
@@ -379,6 +384,144 @@ class ListWorker(QObject):
|
||||
self.failed.emit(str(exc))
|
||||
|
||||
|
||||
class DeleteWorker(QObject):
|
||||
planned = pyqtSignal(int)
|
||||
progress = pyqtSignal(int, int, str)
|
||||
failed = pyqtSignal(str, str)
|
||||
finished = pyqtSignal(int, int)
|
||||
|
||||
def __init__(self, factory: SSHClientFactory, entries: list[RemoteEntry]) -> None:
|
||||
super().__init__()
|
||||
self.factory = factory
|
||||
self.entries = entries
|
||||
self.failure_count = 0
|
||||
|
||||
def run(self) -> None:
|
||||
client = None
|
||||
deleted_count = 0
|
||||
total = len(self.entries)
|
||||
done = 0
|
||||
processed_valid = 0
|
||||
valid_entries: list[RemoteEntry] = []
|
||||
self.planned.emit(total)
|
||||
|
||||
for entry in self.entries:
|
||||
if self._safe_delete_path(entry.path) is not None:
|
||||
valid_entries.append(entry)
|
||||
continue
|
||||
self._report_failure(entry.path, "安全保护:禁止删除远程根目录或无效路径")
|
||||
done += 1
|
||||
self.progress.emit(done, total, entry.path)
|
||||
|
||||
try:
|
||||
if not valid_entries:
|
||||
return
|
||||
client = self.factory.open_client()
|
||||
for batch in self._make_batches(valid_entries):
|
||||
success, message = self._delete_batch(client, batch)
|
||||
if success:
|
||||
deleted_count += len(batch)
|
||||
elif len(batch) == 1:
|
||||
self._report_failure(batch[0].path, message)
|
||||
else:
|
||||
for entry in batch:
|
||||
item_success, item_message = self._delete_batch(client, [entry])
|
||||
if item_success:
|
||||
deleted_count += 1
|
||||
else:
|
||||
self._report_failure(entry.path, item_message)
|
||||
done += len(batch)
|
||||
processed_valid += len(batch)
|
||||
self.progress.emit(done, total, batch[-1].path)
|
||||
except Exception as exc:
|
||||
remaining = valid_entries[processed_valid:]
|
||||
if remaining:
|
||||
for entry in remaining:
|
||||
self._report_failure(entry.path, str(exc))
|
||||
done += len(remaining)
|
||||
self.progress.emit(done, total, remaining[-1].path)
|
||||
else:
|
||||
self._report_failure("", str(exc))
|
||||
finally:
|
||||
if client is not None:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.finished.emit(deleted_count, self.failure_count)
|
||||
|
||||
def _make_batches(self, entries: list[RemoteEntry]) -> list[list[RemoteEntry]]:
|
||||
batches: list[list[RemoteEntry]] = []
|
||||
batch: list[RemoteEntry] = []
|
||||
for entry in entries:
|
||||
candidate = batch + [entry]
|
||||
candidate_command = self._build_delete_command(candidate)
|
||||
if batch and (
|
||||
len(batch) >= DELETE_BATCH_ITEMS
|
||||
or candidate_command is None
|
||||
or len(candidate_command) > DELETE_COMMAND_MAX_CHARS
|
||||
):
|
||||
batches.append(batch)
|
||||
batch = []
|
||||
batch.append(entry)
|
||||
if batch:
|
||||
batches.append(batch)
|
||||
return batches
|
||||
|
||||
def _delete_batch(
|
||||
self,
|
||||
client: paramiko.SSHClient,
|
||||
entries: list[RemoteEntry],
|
||||
) -> tuple[bool, str]:
|
||||
command = self._build_delete_command(entries)
|
||||
if command is None:
|
||||
return False, "安全保护:删除路径无效"
|
||||
last_message = ""
|
||||
for attempt in range(DELETE_RETRIES):
|
||||
_stdin, stdout, stderr = client.exec_command(command)
|
||||
status = stdout.channel.recv_exit_status()
|
||||
message = stderr.read().decode(errors="replace").strip()
|
||||
if status == 0:
|
||||
return True, ""
|
||||
last_message = message or f"远端 rm 返回状态 {status}"
|
||||
if attempt + 1 < DELETE_RETRIES:
|
||||
time.sleep(DELETE_RETRY_BACKOFF * (2**attempt))
|
||||
if (
|
||||
"Directory not empty" in last_message
|
||||
or "Path still exists after removal" in last_message
|
||||
):
|
||||
last_message = "目录仍不为空,可能有其他程序正在持续写入"
|
||||
return False, f"{last_message}(已自动重试 {DELETE_RETRIES} 次)"
|
||||
|
||||
def _build_delete_command(self, entries: list[RemoteEntry]) -> str | None:
|
||||
safe_paths = [self._safe_delete_path(entry.path) for entry in entries]
|
||||
if not safe_paths or any(path is None for path in safe_paths):
|
||||
return None
|
||||
quoted_paths = [shlex.quote(path) for path in safe_paths if path is not None]
|
||||
remove_args = " ".join(quoted_paths)
|
||||
checks = " && ".join(
|
||||
f"( [ ! -e {path} ] && [ ! -L {path} ] )" for path in quoted_paths
|
||||
)
|
||||
return (
|
||||
f"rm -rf -- {remove_args} && "
|
||||
f"if {checks}; then :; "
|
||||
"else printf '%s\\n' 'Path still exists after removal' >&2; exit 73; fi"
|
||||
)
|
||||
|
||||
def _safe_delete_path(self, path: str) -> str | None:
|
||||
if not path or not path.startswith("/") or "\x00" in path:
|
||||
return None
|
||||
collapsed = "/" + path.lstrip("/")
|
||||
normalized = posixpath.normpath(collapsed)
|
||||
if normalized == "/" or collapsed != normalized:
|
||||
return None
|
||||
return normalized
|
||||
|
||||
def _report_failure(self, path: str, message: str) -> None:
|
||||
self.failure_count += 1
|
||||
self.failed.emit(path, message)
|
||||
|
||||
|
||||
class FastServiceWorker(QObject):
|
||||
ready = pyqtSignal(str, int, str)
|
||||
failed = pyqtSignal(str)
|
||||
@@ -808,7 +951,7 @@ class ScanDialog(QDialog):
|
||||
self.scan_btn.setEnabled(False)
|
||||
self.stop_btn.setEnabled(True)
|
||||
|
||||
self.worker = ScanWorker(network, [5901, 5923], workers=256)
|
||||
self.worker = ScanWorker(network, [5901, 5923, 8080], workers=256)
|
||||
self.worker_thread = QThread(self)
|
||||
self.worker.moveToThread(self.worker_thread)
|
||||
self.worker_thread.started.connect(self.worker.run)
|
||||
@@ -823,7 +966,10 @@ class ScanDialog(QDialog):
|
||||
self.stop_btn.setEnabled(False)
|
||||
|
||||
def add_result(self, host: str, ports: list[int]) -> None:
|
||||
item = QListWidgetItem(f"{host} 开放端口: {', '.join(map(str, ports))}")
|
||||
device_type = detected_device_type(ports)
|
||||
item = QListWidgetItem(
|
||||
f"{device_type} {host} 开放端口: {', '.join(map(str, ports))}"
|
||||
)
|
||||
item.setData(Qt.UserRole, (host, ports))
|
||||
self.result_list.addItem(item)
|
||||
|
||||
@@ -853,6 +999,144 @@ class ScanDialog(QDialog):
|
||||
super().reject()
|
||||
|
||||
|
||||
class DeleteProgressDialog(QDialog):
|
||||
confirmed = pyqtSignal()
|
||||
|
||||
def __init__(self, entries: list[RemoteEntry], parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.entries = entries
|
||||
self.running = False
|
||||
self.completed = False
|
||||
self.setWindowTitle("确认删除")
|
||||
self.setWindowIcon(QIcon(resource_path(APP_ICON_FILE)))
|
||||
self.resize(600, 360)
|
||||
self.setModal(True)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(12)
|
||||
|
||||
directory_count = sum(1 for entry in self.entries if entry.is_dir)
|
||||
file_count = len(self.entries) - directory_count
|
||||
self.title_label = QLabel("确定永久删除选中的远程内容吗?")
|
||||
self.title_label.setObjectName("DeleteTitle")
|
||||
self.title_label.setWordWrap(True)
|
||||
layout.addWidget(self.title_label)
|
||||
|
||||
self.summary_label = QLabel(
|
||||
f"已选择 {len(self.entries)} 项:{directory_count} 个目录,{file_count} 个文件。"
|
||||
"非空目录中的所有内容也会被永久删除,不进入远程回收站,此操作无法撤销。"
|
||||
)
|
||||
self.summary_label.setWordWrap(True)
|
||||
layout.addWidget(self.summary_label)
|
||||
|
||||
names = "\n".join(entry.path for entry in self.entries[:8])
|
||||
if len(self.entries) > 8:
|
||||
names += f"\n... 还有 {len(self.entries) - 8} 项"
|
||||
self.items_label = QLabel(names)
|
||||
self.items_label.setObjectName("DeleteItems")
|
||||
self.items_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.items_label.setWordWrap(True)
|
||||
layout.addWidget(self.items_label)
|
||||
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setVisible(False)
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
self.current_label = QLabel("")
|
||||
self.current_label.setObjectName("Hint")
|
||||
self.current_label.setWordWrap(True)
|
||||
self.current_label.setVisible(False)
|
||||
layout.addWidget(self.current_label)
|
||||
|
||||
self.failure_list = QListWidget()
|
||||
self.failure_list.setVisible(False)
|
||||
layout.addWidget(self.failure_list, 1)
|
||||
layout.addStretch(1)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch(1)
|
||||
self.cancel_btn = QPushButton("取消")
|
||||
self.confirm_btn = QPushButton("确认删除")
|
||||
self.confirm_btn.setObjectName("DangerButton")
|
||||
buttons.addWidget(self.cancel_btn)
|
||||
buttons.addWidget(self.confirm_btn)
|
||||
layout.addLayout(buttons)
|
||||
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
self.confirm_btn.clicked.connect(self._confirm_or_close)
|
||||
|
||||
def _confirm_or_close(self) -> None:
|
||||
if self.completed:
|
||||
self.accept()
|
||||
return
|
||||
if self.running:
|
||||
return
|
||||
self.running = True
|
||||
self.setWindowTitle("正在删除")
|
||||
self.title_label.setText("正在永久删除远程内容...")
|
||||
self.summary_label.setText("正在启动远端批量删除,请稍候。")
|
||||
self.items_label.setVisible(False)
|
||||
self.progress.setVisible(True)
|
||||
self.progress.setRange(0, 0)
|
||||
self.progress.setFormat("正在删除...")
|
||||
self.current_label.setVisible(True)
|
||||
self.cancel_btn.setVisible(False)
|
||||
self.confirm_btn.setText("删除中...")
|
||||
self.confirm_btn.setEnabled(False)
|
||||
self.confirmed.emit()
|
||||
|
||||
def set_planned(self, total: int) -> None:
|
||||
self.title_label.setText("正在永久删除远程内容...")
|
||||
self.summary_label.setText(f"正在批量删除 {total} 个选中项,不进入回收站。")
|
||||
self.progress.setRange(0, max(1, total))
|
||||
self.progress.setValue(0)
|
||||
self.progress.setFormat("%v / %m 个选中项")
|
||||
|
||||
def set_progress(self, done: int, total: int, path: str) -> None:
|
||||
self.progress.setRange(0, max(1, total))
|
||||
self.progress.setValue(done)
|
||||
self.progress.setFormat(f"{done} / {total} 个选中项")
|
||||
self.current_label.setText(path)
|
||||
|
||||
def add_failure(self, path: str, message: str) -> None:
|
||||
label = f"{path or '删除任务'}:{message}"
|
||||
item = QListWidgetItem(label)
|
||||
item.setForeground(QColor("#b42318"))
|
||||
self.failure_list.addItem(item)
|
||||
|
||||
def finish(self, deleted_count: int, failure_count: int) -> None:
|
||||
self.running = False
|
||||
self.completed = True
|
||||
self.setWindowTitle("删除完成")
|
||||
if self.progress.maximum() == 0:
|
||||
self.progress.setRange(0, 1)
|
||||
self.progress.setValue(self.progress.maximum())
|
||||
if failure_count:
|
||||
self.title_label.setText("删除已完成,但有部分项目失败")
|
||||
self.summary_label.setText(f"永久删除成功 {deleted_count} 项,失败 {failure_count} 项。")
|
||||
self.failure_list.setVisible(True)
|
||||
else:
|
||||
self.title_label.setText("删除完成")
|
||||
self.summary_label.setText(f"已永久删除 {deleted_count} 个选中项,未进入回收站。")
|
||||
self.current_label.setVisible(False)
|
||||
self.confirm_btn.setText("关闭")
|
||||
self.confirm_btn.setEnabled(True)
|
||||
|
||||
def reject(self) -> None:
|
||||
if self.running:
|
||||
return
|
||||
super().reject()
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
if self.running:
|
||||
event.ignore()
|
||||
return
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
class DownloadSignals(QObject):
|
||||
planned = pyqtSignal(int, int)
|
||||
progress = pyqtSignal(str, int)
|
||||
@@ -1414,6 +1698,7 @@ class MainWindow(QMainWindow):
|
||||
self.max_workers = get_logical_thread_count()
|
||||
self._threads: list[QThread] = []
|
||||
self._active_download = False
|
||||
self._active_delete = False
|
||||
self._total_bytes = 0
|
||||
self._done_bytes = 0
|
||||
self._last_bytes = 0
|
||||
@@ -1632,6 +1917,21 @@ class MainWindow(QMainWindow):
|
||||
QPushButton:hover, QToolButton:hover { background: #e2edf9; }
|
||||
QPushButton:pressed, QToolButton:pressed { background: #d1e2f5; }
|
||||
QPushButton:disabled { color: #98a5b5; background: #edf1f6; }
|
||||
QPushButton#DangerButton {
|
||||
color: #ffffff;
|
||||
background: #c9362b;
|
||||
border-color: #c9362b;
|
||||
font-weight: 700;
|
||||
}
|
||||
QPushButton#DangerButton:hover { background: #ae2c23; }
|
||||
QLabel#DeleteTitle { font-size: 17px; font-weight: 700; color: #9f241d; }
|
||||
QLabel#DeleteItems {
|
||||
color: #4a5568;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid #e1e5ea;
|
||||
border-radius: 5px;
|
||||
padding: 8px;
|
||||
}
|
||||
QPushButton#FastButton[fastState="install"] {
|
||||
background: #e8f5ee;
|
||||
border-color: #8fc8a5;
|
||||
@@ -1990,11 +2290,20 @@ class MainWindow(QMainWindow):
|
||||
def open_remote_menu(self, pos: QPoint) -> None:
|
||||
menu = QMenu(self)
|
||||
download = QAction("下载选中到目录...", self)
|
||||
download.setEnabled(bool(self.selected_remote_entries()) and not self._active_download)
|
||||
has_selection = bool(self.selected_remote_entries())
|
||||
task_idle = not self._active_download and not self._active_delete
|
||||
download.setEnabled(has_selection and task_idle)
|
||||
download.triggered.connect(self.download_selected)
|
||||
delete = QAction("删除选中项", self)
|
||||
delete.setEnabled(has_selection and task_idle)
|
||||
delete.triggered.connect(self.delete_selected)
|
||||
refresh = QAction("刷新", self)
|
||||
refresh.setEnabled(not self._active_delete)
|
||||
refresh.triggered.connect(lambda: self.load_remote(self.current_remote_path))
|
||||
menu.addAction(download)
|
||||
menu.addSeparator()
|
||||
menu.addAction(delete)
|
||||
menu.addSeparator()
|
||||
menu.addAction(refresh)
|
||||
menu.exec_(self.remote_table.viewport().mapToGlobal(pos))
|
||||
|
||||
@@ -2003,11 +2312,73 @@ class MainWindow(QMainWindow):
|
||||
return [self.remote_model.entry_at(row) for row in rows]
|
||||
|
||||
def update_download_enabled(self, *_args) -> None:
|
||||
self.download_btn.setEnabled(bool(self.selected_remote_entries()) and not self._active_download)
|
||||
self.download_btn.setEnabled(
|
||||
bool(self.selected_remote_entries())
|
||||
and not self._active_download
|
||||
and not self._active_delete
|
||||
)
|
||||
|
||||
def delete_selected(self) -> None:
|
||||
entries = self.selected_remote_entries()
|
||||
if not entries or self._active_download or self._active_delete:
|
||||
return
|
||||
dialog = DeleteProgressDialog(entries, self)
|
||||
dialog.confirmed.connect(lambda: self.start_delete(entries, dialog))
|
||||
dialog.exec_()
|
||||
|
||||
def start_delete(self, entries: list[RemoteEntry], dialog: DeleteProgressDialog) -> None:
|
||||
self._active_delete = True
|
||||
self.remote_table.setEnabled(False)
|
||||
self.remote_path_edit.setEnabled(False)
|
||||
self.up_btn.setEnabled(False)
|
||||
self.refresh_btn.setEnabled(False)
|
||||
self.download_btn.setEnabled(False)
|
||||
self.statusBar().showMessage(f"正在删除 {len(entries)} 个选中项...")
|
||||
|
||||
worker = DeleteWorker(self.factory, entries)
|
||||
thread = self._run_worker(worker, worker.run, start=False)
|
||||
worker.planned.connect(dialog.set_planned)
|
||||
worker.progress.connect(dialog.set_progress)
|
||||
worker.failed.connect(dialog.add_failure)
|
||||
worker.finished.connect(
|
||||
lambda deleted, failed: self.on_delete_finished(
|
||||
deleted,
|
||||
failed,
|
||||
dialog,
|
||||
thread,
|
||||
)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def on_delete_finished(
|
||||
self,
|
||||
deleted_count: int,
|
||||
failure_count: int,
|
||||
dialog: DeleteProgressDialog,
|
||||
thread: QThread,
|
||||
) -> None:
|
||||
self._active_delete = False
|
||||
self.remote_table.setEnabled(True)
|
||||
self.remote_path_edit.setEnabled(True)
|
||||
self.up_btn.setEnabled(True)
|
||||
self.refresh_btn.setEnabled(True)
|
||||
self.update_download_enabled()
|
||||
dialog.finish(deleted_count, failure_count)
|
||||
self._finish_thread(thread)
|
||||
if self._closing:
|
||||
return
|
||||
if failure_count:
|
||||
self.statusBar().showMessage(
|
||||
f"删除完成:成功 {deleted_count} 项,失败 {failure_count} 项",
|
||||
8000,
|
||||
)
|
||||
else:
|
||||
self.statusBar().showMessage(f"已删除 {deleted_count} 项", 5000)
|
||||
self.load_remote(self.current_remote_path)
|
||||
|
||||
def download_selected(self) -> None:
|
||||
entries = self.selected_remote_entries()
|
||||
if not entries or self._active_download:
|
||||
if not entries or self._active_download or self._active_delete:
|
||||
return
|
||||
folder = QFileDialog.getExistingDirectory(self, "选择下载目录", str(Path.home() / "Downloads"))
|
||||
if not folder:
|
||||
@@ -2023,7 +2394,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def drag_selected_to_explorer(self) -> None:
|
||||
entries = self.selected_remote_entries()
|
||||
if not entries or self._active_download:
|
||||
if not entries or self._active_download or self._active_delete:
|
||||
return
|
||||
|
||||
self.statusBar().showMessage("拖到已打开的 Windows 资源管理器目录后松开...")
|
||||
@@ -2170,14 +2541,15 @@ class MainWindow(QMainWindow):
|
||||
self.statusBar().showMessage("下载完成", 5000)
|
||||
self.controller = None
|
||||
|
||||
def _run_worker(self, worker: QObject, callback) -> QThread:
|
||||
def _run_worker(self, worker: QObject, callback, start: bool = True) -> QThread:
|
||||
thread = QThread(self)
|
||||
worker.moveToThread(thread)
|
||||
thread.worker = worker
|
||||
thread.started.connect(callback)
|
||||
thread.finished.connect(lambda: self._threads.remove(thread) if thread in self._threads else None)
|
||||
thread.start()
|
||||
self._threads.append(thread)
|
||||
if start:
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
def _finish_thread(self, thread: QThread) -> None:
|
||||
@@ -2298,6 +2670,14 @@ def recommended_ssh_port(open_ports: list[int]) -> int:
|
||||
return 22
|
||||
|
||||
|
||||
def detected_device_type(open_ports: list[int]) -> str:
|
||||
if 5923 in open_ports or 5901 in open_ports:
|
||||
return "PGU4"
|
||||
if 8080 in open_ports:
|
||||
return "PGU5"
|
||||
return "未知设备"
|
||||
|
||||
|
||||
def get_logical_thread_count() -> int:
|
||||
return max(1, os.cpu_count() or 1)
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user