做安装包版本

This commit is contained in:
扫地僧 2026-04-07 23:21:58 +08:00
parent d583e711f0
commit 920d142d9c
11 changed files with 669 additions and 42 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@ ui/__pycache__/
# 打包exe相关
dist/
build/
installer/Output/
*.spec
# 编辑器

View File

@ -1,5 +1,5 @@
APP_NAME = "牛马软件柜"
__VERSION__ = "0.0.3"
__VERSION__ = "0.0.4"
def app_title() -> str:

67
build_installer.bat Normal file
View File

@ -0,0 +1,67 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
REM 一键打包PyInstaller -> dist\niumasoftware -> 生成 Inno 脚本 -> (可选) 编译安装包
REM 需要:
REM - Python + PyInstallerpip install pyinstaller
REM - Inno Setup可选用于自动编译 .iss
cd /d "%~dp0"
echo.
echo === Clean old build artifacts ===
if exist "build" rmdir /s /q "build"
if exist "dist" rmdir /s /q "dist"
echo.
echo === Build main app (onedir) ===
python -m PyInstaller --noconfirm --onedir --name niumasoftware --windowed ^
--icon "logo.ico" ^
--add-data "assets;assets" ^
--add-data "logo.png;." ^
main.py
if errorlevel 1 goto :fail
echo.
echo === Build update helper (onefile -> dist\niumasoftware) ===
python -m PyInstaller --noconfirm --onefile --name update_helper --console --distpath dist\niumasoftware ^
--icon "logo.ico" ^
update_helper.py
if errorlevel 1 goto :fail
echo.
echo === Generate Inno Setup script with AppVersion ===
python installer\build_installer.py
if errorlevel 1 goto :fail
echo.
echo === (Optional) Compile installer via ISCC.exe ===
set "ISCC="
if exist "%ProgramFiles(x86)%\Inno Setup 6\ISCC.exe" set "ISCC=%ProgramFiles(x86)%\Inno Setup 6\ISCC.exe"
if exist "%ProgramFiles%\Inno Setup 6\ISCC.exe" set "ISCC=%ProgramFiles%\Inno Setup 6\ISCC.exe"
if not defined ISCC (
echo ISCC.exe not found, skip compiling installer.
echo You can compile: installer\niumasoftware.generated.iss
goto :ok
)
echo Using ISCC: "%ISCC%"
"%ISCC%" "installer\niumasoftware.generated.iss"
if errorlevel 1 goto :fail
:ok
echo.
echo === Done ===
echo dist\niumasoftware\niumasoftware.exe
echo dist\niumasoftware\update_helper.exe
echo installer\niumasoftware.generated.iss
echo.
exit /b 0
:fail
echo.
echo Build failed.
exit /b 1

View File

@ -4,6 +4,8 @@ import shutil
# 数据库放到 Windows 常见的 APPDATA 目录(避免写在项目目录)
APP_NAME = "CleanDesktopOrganizer"
_SCHEMA_VERSION_KEY = "schema_version"
LATEST_SCHEMA_VERSION = 1
_PROJECT_DB_PATH = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "data.db"
@ -37,52 +39,127 @@ def get_conn():
_ensure_db_dir()
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA foreign_keys=ON")
except Exception:
pass
return conn
def _get_schema_version(conn: sqlite3.Connection) -> int:
try:
row = conn.execute(
"SELECT value FROM settings WHERE key=?",
(_SCHEMA_VERSION_KEY,),
).fetchone()
if not row:
return 0
return int(str(row[0]).strip() or "0")
except Exception:
return 0
def _set_schema_version(conn: sqlite3.Connection, v: int):
conn.execute(
"INSERT INTO settings (key, value) VALUES (?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(_SCHEMA_VERSION_KEY, str(int(v))),
)
def _table_has_column(conn: sqlite3.Connection, table: str, col: str) -> bool:
try:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any((r[1] if isinstance(r, (tuple, list)) else r["name"]) == col for r in rows)
except Exception:
return False
def _apply_migrations(conn: sqlite3.Connection):
"""
版本化迁移schema migration
- 允许从任意旧版本升级到最新
- 每一步尽量幂等并在事务中执行
"""
def m1():
# 基础表
conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
position INTEGER DEFAULT 0,
folder_path TEXT DEFAULT ''
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
icon_path TEXT,
position INTEGER DEFAULT 0,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
)
"""
)
# 兼容更老的库:补字段
if not _table_has_column(conn, "groups", "folder_path"):
conn.execute("ALTER TABLE groups ADD COLUMN folder_path TEXT DEFAULT ''")
migrations = {
1: m1,
}
cur = _get_schema_version(conn)
# 未设置版本但已有表:视为 0走迁移补齐并写入版本号
while cur < LATEST_SCHEMA_VERSION:
nxt = cur + 1
fn = migrations.get(nxt)
if fn is None:
raise RuntimeError(f"missing migration for version {nxt}")
with conn:
fn()
_set_schema_version(conn, nxt)
cur = nxt
def init_db():
_migrate_old_db_if_needed()
conn = get_conn()
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
position INTEGER DEFAULT 0,
folder_path TEXT DEFAULT ''
# 先确保 settings 表存在,才能读写 schema_version
with conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"""
)
""")
c.execute("""
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
icon_path TEXT,
position INTEGER DEFAULT 0,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
)
""")
c.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
# 迁移:旧库没有 folder_path 字段时自动添加
try:
c.execute("ALTER TABLE groups ADD COLUMN folder_path TEXT DEFAULT ''")
conn.commit()
except Exception:
pass # 字段已存在则忽略
_apply_migrations(conn)
# 默认分组
c.execute("SELECT COUNT(*) FROM groups")
if c.fetchone()[0] == 0:
c.execute("INSERT INTO groups (name, position) VALUES ('常用程序', 0)")
conn.commit()
# 默认分组(数据迁移/初始化)
with conn:
row = conn.execute("SELECT COUNT(*) FROM groups").fetchone()
cnt = int(row[0] if row else 0)
if cnt == 0:
conn.execute("INSERT INTO groups (name, position) VALUES ('常用程序', 0)")
conn.close()
# 非结构类迁移(数据清洗/一次性转换)
_migrate_item_shortcut_paths_to_targets()

97
docs/开发.md Normal file
View File

@ -0,0 +1,97 @@
## 开发说明(开发 / 打包 / 安装 / 更新)
### 运行环境
- **系统**Windows 10/11
- **Python**:建议与当前项目一致的版本(你本机是 3.14
- **依赖**:见 `requirements.txt`
安装依赖:
```powershell
python -m pip install -r requirements.txt
python -m pip install pyinstaller
```
### 本地运行
```powershell
python main.py
```
### 版本号
- **版本号来源**`app_info.py` 里的 `__VERSION__`
- **安装包 AppVersion**:由 `installer/build_installer.py` 自动从 `app_info.py` 注入到 `installer/niumasoftware.generated.iss`
### 数据库存储与结构升级SQLite Migration
- **数据文件位置**:默认在 `%APPDATA%\CleanDesktopOrganizer\data.db`(不在安装目录,不会被覆盖更新影响)
- **结构升级**:在 `db/database.py` 内通过 `schema_version` 做版本化迁移
- 新增表/字段/索引:新增一个迁移版本并提升 `LATEST_SCHEMA_VERSION`
- 启动时 `init_db()` 自动从旧版本迁移到最新
### 一键打包(推荐)
项目根目录直接运行:
```powershell
.\build_installer.bat
```
它会自动完成:
- 清理 `build/`、`dist/`
- PyInstaller 打包主程序onedir`dist\niumasoftware\`
- PyInstaller 打包更新助手onefile`dist\niumasoftware\update_helper.exe`
- 生成 `installer\niumasoftware.generated.iss`
- 若检测到 Inno Setup 的 `ISCC.exe`,则自动编译安装包
### 手动打包PyInstaller
主程序onedir
```powershell
python -m PyInstaller --noconfirm --onedir --name niumasoftware --windowed `
--icon "logo.ico" `
--add-data "assets;assets" `
--add-data "logo.png;." `
main.py
```
更新助手onefile统一输出到 `dist\niumasoftware`
```powershell
python -m PyInstaller --noconfirm --onefile --name update_helper --console --distpath dist\niumasoftware `
--icon "logo.ico" `
update_helper.py
```
产物约定(用于安装包脚本):
- `dist\niumasoftware\niumasoftware.exe`
- `dist\niumasoftware\update_helper.exe`
### 生成安装包Inno Setup
1) 生成带版本号的最终脚本:
```powershell
python installer/build_installer.py
```
2) 用 Inno Setup Compiler 编译:
- `installer/niumasoftware.generated.iss`
### 全局安装后的静默覆盖更新(计划任务)
由于安装目录通常在 `Program Files`,普通权限无法覆盖 exe。
实现方式(已内置):
- 主程序下载更新到:`{commonappdata}\CleanDesktopOrganizer\updates\_update_new.exe`
- 写入请求:`{commonappdata}\CleanDesktopOrganizer\update_request.json`
- 触发计划任务:`schtasks /Run /TN CleanDesktopOrganizer\Update`
- 计划任务以 **SYSTEM** 执行 `{app}\update_helper.exe` 覆盖并重启

View File

@ -0,0 +1,42 @@
from __future__ import annotations
import os
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
APP_INFO = ROOT / "app_info.py"
ISS_TEMPLATE = ROOT / "installer" / "niumasoftware.iss"
ISS_OUT = ROOT / "installer" / "niumasoftware.generated.iss"
def read_version() -> str:
text = APP_INFO.read_text(encoding="utf-8")
m = re.search(r"""__VERSION__\s*=\s*["']([^"']+)["']""", text)
if not m:
raise RuntimeError("未在 app_info.py 中找到 __VERSION__")
return m.group(1).strip()
def generate_iss(version: str) -> str:
tpl = ISS_TEMPLATE.read_text(encoding="utf-8")
if "{#AppVersion}" not in tpl:
raise RuntimeError("niumasoftware.iss 未包含 {#AppVersion} 占位符")
header = f'#define AppVersion "{version}"\n'
# 如果模板里已经有 #define则保留并在最前面插入避免影响原结构
return header + tpl
def main() -> int:
version = read_version()
out_text = generate_iss(version)
ISS_OUT.write_text(out_text, encoding="utf-8", newline="\n")
print(f"generated: {ISS_OUT}")
print(f"version: {version}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,77 @@
#define AppVersion "0.0.3"
; Inno Setup 安装脚本(全局安装)
; 产物约定(统一放在 dist\niumasoftware 下):
; - dist\niumasoftware\niumasoftware.exe 主程序PyInstaller onedir
; - dist\niumasoftware\update_helper.exe 更新助手PyInstaller onefile
;
; 安装时会创建一个“按需运行”的计划任务(最高权限)用于静默覆盖更新:
; CleanDesktopOrganizer\Update
;
; 同时创建 ProgramData 目录并赋予 Users Modify 权限,主程序把下载与请求文件写进去:
; {commonappdata}\CleanDesktopOrganizer\
[Setup]
AppName=牛马软件柜
; 由 build_installer.py 生成时注入(不要手改)
AppVersion={#AppVersion}
; 安装包自身图标
SetupIconFile=..\logo.ico
DefaultDirName={autopf}\牛马软件柜
DefaultGroupName=牛马软件柜
OutputBaseFilename=牛马软件柜安装包_{#AppVersion}
Compression=lzma2
SolidCompression=yes
PrivilegesRequired=admin
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
DisableProgramGroupPage=yes
UninstallDisplayIcon={app}\niumasoftware.exe
[Tasks]
Name: desktopicon; Description: "创建桌面快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked
[Dirs]
; ProgramData给普通用户写入下载包与请求文件更新计划任务读取
Name: "{commonappdata}\CleanDesktopOrganizer"; Permissions: users-modify
Name: "{commonappdata}\CleanDesktopOrganizer\updates"; Permissions: users-modify
[Files]
; PyInstaller onedir直接复制整个目录包含 _internal、dll、资源等
Source: "..\dist\niumasoftware\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; 更新助手PyInstaller onefile 也输出到 dist\niumasoftware 目录
Source: "..\dist\niumasoftware\update_helper.exe"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\牛马软件柜"; Filename: "{app}\niumasoftware.exe"; IconFilename: "{app}\niumasoftware.exe"
Name: "{autodesktop}\牛马软件柜"; Filename: "{app}\niumasoftware.exe"; Tasks: desktopicon; IconFilename: "{app}\niumasoftware.exe"
[Run]
Filename: "{app}\niumasoftware.exe"; Description: "运行 牛马软件柜"; Flags: nowait postinstall skipifsilent
[UninstallRun]
Filename: "{sys}\schtasks.exe"; Parameters: "/Delete /TN ""CleanDesktopOrganizer\Update"" /F"; Flags: runhidden
[Code]
procedure CreateUpdateTask();
var
ResultCode: Integer;
Cmd: string;
begin
// 计划任务:以 SYSTEM 运行,按需触发(/SC ONDEMAND
// 注意TR 不带参数update_helper.exe 会读取 ProgramData 请求文件
Cmd :=
'/Create /F /TN "CleanDesktopOrganizer\Update" ' +
'/RU "SYSTEM" /RL HIGHEST /SC ONDEMAND ' +
'/TR "' + ExpandConstant('{app}\update_helper.exe') + '"';
Exec(ExpandConstant('{sys}\schtasks.exe'), Cmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssPostInstall then
begin
CreateUpdateTask();
end;
end;

View File

@ -0,0 +1,76 @@
; Inno Setup 安装脚本(全局安装)
; 产物约定(统一放在 dist\niumasoftware 下):
; - dist\niumasoftware\niumasoftware.exe 主程序PyInstaller onedir
; - dist\niumasoftware\update_helper.exe 更新助手PyInstaller onefile
;
; 安装时会创建一个“按需运行”的计划任务(最高权限)用于静默覆盖更新:
; CleanDesktopOrganizer\Update
;
; 同时创建 ProgramData 目录并赋予 Users Modify 权限,主程序把下载与请求文件写进去:
; {commonappdata}\CleanDesktopOrganizer\
[Setup]
AppName=牛马软件柜
; 由 build_installer.py 生成时注入(不要手改)
AppVersion={#AppVersion}
; 安装包自身图标
SetupIconFile=..\logo.ico
DefaultDirName={autopf}\牛马软件柜
DefaultGroupName=牛马软件柜
OutputBaseFilename=牛马软件柜安装包_{#AppVersion}
Compression=lzma2
SolidCompression=yes
PrivilegesRequired=admin
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
DisableProgramGroupPage=yes
UninstallDisplayIcon={app}\niumasoftware.exe
[Tasks]
Name: desktopicon; Description: "创建桌面快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked
[Dirs]
; ProgramData给普通用户写入下载包与请求文件更新计划任务读取
Name: "{commonappdata}\CleanDesktopOrganizer"; Permissions: users-modify
Name: "{commonappdata}\CleanDesktopOrganizer\updates"; Permissions: users-modify
[Files]
; PyInstaller onedir直接复制整个目录包含 _internal、dll、资源等
Source: "..\dist\niumasoftware\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; 更新助手PyInstaller onefile 也输出到 dist\niumasoftware 目录
Source: "..\dist\niumasoftware\update_helper.exe"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\牛马软件柜"; Filename: "{app}\niumasoftware.exe"; IconFilename: "{app}\niumasoftware.exe"
Name: "{autodesktop}\牛马软件柜"; Filename: "{app}\niumasoftware.exe"; Tasks: desktopicon; IconFilename: "{app}\niumasoftware.exe"
[Run]
Filename: "{app}\niumasoftware.exe"; Description: "运行 牛马软件柜"; Flags: nowait postinstall skipifsilent
[UninstallRun]
Filename: "{sys}\schtasks.exe"; Parameters: "/Delete /TN ""CleanDesktopOrganizer\Update"" /F"; Flags: runhidden
[Code]
procedure CreateUpdateTask();
var
ResultCode: Integer;
Cmd: string;
begin
// 计划任务:以 SYSTEM 运行,按需触发(/SC ONDEMAND
// 注意TR 不带参数update_helper.exe 会读取 ProgramData 请求文件
Cmd :=
'/Create /F /TN "CleanDesktopOrganizer\Update" ' +
'/RU "SYSTEM" /RL HIGHEST /SC ONDEMAND ' +
'/TR "' + ExpandConstant('{app}\update_helper.exe') + '"';
Exec(ExpandConstant('{sys}\schtasks.exe'), Cmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssPostInstall then
begin
CreateUpdateTask();
end;
end;

View File

@ -7,12 +7,15 @@ import os
import sys
import subprocess
import tempfile
import json
from PyQt6.QtCore import QThread, pyqtSignal, QObject
from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication
import urllib.request
UPDATE_CHECK_URL = "https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware"
APP_NAME = "CleanDesktopOrganizer"
UPDATE_TASK_NAME = r"CleanDesktopOrganizer\Update"
def _current_version() -> str:
@ -74,6 +77,51 @@ class _DownloadWorker(QThread):
self.error.emit(str(e))
def _programdata_dir() -> str:
base = os.environ.get("PROGRAMDATA") or r"C:\ProgramData"
return os.path.join(base, APP_NAME)
def _ensure_dir(p: str):
try:
os.makedirs(p, exist_ok=True)
except Exception:
pass
def _update_work_dir() -> str:
"""
全局安装场景下Program Files 不可写更新文件与请求文件统一放 ProgramData
该目录应由安装器创建并赋予 Users Modify 权限
"""
d = os.path.join(_programdata_dir(), "updates")
_ensure_dir(d)
if os.path.isdir(d):
return d
return tempfile.gettempdir()
def _request_file_path() -> str:
d = _programdata_dir()
_ensure_dir(d)
return os.path.join(d, "update_request.json")
def _run_update_task() -> bool:
try:
r = subprocess.run(
["schtasks", "/Run", "/TN", UPDATE_TASK_NAME],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
creationflags=subprocess.CREATE_NO_WINDOW,
)
return r.returncode == 0
except Exception:
return False
def _replace_and_restart(new_exe: str):
"""
onedir 模式只替换 exe 本身dll 等文件不变
@ -178,11 +226,8 @@ class Updater(QObject):
QMessageBox.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}")
def _download(self, url: str):
# 下载到原 exe 同目录,避免跨盘 move 失败
if getattr(sys, "frozen", False):
dest_dir = os.path.dirname(sys.executable)
else:
dest_dir = tempfile.gettempdir()
# 全局安装:不要下载到安装目录(通常在 Program Files不可写
dest_dir = _update_work_dir() if getattr(sys, "frozen", False) else tempfile.gettempdir()
dest = os.path.join(dest_dir, "_update_new.exe")
self._progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self._parent_widget)
@ -199,6 +244,29 @@ class Updater(QObject):
def _on_download_done(self, path: str):
self._progress.close()
# frozen 且全局安装:交给计划任务(最高权限)覆盖安装目录 exe
if getattr(sys, "frozen", False):
try:
req = {
"src": path,
"dst": sys.executable,
"pid": os.getpid(),
"restart": True,
}
with open(_request_file_path(), "w", encoding="utf-8") as f:
json.dump(req, f, ensure_ascii=False)
except Exception as e:
QMessageBox.critical(self._parent_widget, "更新失败", f"无法准备更新任务:\n{e}")
return
if _run_update_task():
QApplication.quit()
return
# 兜底:如果计划任务不存在/失败,尝试旧的自替换方式(仅当安装目录可写时有效)
_replace_and_restart(path)
return
_replace_and_restart(path)
def _on_download_error(self, err: str):

122
update_helper.py Normal file
View File

@ -0,0 +1,122 @@
"""
计划任务(最高权限)方式执行静默覆盖更新
工作流
- 主程序普通权限下载新 exe 到可写目录建议 ProgramData并写入 update_request.json
- 计划任务以 SYSTEM/管理员身份运行本程序
- 本程序读取请求文件 -> 等待主程序退出 -> 覆盖安装目录 exe -> 重启 -> 清理请求文件
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
APP_NAME = "CleanDesktopOrganizer"
REQUEST_FILE = "update_request.json"
def _programdata_dir() -> str:
base = os.environ.get("PROGRAMDATA") or r"C:\ProgramData"
return os.path.join(base, APP_NAME)
def _request_path() -> str:
return os.path.join(_programdata_dir(), REQUEST_FILE)
def _read_request() -> dict:
p = _request_path()
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
def _pid_exists(pid: int) -> bool:
if pid <= 0:
return False
try:
# tasklist 不需要管理员权限SYSTEM 下也可用
out = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
creationflags=subprocess.CREATE_NO_WINDOW,
).stdout
return str(pid) in out
except Exception:
return False
def _copy_with_retries(src: str, dst: str, retries: int = 10) -> bool:
for _ in range(max(1, retries)):
try:
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.exists(dst):
try:
os.chmod(dst, 0o666)
except Exception:
pass
# 用二进制流复制,避免权限/锁问题时直接抛错
with open(src, "rb") as rf:
data = rf.read()
with open(dst, "wb") as wf:
wf.write(data)
return True
except Exception:
time.sleep(0.8)
return False
def main() -> int:
try:
req = _read_request()
except FileNotFoundError:
return 0
except Exception:
return 2
src = str(req.get("src") or "")
dst = str(req.get("dst") or "")
pid = int(req.get("pid") or 0)
restart = bool(req.get("restart", True))
if not src or not dst:
return 3
# 等待主程序退出,最多 60 秒
waited = 0.0
while _pid_exists(pid) and waited < 60.0:
time.sleep(0.5)
waited += 0.5
ok = _copy_with_retries(src, dst, retries=15)
try:
os.remove(src)
except Exception:
pass
# 清理请求文件(成功/失败都尽量清掉,避免重复执行)
try:
os.remove(_request_path())
except Exception:
pass
if ok and restart:
try:
subprocess.Popen([dst], creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW)
except Exception:
pass
return 0
return 4
if __name__ == "__main__":
raise SystemExit(main())