做安装包版本
This commit is contained in:
parent
d583e711f0
commit
920d142d9c
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@ ui/__pycache__/
|
|||||||
# 打包exe相关
|
# 打包exe相关
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
installer/Output/
|
||||||
*.spec
|
*.spec
|
||||||
|
|
||||||
# 编辑器
|
# 编辑器
|
||||||
|
|||||||
@ -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
67
build_installer.bat
Normal file
@ -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
|
||||||
|
|
||||||
Binary file not shown.
119
db/database.py
119
db/database.py
@ -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
97
docs/开发.md
Normal 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` 覆盖并重启
|
||||||
|
|
||||||
42
installer/build_installer.py
Normal file
42
installer/build_installer.py
Normal 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())
|
||||||
|
|
||||||
77
installer/niumasoftware.generated.iss
Normal file
77
installer/niumasoftware.generated.iss
Normal 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;
|
||||||
|
|
||||||
76
installer/niumasoftware.iss
Normal file
76
installer/niumasoftware.iss
Normal 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;
|
||||||
|
|
||||||
@ -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
122
update_helper.py
Normal 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())
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user