diff --git a/.gitignore b/.gitignore index 7a4e2c2..38e8449 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ ui/__pycache__/ # 打包exe相关 dist/ build/ +installer/Output/ *.spec # 编辑器 diff --git a/app_info.py b/app_info.py index b66c1e7..ad0fc0d 100644 --- a/app_info.py +++ b/app_info.py @@ -1,5 +1,5 @@ APP_NAME = "牛马软件柜" -__VERSION__ = "0.0.3" +__VERSION__ = "0.0.4" def app_title() -> str: diff --git a/build_installer.bat b/build_installer.bat new file mode 100644 index 0000000..b7e0afa --- /dev/null +++ b/build_installer.bat @@ -0,0 +1,67 @@ +@echo off +chcp 65001 >nul +setlocal enabledelayedexpansion + +REM 一键打包:PyInstaller -> dist\niumasoftware -> 生成 Inno 脚本 -> (可选) 编译安装包 +REM 需要: +REM - Python + PyInstaller(pip 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 + diff --git a/db/__pycache__/database.cpython-314.pyc b/db/__pycache__/database.cpython-314.pyc index d9d0367..5c3f1a0 100644 Binary files a/db/__pycache__/database.cpython-314.pyc and b/db/__pycache__/database.cpython-314.pyc differ diff --git a/db/database.py b/db/database.py index 681752e..50fbc2d 100644 --- a/db/database.py +++ b/db/database.py @@ -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() diff --git a/docs/开发.md b/docs/开发.md new file mode 100644 index 0000000..a16d7d4 --- /dev/null +++ b/docs/开发.md @@ -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` 覆盖并重启 + diff --git a/installer/build_installer.py b/installer/build_installer.py new file mode 100644 index 0000000..e97f949 --- /dev/null +++ b/installer/build_installer.py @@ -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()) + diff --git a/installer/niumasoftware.generated.iss b/installer/niumasoftware.generated.iss new file mode 100644 index 0000000..1ce4ffe --- /dev/null +++ b/installer/niumasoftware.generated.iss @@ -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; + diff --git a/installer/niumasoftware.iss b/installer/niumasoftware.iss new file mode 100644 index 0000000..698bdc4 --- /dev/null +++ b/installer/niumasoftware.iss @@ -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; + diff --git a/ui/updater.py b/ui/updater.py index cfda538..0d46484 100644 --- a/ui/updater.py +++ b/ui/updater.py @@ -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): diff --git a/update_helper.py b/update_helper.py new file mode 100644 index 0000000..f65f112 --- /dev/null +++ b/update_helper.py @@ -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()) +