做安装包版本

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相关 # 打包exe相关
dist/ dist/
build/ build/
installer/Output/
*.spec *.spec
# 编辑器 # 编辑器

View File

@ -1,5 +1,5 @@
APP_NAME = "牛马软件柜" APP_NAME = "牛马软件柜"
__VERSION__ = "0.0.3" __VERSION__ = "0.0.4"
def app_title() -> str: 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 目录(避免写在项目目录) # 数据库放到 Windows 常见的 APPDATA 目录(避免写在项目目录)
APP_NAME = "CleanDesktopOrganizer" APP_NAME = "CleanDesktopOrganizer"
_SCHEMA_VERSION_KEY = "schema_version"
LATEST_SCHEMA_VERSION = 1
_PROJECT_DB_PATH = os.path.join( _PROJECT_DB_PATH = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "data.db" os.path.dirname(os.path.dirname(__file__)), "data.db"
@ -37,22 +39,71 @@ def get_conn():
_ensure_db_dir() _ensure_db_dir()
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA foreign_keys=ON")
except Exception:
pass
return conn return conn
def init_db(): def _get_schema_version(conn: sqlite3.Connection) -> int:
_migrate_old_db_if_needed() try:
conn = get_conn() row = conn.execute(
c = conn.cursor() "SELECT value FROM settings WHERE key=?",
c.execute(""" (_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 ( CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
position INTEGER DEFAULT 0, position INTEGER DEFAULT 0,
folder_path TEXT DEFAULT '' folder_path TEXT DEFAULT ''
) )
""") """
c.execute(""" )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL, group_id INTEGER NOT NULL,
@ -62,27 +113,53 @@ def init_db():
position INTEGER DEFAULT 0, position INTEGER DEFAULT 0,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
) )
""") """
c.execute(""" )
# 兼容更老的库:补字段
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()
# 先确保 settings 表存在,才能读写 schema_version
with conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
) )
""") """
# 迁移:旧库没有 folder_path 字段时自动添加 )
try: _apply_migrations(conn)
c.execute("ALTER TABLE groups ADD COLUMN folder_path TEXT DEFAULT ''")
conn.commit()
except Exception:
pass # 字段已存在则忽略
# 默认分组 # 默认分组(数据迁移/初始化)
c.execute("SELECT COUNT(*) FROM groups") with conn:
if c.fetchone()[0] == 0: row = conn.execute("SELECT COUNT(*) FROM groups").fetchone()
c.execute("INSERT INTO groups (name, position) VALUES ('常用程序', 0)") cnt = int(row[0] if row else 0)
conn.commit() if cnt == 0:
conn.execute("INSERT INTO groups (name, position) VALUES ('常用程序', 0)")
conn.close() conn.close()
# 非结构类迁移(数据清洗/一次性转换)
_migrate_item_shortcut_paths_to_targets() _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 sys
import subprocess import subprocess
import tempfile import tempfile
import json
from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtCore import QThread, pyqtSignal, QObject
from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication
import urllib.request import urllib.request
UPDATE_CHECK_URL = "https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware" 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: def _current_version() -> str:
@ -74,6 +77,51 @@ class _DownloadWorker(QThread):
self.error.emit(str(e)) 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): def _replace_and_restart(new_exe: str):
""" """
onedir 模式只替换 exe 本身dll 等文件不变 onedir 模式只替换 exe 本身dll 等文件不变
@ -178,11 +226,8 @@ class Updater(QObject):
QMessageBox.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}") QMessageBox.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}")
def _download(self, url: str): def _download(self, url: str):
# 下载到原 exe 同目录,避免跨盘 move 失败 # 全局安装:不要下载到安装目录(通常在 Program Files不可写
if getattr(sys, "frozen", False): dest_dir = _update_work_dir() if getattr(sys, "frozen", False) else tempfile.gettempdir()
dest_dir = os.path.dirname(sys.executable)
else:
dest_dir = tempfile.gettempdir()
dest = os.path.join(dest_dir, "_update_new.exe") dest = os.path.join(dest_dir, "_update_new.exe")
self._progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self._parent_widget) self._progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self._parent_widget)
@ -199,6 +244,29 @@ class Updater(QObject):
def _on_download_done(self, path: str): def _on_download_done(self, path: str):
self._progress.close() 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) _replace_and_restart(path)
def _on_download_error(self, err: str): 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())