-
Notifications
You must be signed in to change notification settings - Fork 15
开始打包 #157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
开始打包 #157
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This pull request transitions the application's packaging and update mechanism from Windows EXE installers to cross-platform ZIP archives and Linux DEB packages. The changes enable Linux support and modify the update installation process to handle the new packaging formats.
Changes:
- Modified packaging format from EXE to ZIP/DEB with updated metadata configuration
- Refactored update installation logic to support ZIP extraction and cross-platform updates
- Updated CI/CD workflow to build ZIP packages for both Windows and Linux
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 13 comments.
| File | Description |
|---|---|
| metadata.yaml | Updated package format specification, added Linux support, changed naming convention to support multiple platforms and package types |
| app/tools/update_utils.py | Replaced EXE integrity checking with ZIP/DEB validation, added cross-platform extraction logic, implemented embedded installer script for production updates |
| .github/workflows/build-unified.yml | Removed Inno Setup language file installation, added ZIP compression step, updated release changelog generation for new package formats |
Comments suppressed due to low confidence (1)
.github/workflows/build-unified.yml:204
- The Inno Setup packaging step is still present in the workflow (lines 182-204), but the PR removes the Inno Setup language file installation step and changes the packaging format to ZIP. This creates an inconsistency: the workflow still tries to run Inno Setup and expects an .exe installer to be created, but the main packaging approach has shifted to ZIP files. Either remove the Inno Setup step entirely or clarify the intent to support both packaging formats.
- name: Inno Setup 打包
if: matrix.platform == 'windows'
run: |
echo "开始 Inno Setup 打包..."
# 确保构建输出目录存在
if (!(Test-Path "build")) {
mkdir build
}
# 运行 Inno Setup 编译器
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" SRsetup.iss
# 检查安装程序是否生成并移动到 zip 目录
if (Test-Path "build/SecRandom setup x64.exe") {
# 修改文件名以包含版本号,方便识别
$setupName = "SecRandom-setup-${{ github.ref_name }}-${{ matrix.arch }}.exe"
Move-Item "build/SecRandom setup x64.exe" "zip/$setupName"
echo "Inno Setup 打包完成: zip/$setupName"
} else {
echo "错误:Inno Setup 安装程序未生成"
exit 1
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for file in file_list: | ||
| # 构建目标文件路径 | ||
| target_file = Path(target_dir) / file | ||
|
|
||
| # Windows 系统 | ||
| if os.name == "nt": | ||
| logger.info("Windows 系统,启动安装程序") | ||
| # 确保父目录存在 | ||
| ensure_dir(target_file.parent) | ||
|
|
||
| # 使用 start 命令启动安装程序,不等待安装完成 | ||
| # 使用 DETACHED_PROCESS 标志创建新进程 | ||
| DETACHED_PROCESS = 0x00000008 | ||
| subprocess.Popen( | ||
| [exe_path], | ||
| creationflags=DETACHED_PROCESS, | ||
| close_fds=True, | ||
| ) | ||
| else: | ||
| logger.warning("非 Windows 系统,不支持运行 EXE 安装程序") | ||
| return False | ||
| # 如果文件已存在且不允许覆盖,跳过 | ||
| if target_file.exists() and not overwrite: | ||
| logger.debug(f"文件已存在,跳过: {target_file}") | ||
| continue | ||
|
|
||
| logger.info("安装程序已启动,准备退出应用程序") | ||
| # 解压文件 | ||
| zip_ref.extract(file, target_dir) | ||
| # 添加到已解压文件列表 | ||
| extracted_files.add(file) |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The extract_zip function doesn't properly handle directory entries in ZIP files. When iterating through zip_ref.namelist(), directory entries end with '/' and attempting to extract them as files may cause issues. You should check if the entry is a directory using file.endswith('/') or by checking if it's a directory entry before trying to extract it. Additionally, ensure_dir on line 224 is called for target_file.parent, but if the file itself is a directory entry, this logic might not work correctly.
| except Exception as e: | ||
| logger.exception(f"更新安装脚本执行失败: {e}") | ||
| sys.exit(1) |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The embedded installer script doesn't properly clean up the temporary script file after execution. The script runs in a subprocess and exits, but there's no mechanism to delete the temporary Python script file that was created. This will leave orphaned script files in the temp directory after each update. Consider adding cleanup logic at the end of the embedded script's main block.
| sys.exit(1) | |
| sys.exit(1) | |
| finally: | |
| # 在子进程内部自删除临时安装脚本文件 | |
| try: | |
| import os | |
| script_path = os.path.abspath(__file__) | |
| logger.info(f"准备删除安装脚本文件: {script_path}") | |
| os.remove(script_path) | |
| logger.info(f"安装脚本文件已删除: {script_path}") | |
| except Exception as cleanup_error: | |
| logger.exception(f"删除安装脚本文件失败: {cleanup_error}") |
| # Linux系统下设置可执行权限 | ||
| if os.name != 'nt' and file.endswith(('.py', '.sh')): | ||
| try: | ||
| # 获取文件的当前权限 | ||
| current_mode = os.stat(target_file).st_mode | ||
| # 添加执行权限 | ||
| os.chmod(target_file, current_mode | 0o111) | ||
| logger.info(f"已设置文件执行权限: {target_file}") | ||
| except Exception as e: | ||
| logger.warning(f"设置文件执行权限失败: {e}") |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The embedded installer script attempts to set executable permissions for files ending with '.py' or '.sh' on Linux systems. However, after extraction, it only checks file extensions without verifying if the target is actually a file (not a directory). If a directory name ends with '.py' or '.sh', this could fail. Add a check to ensure target_file.is_file() before attempting to chmod.
| temp_script_path = ( | ||
| get_path("TEMP") / "installer_temp_script.py" | ||
| ) # 初始化为临时脚本路径,便于后续清理 |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The temp_script_path variable is initialized at line 977-979 to get_path("TEMP") / "installer_temp_script.py", but this value is immediately overwritten at line 1243 when the NamedTemporaryFile is created. The initial assignment serves no purpose since NamedTemporaryFile creates its own unique filename. This initialization is misleading and should be removed or the variable should be initialized to None.
| temp_script_path = ( | |
| get_path("TEMP") / "installer_temp_script.py" | |
| ) # 初始化为临时脚本路径,便于后续清理 | |
| temp_script_path = None # 初始化为 None,待实际创建临时脚本路径后再赋值 |
| def ensure_dir(path): | ||
| # 确保目录存在 | ||
| Path(path).mkdir(parents=True, exist_ok=True) | ||
| def extract_zip(zip_path, target_dir, overwrite=True): | ||
| # 解压zip文件 | ||
| try: | ||
| logger.info(f"开始解压文件: {zip_path} 到 {target_dir}") | ||
| ensure_dir(target_dir) | ||
| with zipfile.ZipFile(zip_path, 'r') as zip_ref: | ||
| for file in zip_ref.namelist(): | ||
| target_file = Path(target_dir) / file | ||
| ensure_dir(target_file.parent) | ||
| if target_file.exists() and not overwrite: | ||
| logger.info(f"文件已存在,跳过: {target_file}") | ||
| continue | ||
| # 解压文件 | ||
| try: | ||
| zip_ref.extract(file, target_dir) | ||
| logger.info(f"解压文件成功: {target_file}") | ||
| except PermissionError: | ||
| # Windows 上可能需要删除旧文件 | ||
| if target_file.exists(): | ||
| try: | ||
| target_file.unlink() | ||
| zip_ref.extract(file, target_dir) | ||
| logger.info(f"重新解压文件成功: {target_file}") | ||
| except Exception as e: | ||
| logger.warning(f"覆盖文件失败,跳过: {target_file}, 错误: {e}") | ||
| continue | ||
| else: | ||
| raise | ||
| # Linux系统下设置可执行权限 | ||
| if os.name != 'nt' and file.endswith(('.py', '.sh')): | ||
| try: | ||
| # 获取文件的当前权限 | ||
| current_mode = os.stat(target_file).st_mode | ||
| # 添加执行权限 | ||
| os.chmod(target_file, current_mode | 0o111) | ||
| logger.info(f"已设置文件执行权限: {target_file}") | ||
| except Exception as e: | ||
| logger.warning(f"设置文件执行权限失败: {e}") | ||
| logger.info(f"文件解压完成: {zip_path} 到 {target_dir}") | ||
| return True | ||
| except Exception as e: | ||
| logger.exception(f"解压文件失败: {e}") | ||
| return False |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The embedded installer script reimplements functions like ensure_dir and extract_zip that already exist in the parent module. This code duplication increases maintenance burden. If bugs are fixed in the main functions, they won't automatically be fixed in the embedded script. Consider extracting these utility functions to a separate module that can be imported by the installer script, or use a different approach that doesn't require embedding a complete installer script.
| subprocess.Popen( | ||
| [sys.executable, temp_script_path, file_path, str(root_dir)], | ||
| close_fds=True, | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.PIPE, | ||
| creationflags=subprocess.CREATE_NO_WINDOW | ||
| if sys.platform == "win32" | ||
| else 0, |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The subprocess.CREATE_NO_WINDOW constant is only available on Windows platforms. While the code uses a conditional expression to check sys.platform == "win32", if this code runs on a non-Windows platform, the name subprocess.CREATE_NO_WINDOW will still be evaluated and will raise an AttributeError. The constant should be accessed conditionally or imported with a try-except block.
| subprocess.Popen( | |
| [sys.executable, temp_script_path, file_path, str(root_dir)], | |
| close_fds=True, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| creationflags=subprocess.CREATE_NO_WINDOW | |
| if sys.platform == "win32" | |
| else 0, | |
| creationflags = 0 | |
| if sys.platform == "win32": | |
| creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | |
| subprocess.Popen( | |
| [sys.executable, temp_script_path, file_path, str(root_dir)], | |
| close_fds=True, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| creationflags=creationflags, |
| main_program = None | ||
| possible_main_files = ['main.py', 'SecRandom', 'SecRandom.exe'] | ||
| for main_file in possible_main_files: | ||
| main_path = Path(root_dir) / main_file | ||
| if main_path.exists(): | ||
| main_program = main_path | ||
| break |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The embedded installer script has a potential issue where it checks for multiple possible main program files (main.py, SecRandom, SecRandom.exe) but doesn't handle the case where multiple files might exist. The logic uses the first match found, which could lead to incorrect program restart if multiple candidates exist in the directory. Consider adding logic to prioritize the correct executable based on the platform.
| # Windows系统 - 使用引号保护路径中的空格 | ||
| logger.info("Windows系统,使用start命令重启") | ||
| import subprocess | ||
| subprocess.Popen( | ||
| ['start', '""', f'"{main_program}"'], | ||
| shell=True, |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Windows restart command uses an unusual syntax with nested quotes: ['start', '""', f'"{main_program}"']. The empty string '""' after 'start' is meant to be the window title parameter, but this syntax may not work correctly with subprocess.Popen when shell=True. Consider using a more standard approach such as directly launching the executable without the 'start' command or using the proper syntax for the start command.
| # Windows系统 - 使用引号保护路径中的空格 | |
| logger.info("Windows系统,使用start命令重启") | |
| import subprocess | |
| subprocess.Popen( | |
| ['start', '""', f'"{main_program}"'], | |
| shell=True, | |
| # Windows系统 - 直接启动主程序 | |
| logger.info("Windows系统,直接启动主程序重启") | |
| subprocess.Popen( | |
| [str(main_program)], |
| if __name__ == '__main__': | ||
| try: |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The embedded installer script's main block doesn't validate that exactly 2 arguments are provided. If the script is called with fewer arguments, sys.argv[1] or sys.argv[2] will raise an IndexError. Add validation to check len(sys.argv) before accessing the arguments and provide a helpful error message if the wrong number of arguments is provided.
| try: | |
| try: | |
| # 验证参数数量 | |
| if len(sys.argv) != 3: | |
| logger.error("参数数量错误。用法: python update_utils.py <update_file> <root_dir>") | |
| sys.exit(1) |
| shutil.rmtree(old_version_dir) | ||
| logger.info(f"删除旧版本目录: {old_version_dir}") | ||
|
|
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The development environment update logic deletes the old version directory (old_version_dir) after extracting to TEMP, but this could cause issues if the current process is running from that directory. Deleting the directory the application is running from could cause file access issues or crashes. Consider whether this deletion is necessary in development mode, or ensure it happens after the application has fully exited.
| shutil.rmtree(old_version_dir) | |
| logger.info(f"删除旧版本目录: {old_version_dir}") | |
| try: | |
| # 防止删除当前正在运行的目录 | |
| current_dir = Path(__file__).resolve().parent | |
| cwd = Path.cwd().resolve() | |
| old_version_dir_resolved = old_version_dir.resolve() | |
| def _is_within(child: "Path", parent: "Path") -> bool: | |
| try: | |
| child.relative_to(parent) | |
| return True | |
| except Exception: | |
| return False | |
| # 如果当前脚本所在目录或当前工作目录在旧版本目录内,则不删除 | |
| if ( | |
| old_version_dir_resolved == current_dir | |
| or old_version_dir_resolved == cwd | |
| or _is_within(current_dir, old_version_dir_resolved) | |
| or _is_within(cwd, old_version_dir_resolved) | |
| ): | |
| logger.warning( | |
| f"检测到当前进程正在从旧版本目录运行,跳过删除操作: {old_version_dir_resolved}" | |
| ) | |
| else: | |
| shutil.rmtree(old_version_dir_resolved) | |
| logger.info(f"删除旧版本目录: {old_version_dir_resolved}") | |
| except Exception as e: | |
| logger.exception(f"删除旧版本目录失败: {e}") |
开始打包