- Add Zig compiler support for Python 3.13+ and smart conflict diagnostics - Refactor Nuitka compiler decision matrix and decouple syntax pre-checks - Enhance UPX DLL protection and expand dependency isolation rules - Fix legacy MSVC crash, Windows SDK misdetection, and folder nesting issues - Complete compiler-related i18n localization
7220 lines
365 KiB
Python
7220 lines
365 KiB
Python
import sys
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
import re
|
||
import time
|
||
import stat
|
||
import json
|
||
import math
|
||
import wave
|
||
import struct
|
||
import traceback
|
||
import threading
|
||
import configparser
|
||
import locale
|
||
import ast
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from pathlib import Path
|
||
import logging
|
||
|
||
_orig_resolve = Path.resolve
|
||
def _safe_resolve(self, strict=False):
|
||
try:
|
||
return _orig_resolve(self, strict=strict)
|
||
except Exception:
|
||
return self.absolute()
|
||
Path.resolve = _safe_resolve
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
if os.name == 'nt':
|
||
try:
|
||
import winreg
|
||
except ImportError:
|
||
winreg = None
|
||
else:
|
||
winreg = None
|
||
|
||
os.environ["QT_LOGGING_RULES"] = "qt.text.font.db=false;qt.multimedia*=false"
|
||
|
||
from PySide6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget,
|
||
QPushButton, QLabel, QLineEdit, QFileDialog, QCheckBox,
|
||
QComboBox, QFrame, QStackedLayout, QFormLayout, QTextEdit,
|
||
QGraphicsOpacityEffect, QGridLayout, QTabWidget,
|
||
QMessageBox, QInputDialog, QFileIconProvider, QSizePolicy, QScrollArea,
|
||
QGraphicsDropShadowEffect, QSpinBox, QListWidget, QListWidgetItem,
|
||
QListView, QStyledItemDelegate, QMenu, QTableWidget, QTableWidgetItem,
|
||
QHeaderView, QDialog, QRadioButton)
|
||
from PySide6.QtCore import (Qt, QThread, Signal, QPropertyAnimation, QEasingCurve,
|
||
QParallelAnimationGroup, QFileInfo, QVariantAnimation,
|
||
QTimer, QPointF, QRectF, QRect, QSize, QUrl, QLocale, QObject, QTranslator, QLibraryInfo)
|
||
from PySide6.QtGui import (QFont, QDragEnterEvent, QDropEvent, QIcon, QPixmap,
|
||
QPainter, QColor, QPen, QImage, QImageWriter, QDesktopServices, QPalette)
|
||
from PySide6.QtSvg import QSvgRenderer
|
||
|
||
try:
|
||
from PySide6.QtMultimedia import QSoundEffect
|
||
HAS_QT_AUDIO = True
|
||
except ImportError:
|
||
HAS_QT_AUDIO = False
|
||
|
||
__app_name__ = "QPyPack"
|
||
__version__ = "2.7.11"
|
||
__author__ = "QwejayHuang"
|
||
__company__ = "QwejayHuang"
|
||
__description__ = "Modern Cross-Platform Python Packaging GUI Powered by PyInstaller & Nuitka"
|
||
|
||
DISK_SPACE_MIN_GB = 0.5
|
||
DISK_SPACE_WARN_GB = 5.0
|
||
DISK_SPACE_LOW_GB = 3.0
|
||
MEMORY_LOW_THRESHOLD_GB = 2.0
|
||
MEMORY_MEDIUM_THRESHOLD_GB = 3.5
|
||
MEMORY_HIGH_THRESHOLD_GB = 6.0
|
||
BUILD_TIMEOUT_SECONDS = 3600
|
||
COMMAND_TIMEOUT_SECONDS = 120
|
||
PYTHON_CHECK_TIMEOUT = 3
|
||
PROCESS_KILL_TIMEOUT = 5
|
||
MAX_CLEANUP_RETRIES = 15
|
||
CLEANUP_RETRY_DELAY = 0.8
|
||
|
||
_CONFIG_DIR = Path.home() / ".qpypack"
|
||
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||
CONFIG_FILE = (_CONFIG_DIR / "config.ini").as_posix()
|
||
|
||
ZH_CN_DICT = {
|
||
"PyPI Official (Global Default)": "PyPI 官方源 (默认)",
|
||
"Python TestPyPI": "TestPyPI 测试源",
|
||
"AWS PyPI Mirror (US/EU)": "AWS PyPI 镜像 (美/欧)",
|
||
"Tsinghua University (China)": "清华大学镜像",
|
||
"Aliyun Cloud (China)": "阿里云镜像",
|
||
"Tencent Cloud (China)": "腾讯云镜像",
|
||
"Huawei Cloud (China)": "华为云镜像",
|
||
"USTC (China)": "中科大镜像",
|
||
|
||
"Modern Cross-Platform Python Application Packager": "现代化的跨平台 Python 应用打包工具",
|
||
"Modern Cross-Platform Python Packaging GUI Powered by PyInstaller & Nuitka": "基于 PyInstaller 与 Nuitka 的现代化跨平台 Python 打包工具",
|
||
"Python Packaging, Reimagined.": "重新定义 Python 应用打包体验",
|
||
"Drop Python source code to start": "拖拽 Python 源码至此开始",
|
||
"Drag & Drop Python script (.py/.pyw) here\nor Click to Browse": "拖拽 Python 脚本 (.py/.pyw) 到此\n或点击浏览",
|
||
|
||
"Build Settings": "构建设置",
|
||
"Preferences": "偏好设置",
|
||
"About": "关于",
|
||
"Engine": "构建引擎",
|
||
"Dependencies": "依赖管理",
|
||
"Resources": "附加资源",
|
||
"Optimization & Security": "优化与安全",
|
||
"Package Map": "包名与依赖",
|
||
"Execution Log": "执行日志",
|
||
|
||
"Loaded: {filename}": "已载入: {filename}",
|
||
"Parsing metadata...": "解析元数据...",
|
||
"Ready, waiting for build.": "就绪,等待构建",
|
||
"Initializing build...": "初始化构建...",
|
||
"Preparing engine...": "准备引擎...",
|
||
"Build Successful": "构建成功",
|
||
"Open output directory or reset workspace.": "打开输出目录或重置工作区",
|
||
"Build Failed": "构建失败",
|
||
"Check log output below for troubleshooting.": "请查看下方日志排查问题",
|
||
"Status: Ready": "状态: 就绪",
|
||
"Status: Parsing {filename}...": "状态: 解析 {filename}...",
|
||
"Status: Loaded {filename}{mode}": "状态: 已载入 {filename}{mode}",
|
||
"Status: Packaging ({engine}) ...": "状态: 构建中 ({engine})...",
|
||
"Status: Build Completed": "状态: 构建完成",
|
||
"Status: Build Failed": "状态: 构建失败",
|
||
"Status: Workspace Reset": "状态: 工作区已重置",
|
||
"Status: Build Cancelled": "状态: 构建已取消",
|
||
"Build Cancelled": "构建已取消",
|
||
" [Console]": " [控制台]",
|
||
" [No Console]": " [无控制台]",
|
||
|
||
"Start Build": "开始构建",
|
||
"Stop Build": "停止构建",
|
||
"Open Directory": "打开目录",
|
||
"Rebuild": "重新构建",
|
||
"Save & Return": "保存并返回",
|
||
"Browse": "浏览...",
|
||
"AST Scan": "AST 扫描",
|
||
"Add File": "添加文件",
|
||
"Add Dir": "添加目录",
|
||
"Remove Selected": "移除选中项",
|
||
"Clear All": "清空全部",
|
||
"Add Mapping": "添加映射",
|
||
"Restore Defaults": "恢复默认",
|
||
"Export Preset...": "导出预设...",
|
||
"Import Preset...": "导入预设...",
|
||
"Reset to Default Config": "重置默认配置",
|
||
"Cancel & Return": "取消并返回",
|
||
"Cancel": "取消",
|
||
"Configure Build Settings": "构建设置",
|
||
"Toggle Execution Log": "切换日志",
|
||
"Detailed Mode": "详细日志",
|
||
"Concise Mode": "精简日志",
|
||
"Copy": "复制",
|
||
"Select All": "全选",
|
||
"Clear Log": "清空日志",
|
||
"Export Log...": "导出日志...",
|
||
"Reset": "重置",
|
||
"Undo": "撤销",
|
||
"Cut": "剪切",
|
||
"Paste": "粘贴",
|
||
"Create": "创建",
|
||
"Generate": "生成",
|
||
"Save Certificate": "保存证书",
|
||
"Error": "错误",
|
||
"<b>Are you sure you want to reset all preferences?</b><br><span style='color:#64748b; font-size:12px;'>All settings will be restored to default state.</span>":
|
||
"<b>确定重置所有偏好设置?</b><br><span style='color:#64748b; font-size:12px;'>所有设置将恢复为默认状态。</span>",
|
||
|
||
"Engine & Environment": "引擎与环境",
|
||
"Execution Mode": "执行模式",
|
||
"Mirrors & Scanner": "镜像与扫描",
|
||
"Additional Resources (Drag & Drop Supported)": "附加资源 (支持拖拽)",
|
||
"Performance Optimization": "性能优化",
|
||
"Code Signing": "代码签名",
|
||
"Lock Core Dependencies": "锁定核心依赖",
|
||
"Package Name Mappings": "包名映射",
|
||
"UI Language:": "界面语言:",
|
||
"App Metadata & Presets": "元数据与预设",
|
||
"Output Location": "输出位置",
|
||
"Preferences & System Behavior": "偏好与行为",
|
||
|
||
"Build Engine:": "构建引擎:",
|
||
"Packaging Mode:": "打包模式:",
|
||
"Interpreter:": "解释器:",
|
||
"Output Name:": "输出名称:",
|
||
"App Icon:": "应用图标:",
|
||
"Primary PIP Index:": "主 PIP 源:",
|
||
"Backup PIP Index:": "备用 PIP 源:",
|
||
"Requirements File:": "依赖清单 (requirements):",
|
||
"Hidden Imports:": "隐式导入 (hidden-imports):",
|
||
"Exclude Modules:": "排除模块 (excludes):",
|
||
"CPU Cores:": "CPU 核心数:",
|
||
"UPX Path:": "UPX 路径:",
|
||
"PyInstaller Version:": "PyInstaller 版本:",
|
||
"Nuitka Version:": "Nuitka 版本:",
|
||
"Version:": "版本:",
|
||
"Author/Company:": "作者/公司:",
|
||
"Description:": "描述:",
|
||
"Output Location:": "保存位置:",
|
||
"Target Directory:": "自定义路径:",
|
||
"Temporary Directory:": "临时目录:",
|
||
"Source Directory (.qpypack_build)": "源码同级目录 (.qpypack_build)",
|
||
"System Temp Directory": "系统 Temp 目录",
|
||
"Source File Directory": "源码同级目录",
|
||
"Custom Directory": "自定义目录",
|
||
"Import Name": "导入名",
|
||
"PyPI Package Name": "PyPI 包名",
|
||
"File": "文件",
|
||
"Directory": "目录",
|
||
"Edit Path": "编辑路径",
|
||
"Target relative path:": "目标相对路径:",
|
||
"Import name (e.g. cv2):": "导入名 (如 cv2):",
|
||
"PyPI package name for [{imp_name}]:": "[{imp_name}] 对应 PyPI 包名:",
|
||
"Commercial PFX File:": "商业证书文件 (.pfx):",
|
||
"Cert Password:": "证书密钥密码:",
|
||
"Certificate Subject (e.g. My Company):": "证书颁发者 (如 My Company):",
|
||
"Confirm Password:": "确认密码:",
|
||
|
||
"Compatibility Mode (Default)": "兼容模式 (默认)",
|
||
"Lite Mode (Smaller size)": "精简模式 (体积小)",
|
||
"Maximum compatibility. Recommended for apps with complex dynamic imports.": "最大兼容性。适合使用了复杂动态导入的项目。",
|
||
"Smaller output size. Recommended for 95% of standard apps.": "产物体积最小。推荐 95% 的常规应用使用。",
|
||
"Tip: Build failed in Lite Mode. You can try switching to [Compatibility Mode] in Build Settings and rebuild.": "提示: 精简模式构建失败。您可以尝试在【构建设置】中勾选 [兼容模式] 重新打包。",
|
||
"One-File Mode (--onefile)": "单文件模式 (--onefile)",
|
||
"Folder Mode (--onedir)": "文件夹模式 (--onedir)",
|
||
"Contents Directory (--contents-directory):": "内部资源目录:",
|
||
"Internal directory name for dependencies (default: _internal)": "内部依赖与资源存放目录名 (默认: _internal)",
|
||
"Hide Console (--noconsole)": "隐藏控制台 (--noconsole)",
|
||
"Use Virtual Environment": "使用虚拟环境",
|
||
"Keep Local Venv": "保留当前脚本虚拟环境",
|
||
"Install requirements.txt": "安装 requirements.txt",
|
||
"Analyze Dependencies (AST)": "分析依赖 (原生 AST)",
|
||
"Scan Entire Folder": "扫描整个文件夹",
|
||
"Enable UPX Compression": "启用 UPX 压缩",
|
||
"Enable Smart Code Signing": "开启数字签名",
|
||
"Auto-applies digital signature to built app, eliminating 'Unknown Publisher' warning": "自动为生成的应用程序添加数字签名,提升系统信任度",
|
||
"Use Custom Commercial Cert (Optional) ▸": "配置商业数字证书 (可选) ▸",
|
||
"Hide Custom Cert Settings ▾": "折叠证书高级设置 ▾",
|
||
"Create Self-Signed Cert": "创建自签证书",
|
||
"Self-Signed Certificate (*.pfx)": "自签名证书 (*.pfx)",
|
||
"Concise Log Output": "精简日志",
|
||
"Auto-save Build Log": "自动保存日志",
|
||
"Auto Extract Icon": "自动提取图标",
|
||
"Clean Temporary Cache After Build": "构建后清理缓存",
|
||
"Sound Notification": "完成提示音",
|
||
|
||
"Leave blank to auto-detect system default Python": "留空则自动检测系统 Python",
|
||
"Leave blank to auto-match script name": "留空则自动匹配脚本名",
|
||
"Leave blank to auto-search requirements.txt in current directory": "留空则自动检索当前目录 requirements.txt",
|
||
"Comma separated (e.g. pandas, PyQt5)": "逗号分隔 (如 pandas, PyQt5)",
|
||
"Comma separated (e.g. tkinter, matplotlib)": "逗号分隔 (如 tkinter, matplotlib)",
|
||
"Leave blank to auto-detect from environment variables": "留空则从环境变量自动检测",
|
||
"Select .pfx / .p12 certificate file": "请选择 .pfx / .p12 格式证书",
|
||
"Certificate password": "证书密钥密码",
|
||
"Double-click to edit target path; Drag & drop supported": "双击编辑目标路径;支持拖拽",
|
||
"Double-click to edit target path; Drag & drop supported. Use 'Export Preset' to save for reuse.": "双击编辑目标路径;支持拖拽。建议使用「导出预设」进行保存。",
|
||
"Project-specific. Not saved to global preferences. Use 'Export Preset' to save config.": "提示:此处资源配置仅对当前项目生效。如需长期复用,请使用「导出预设」。",
|
||
"Recommendation: 6+ chars, alphanumeric, '-' and '_' only. Avoid spaces or Chinese to prevent signing errors.": "建议:6位以上,仅使用英文字母、数字、减号及下划线。避免使用中文或空格以防签名失败。",
|
||
|
||
"PyInstaller — Bundles Python interpreter and bytecode. Fast build speed, zero configuration (no C compiler needed), and excellent compatibility.":
|
||
"PyInstaller — 打包解释器与字节码。构建迅速,零配置 (无需 C 编译器),兼容性优异。",
|
||
"Nuitka — Compiles source code into native C/C++ binary. Produces smaller package size, faster execution, and deep anti-decompilation protection (requires C compiler).":
|
||
"Nuitka — 编译为原生 C/C++ 二进制。体积更小,执行更快,具备强抗反编译能力 (需 C 编译器)。",
|
||
"OS Support Matrix:": "支持操作系统情况:",
|
||
"Platform Support Matrix:": "平台支持:",
|
||
"No Windows 7 Support": "不支持 Windows 7",
|
||
"Full backward compatibility": "兼容旧版操作系统",
|
||
"Legacy macOS Compatible": "兼容旧版 macOS",
|
||
"Apple Silicon (M1/M2) & Intel": "支持 Apple Silicon (M1/M2) 与 Intel",
|
||
"Apple Silicon (M-Series) & Intel": "支持 Apple Silicon (M系列) 与 Intel",
|
||
"Compatibility depends on build environment glibc version.": "兼容性取决于当前系统的 glibc 版本",
|
||
"* Nuitka Auto-manages C Compiler": "* Nuitka 自动接管 C 编译器",
|
||
'<div style="margin-bottom: 5px;"><b>Interpreter</b></div><span style="color:#6b7280;">Auto-detecting system environment and OS support matrix...</span>':
|
||
'<div style="margin-bottom: 5px;"><b>解释器</b></div><span style="color:#6b7280;">正在自动检测系统环境与支持操作系统情况...</span>',
|
||
|
||
"[INFO] Build Cancelled.": "[INFO] 构建已取消。",
|
||
"[INFO] Performing pre-flight environment checks...": "[INFO] 执行预构建环境检查...",
|
||
"[INFO] Analyzing source code and project dependencies...": "[INFO] 分析源码与依赖...",
|
||
"[INFO] Initializing isolated build environment...": "[INFO] 初始化构建环境...",
|
||
"[INFO] Playwright detected. Installing built-in browsers (PLAYWRIGHT_BROWSERS_PATH=0)...": "[INFO] 检测到 Playwright。正在安装内置浏览器驱动...",
|
||
"[INFO] Python interpreter path: {path}": "[INFO] Python 路径: {path}",
|
||
"[INFO] Creating virtual environment...": "[INFO] 创建虚拟环境...",
|
||
"[INFO] Reusing existing virtual environment...": "[INFO] 复用本地已存在的虚拟环境...",
|
||
"[INFO] Synchronizing and upgrading pip package manager...": "[INFO] 更新 pip...",
|
||
"[INFO] Scanning project source code via AST engine...": "[INFO] 使用原生 AST 引擎扫描依赖...",
|
||
"[INFO] Nuitka Tip: If prompted to download GCC/MinGW compiler on first build, please ensure stable network connection.": "[INFO] Nuitka 提示: 首次构建如需下载 GCC/MinGW,请确保网络畅通。",
|
||
"[INFO] Successfully installed compatible versions.": "[INFO] 已成功安装兼容版本。",
|
||
"[INFO] Resolving project dependencies...": "[INFO] 解析项目依赖...",
|
||
"Declared in requirements.txt ({count}): {pkgs}": "依赖声明 ({count}): {pkgs}",
|
||
"Declared in requirements.txt: None": "依赖声明: 无",
|
||
"Discovered via scanner ({count}): {pkgs}": "扫描发现 ({count}): {pkgs}",
|
||
"Auto-patched missing ({count}): {pkgs}": "自动补齐 ({count}): {pkgs}",
|
||
"Manifest complete (No missing packages)": "清单完整 (无缺失包)",
|
||
"Build engine packages ({count}): {pkgs}": "引擎依赖 ({count}): {pkgs}",
|
||
"[INFO] Installing build environment and project dependencies ({count} packages): {pkgs}": "[INFO] 安装依赖 ({count}): {pkgs}",
|
||
"[INFO] Switching to backup PyPI source for retrieval: {url}": "[INFO] 切换备用 PyPI 源: {url}",
|
||
"[INFO] Starting {engine} engine to compile binary files...": "[INFO] 启动 {engine} 引擎编译...",
|
||
"[INFO] Found local MSVC environment, prioritizing native C++ compiler.": "[INFO] 检测到本地 MSVC 环境,优先使用原生 C++ 编译器。",
|
||
"[INFO] Found local Clang environment, prioritizing LLVM Clang compiler.": "[INFO] 检测到本地 Clang 环境,优先使用 LLVM Clang 编译器。",
|
||
"[INFO] Python 3.13+ detected: Using Zig compiler (--zig) as C backend.": "[INFO] Python 3.13+: 使用 Zig 编译器。",
|
||
"[INFO] Using MinGW64 compiler (--mingw64) for C backend.": "[INFO] 使用 MinGW64 编译器。",
|
||
"[INFO] Found local GCC/MinGW-w64 environment, using MinGW64 compiler.": "[INFO] 检测到本地 GCC/MinGW-w64 环境,优先使用 MinGW64 编译器。",
|
||
"[INFO] Found local Zig environment, letting Nuitka manage C backend compilation.": "[INFO] 检测到本地 Zig 环境,由 Nuitka 接管 C 后端编译。",
|
||
"[INFO] No local C compiler detected. Nuitka will automatically download and manage a compatible MinGW-w64 toolchain.": "[INFO] 未检测到本地 C 编译器,Nuitka 将自动下载并管理兼容的 MinGW-w64 工具链。",
|
||
"[INFO] Evaluating system physical memory (Available: {ram:.1f} GB). Adaptive concurrency adjusted: {cores} -> {safe_jobs} ...": "[INFO] 评估系统可用物理内存 ({ram:.1f} GB),动态调整并发数: {cores} -> {safe_jobs}",
|
||
"[INFO] Stripping icon parameters and automatically rebuilding...": "[INFO] 移除图标参数并重新构建...",
|
||
"[INFO] Lite mode enabled, applying bytecode optimization (-OO) and stripping dev modules...": "[INFO] 启用精简模式,应用字节码优化 (-OO) 并剔除开发环境模块...",
|
||
"[INFO] Compilation completed, archiving built files...": "[INFO] 编译完成,归档构建产物...",
|
||
"[INFO] Validating output files and generating final product...": "[INFO] 校验并生成最终产物...",
|
||
"[INFO] Build log exported to: {path}": "[INFO] 日志已导出: {path}",
|
||
"[INFO] Freeing up space, cleaning temporary build environment...": "[INFO] 清理临时构建环境...",
|
||
"[INFO] Downloading Playwright browsers via mirror: {url}": "[INFO] 通过镜像下载 Playwright 浏览器: {url}",
|
||
"[INFO] Retrying Playwright browser download via official CDN...": "[INFO] 通过官方 CDN 重试下载 Playwright 浏览器...",
|
||
"[INFO] Applying smart digital signature...": "[INFO] 正在为应用程序添加数字签名...",
|
||
"[SUCCESS] Digital signature applied successfully!": "[SUCCESS] 数字签名添加成功!",
|
||
"[WARN] Smart signing failed, but build output is retained.": "[WARN] 数字签名未成功,但应用产物已正常保留。",
|
||
"[SUCCESS] Compilation completed, output path: {path}": "[SUCCESS] 构建成功,输出路径: {path}",
|
||
"[WARN] Target project is in a Cloud Sync directory (e.g. OneDrive/Dropbox). Cloud sync may temporarily lock build files.": "[WARN] 项目位于云同步目录,可能锁定构建文件。",
|
||
"[WARN] Specified versions failed to install. Stripping version constraints for automatic compatibility match...": "[WARN] 指定版本安装失败,尝试自动匹配兼容版本...",
|
||
"[WARN] Some dependencies failed to install, build will proceed with risk...": "[WARN] 部分依赖安装失败,继续构建 (有风险)。",
|
||
"[WARN] Low disk space detected on build target drive (Available: {free:.1f} GB, >= 5.0 GB recommended). Build process may interrupt due to disk exhaustion.": "[WARN] 目标磁盘空间不足 (可用: {free:.1f} GB,推荐 >= 5.0 GB)。",
|
||
"[WARN] Memory allocation exception caught (ZstdError / OOM). Triggering memory protection fallback: Retrying in single-thread mode...": "[WARN] 捕获内存分配异常 (ZstdError/OOM),降级为单线程模式重试...",
|
||
"[WARN] System low memory detected (zstd memory allocation failed). Automatically retrying in single-thread low-memory mode...": "[WARN] 内存不足 (zstd 分配失败),降级为单线程模式重试...",
|
||
"[WARN] Icon resource writing blocked (possibly locked by system/antivirus), triggering fallback protection...": "[WARN] 图标资源写入被拦截 (可能受安全软件限制),触发降级机制...",
|
||
"[WARN] Strongly recommend checking [Virtual Environment] to maximize lite mode effect.": "[WARN] 强烈建议启用「虚拟环境」以最大化精简效果。",
|
||
"[WARN] Pause code injection exception: {error}": "[WARN] 注入暂停代码异常: {error}",
|
||
"[WARN] AST Analysis Exception: {error}": "[WARN] AST 分析异常: {error}",
|
||
"[WARN] Read requirements.txt warning: {error}": "[WARN] 读取 requirements.txt 警告: {error}",
|
||
"[WARN] Command timeout (>{timeout}s)": "[WARN] 命令超时 (>{timeout}s)",
|
||
"[WARN] Detected 'requests' or 'httpx'. Auto-bundling 'certifi' certificates to prevent SSL errors.": "[WARN] 检测到网络请求库,自动捆绑 'certifi' 根证书以防止 SSL 异常。",
|
||
"[WARN] Usage of '__file__' detected. In PyInstaller One-File mode, use 'sys._MEIPASS' to reliably locate bundled resource files!": "[WARN] 侦测到 '__file__' 的使用。在 PyInstaller 单文件模式下,请使用 'sys._MEIPASS' 定位资源释放路径,以免引发文件丢失错误。",
|
||
"[WARN] 'multiprocessing' module detected. Ensure 'multiprocessing.freeze_support()' is called under 'if __name__ == \"__main__\":' to prevent infinite process loops (fork bombs).": "[WARN] 检测到 multiprocessing 模块。请确保在 'if __name__ == \"__main__\":' 块内调用 'multiprocessing.freeze_support()',以防引发无限进程死机。",
|
||
"[WARN] Package '{pkg}' failed to install, skipping...": "[WARN] 包 '{pkg}' 安装失败,已跳过...",
|
||
"[WARN] Playwright browser installation failed across all sources, build will proceed with risk...": "[WARN] 所有 Playwright 浏览器下载源均失败,继续构建 (有风险)...",
|
||
"[WARN] Icon conversion to .icns failed, building without icon.": "[WARN] 图标转换为 .icns 失败,将不使用图标构建。",
|
||
"[INFO] Tip: Executables without commercial signatures might be falsely flagged by Windows Defender/Antivirus.": "[INFO] 提示: 未进行商业签名的可执行程序可能被安全软件误报。",
|
||
"[ERROR] Build aborted: Insufficient disk space (NoSpaceLeft / Errno 28). Please clean up drive space (at least 5 GB free space recommended) and try again.": "[ERROR] 构建中止: 磁盘空间不足 (NoSpaceLeft / Errno 28)。建议预留至少 5GB 可用空间。",
|
||
"[ERROR] Output directory is missing write permissions: {error}": "[ERROR] 目标输出目录无写入权限: {error}",
|
||
"[ERROR] Requirements file not found: {path}": "[ERROR] 指定的依赖清单文件不存在: {path}",
|
||
"[ERROR] Insufficient disk space (Available: {free:.1f} GB). At least 0.5 GB is required to safely initialize the build environment.": "[ERROR] 磁盘空间不足 (可用 {free:.1f} GB)。至少需 0.5 GB 才能安全初始化环境。",
|
||
"[ERROR] Python interpreter is invalid or not found: {path}": "[ERROR] Python 解释器无效或未找到: {path}",
|
||
"[ERROR] The specified icon file does not exist: {path}": "[ERROR] 指定的应用图标文件不存在: {path}",
|
||
"[ERROR] Additional resource file/directory not found: {path}": "[ERROR] 附加数据中的文件/目录不存在: {path}",
|
||
"[ERROR] Failed to create virtual environment. Current Python environment might be missing necessary modules or have restricted permissions.": "[ERROR] 虚拟环境创建失败。环境可能缺失模块或权限受限。",
|
||
"[ERROR] Product transfer failed, file might be occupied by system process or lack permission: {error}": "[ERROR] 构建产物转移失败,文件可能被占用或无写入权限: {error}",
|
||
"[ERROR] Could not locate valid executable product in temporary build directory: {path}": "[ERROR] 临时构建目录未找到可执行产物: {path}",
|
||
"[ERROR] Target file is locked or encrypted by cloud drive. Please decrypt and try again.": "[ERROR] 目标文件被云盘锁定或加密,请解密后重试。",
|
||
"[ERROR] Target file is running or occupied. Please close the existing application and try again.": "[ERROR] 目标程序正在运行或被占用。请先关闭程序后再重新构建。",
|
||
"[ERROR] Please load a valid Python source file first!": "[ERROR] 请加载有效的 Python 源码!",
|
||
"[ERROR] Exception occurred during AST parsing: {error}": "[ERROR] AST 解析异常: {error}",
|
||
"[ERROR] Failed to export preset file: {error}": "[ERROR] 导出预设失败: {error}",
|
||
"[ERROR] Preset file format error or corrupted: {error}": "[ERROR] 预设文件格式错误或已损坏: {error}",
|
||
"[ERROR] Failed to export log file: {error}": "[ERROR] 导出日志失败: {error}",
|
||
"[ERROR] Process error: command or binary missing ({error})": "[ERROR] 进程错误: 命令或二进制文件缺失 ({error})",
|
||
"[ERROR] System execution exception: {error}": "[ERROR] 系统执行异常: {error}",
|
||
"[ERROR] I/O Exception: {err_msg}": "[ERROR] I/O 异常: {err_msg}",
|
||
"[ERROR] Python installation failed or was cancelled.": "[ERROR] Python 安装失败或已被主动取消。",
|
||
"Passwords do not match!": "两次输入的密码不一致!",
|
||
"Failed to generate certificate.": "生成证书失败。",
|
||
"Certificate generated successfully!": "证书生成成功!",
|
||
"Only supported on Windows.": "仅支持在 Windows 系统下生成 .pfx 证书。",
|
||
"Password cannot be empty!": "密码不能为空!",
|
||
|
||
"[ERROR] Build completed, but the following dependencies failed to install:\n\n - {pkgs}\n\nNote: The application might raise ModuleNotFoundError at runtime.":
|
||
"[ERROR] 构建完成,但以下依赖安装失败:\n\n - {pkgs}\n\n注意: 运行时程序可能会抛出 ModuleNotFoundError。",
|
||
|
||
"[Syntax Error] Source code syntax parsing failed, build aborted:\n - File: {file}\n - Line: Line {line}\n - Detail: {desc}\n\nTip: This is usually NOT a fault of the packaging tool.\nPlease ensure the [Build Python Version] you selected matches the version you used to [Write/Test the Code].\nUsing newer syntax (e.g., walrus operator :=, type unions |, match-case) in an older Python environment will trigger this error.\nWe recommend going to [Build Settings] -> [Engine] to switch to the correct Python version.":
|
||
"[Syntax Error] 源码语法解析失败,构建中止:\n - 文件: {file}\n - 行号: {line}\n - 详情: {desc}\n\n提示:这通常不是打包工具的故障。\n请确认您当前选择的【打包 Python 版本】与您【编写代码时的版本】是否一致。\n高版本语法(如海象运算符 :=、类型联合 |、match-case 等)在低版本 Python 中会导致此错误。\n建议前往【构建设置】->【构建引擎】切换至对应版本的 Python。",
|
||
|
||
"[Syntax Error] Source code contains syntax errors, compilation aborted:\n - File: {file}\n - Type: {type}\n - Line: Line {line}\n - Detail: {desc}\n\nTip: Please ensure the source code runs locally before packaging.":
|
||
"[ERROR] 源码语法错误,构建中止:\n - 文件: {file}\n - 类型: {type}\n - 行号: {line}\n - 详情: {desc}\n\n提示: 请确保源码可在本地运行。",
|
||
|
||
"[FAILED] Build interrupted exceptionally!\n\nCommon Troubleshooting:\n1. Environment Mismatch (Most Common): The selected Python version is incompatible with your source code. Please go to [Build Settings] to switch to the Python version you normally use for this code.\n2. Missing Dependencies: Click 'Detailed Mode' above to check for ModuleNotFoundError.\n3. Antivirus Block: Ensure your security software is not blocking the build process.\n\n(Note: This is usually caused by environment/code discrepancies rather than the packaging engine itself. Please check the detailed log for exact reasons.)":
|
||
"[FAILED] 构建异常中断!\n\n常见原因排查:\n1. 环境不匹配 (最常见):您选择的打包 Python 版本与您的源码不兼容。请前往【构建设置】切换为您平时开发运行该代码的 Python 版本。\n2. 依赖缺失:请点击上方切换至「详细日志」,查看是否有模块未找到 (ModuleNotFoundError)。\n3. 杀毒软件拦截:请检查是否有安全软件阻止了打包过程。\n\n(注意:这通常是环境配置与代码差异导致,并非打包引擎本身故障,请查阅详细日志获取确切原因。)",
|
||
|
||
"[FAILED] Compilation interrupted with exceptions, please click 'Detailed Mode' above the log window for troubleshooting.":
|
||
"[FAILED] 构建异常中断,请切换至「详细日志」排查原因。",
|
||
|
||
"GitHub Repository": "GitHub 仓库",
|
||
"Issues & Feedback": "问题与反馈",
|
||
"PyPI Home": "PyPI 主页",
|
||
"Sponsor": "赞助支持",
|
||
"QPyPack is a free and open-source tool. If it has improved your efficiency or solved packaging problems, consider buying the author a coffee!": "QPyPack 是一款免费开源工具。如果它提升了您的效率或解决了打包难题,欢迎赞助支持本开源项目。",
|
||
"* Sponsorship is completely voluntary, serves as an unconditional encouragement to the open-source community, and involves no commercial commitments. Thank you for your support!": "* 赞助完全出于自愿,属于对开源社区的无偿鼓励,不涉及任何商业承诺。感谢您的支持!",
|
||
"Python Environment Required": "需要 Python 环境",
|
||
"<b>Python is not detected on your system!</b><br><br>QPyPack requires a Python environment to compile your code.<br>If you haven't installed Python, please download and install it (remember to check <b>'Add Python.exe to PATH'</b> during installation).": "<b>未在系统中检测到有效的 Python 环境</b><br><br>QPyPack 需要依赖 Python 才能编译代码。<br>如果您尚未安装,请前往官网下载安装 (提示:安装界面底部请务必勾选 <b>'Add Python.exe to PATH'</b>)。",
|
||
"Download Python": "前往下载 Python",
|
||
"Python Environment Management": "Python 环境管理",
|
||
"<b>Python Environment Required</b><br><span style='color:#475569; font-size:12px; line-height:1.6;'>Application build depends on a Python interpreter.<br>Please select a version to automatically download, install, and configure environment variables.</span>":
|
||
"<b>需要 Python 编译环境</b><br><span style='color:#475569; font-size:12px; line-height:1.6;'>应用构建需要依赖 Python 解释器。<br>请选择版本以自动下载安装与配置环境变量。</span>",
|
||
"<b>Python Environment Management</b><br><span style='color:#475569; font-size:12px; line-height:1.6;'>Supports switching locally detected Python environments or downloading new versions.</span>":
|
||
"<b>Python 环境管理</b><br><span style='color:#475569; font-size:12px; line-height:1.6;'>支持切换本地已检测到的 Python 环境或下载新版本。</span>",
|
||
"<b>Recommendation:</b> Python <b>3.11.9</b> is recommended for optimal build compatibility and engine support.":
|
||
"<b>建议:</b> 推荐选择 <b>Python 3.11.9</b>,该版本具备最佳的编译兼容性与引擎支持。",
|
||
"View Python": "查看 Python",
|
||
"Locally Detected Pythons": "已检测环境",
|
||
"Download New Version": "下载新版本",
|
||
"Use Selected Environment": "切换至选中环境",
|
||
"No local Python environments detected.": "未检测到本地 Python 环境",
|
||
"Select Python Version to Download:": "选择 Python 版本:",
|
||
"Click to automatically download, install Python, and configure system environment variables.":
|
||
"点击后将自动下载安装 Python 并配置系统环境变量。",
|
||
"One-Click Download & Install": "下载并自动安装",
|
||
"One-Click Install": "一键安装 Python",
|
||
"Use Installed Version Directly": "✔ 本地已存在,直接切换",
|
||
"<b><span style='color:#16a34a;'>✔ Detected locally:</span></b><br><span style='color:#475569; font-family:Consolas;'>{path}</span><br><span style='color:#16a34a; font-size:11px;'>Ready to switch directly without re-downloading.</span>":
|
||
"<b><span style='color:#16a34a;'>✔ 本地已检测到此版本:</span></b><br><span style='color:#475569; font-family:Consolas;'>{path}</span><br><span style='color:#16a34a; font-size:11px;'>可直接切换使用,无需重复下载。</span>",
|
||
"Python 3.14.6 (Experimental)": "Python 3.14.6 (实验性)",
|
||
"Python 3.13.0": "Python 3.13.0",
|
||
"Python 3.12.4": "Python 3.12.4",
|
||
"Python 3.11.9 (Recommended)": "Python 3.11.9 (推荐)",
|
||
"Python 3.10.11": "Python 3.10.11",
|
||
"Python 3.9.13": "Python 3.9.13",
|
||
"Python 3.8.10 (Win7 Support)": "Python 3.8.10 (Win7 兼容)",
|
||
"Downloading Python {ver}... Please wait.": "正在下载 Python {ver},请稍候...",
|
||
"Download complete. Starting Python installation...": "下载完成,正在后台安装 Python...",
|
||
"Install Complete": "安装完成",
|
||
"Exit Now": "退出软件",
|
||
"Later": "稍后",
|
||
"<b>Python Environment Installed Successfully!</b><br><br>System environment variables updated. Recommended to exit and restart the software to apply changes.":
|
||
"<b>Python 环境安装成功!</b><br><br>建议退出软件后重新打开以生效环境变量。",
|
||
"Python installed. Please restart QPyPack manually later.": "Python 安装完毕。请稍后手动重启 QPyPack 以生效环境变量。",
|
||
"Configure Manually": "手动配置路径",
|
||
"Switched to [Build Settings] -> [Engine], please set the Python path.": "已自动切换至【构建设置】->【构建引擎】,请手动指定 Python 解释器路径。",
|
||
"Switched to local Python environment: {path}": "已成功切换至本地 Python 环境: {path}",
|
||
|
||
"Dependency Missing Warning: {pkgs} failed to install. Check log for details.": "依赖缺失警告: {pkgs} 安装失败。详情请查看日志。",
|
||
"Package mappings have been reset to defaults.": "包名映射已恢复为默认设置。",
|
||
"Global configuration has been reset.": "全局偏好配置已恢复默认。",
|
||
"Config preset exported to: {path}": "配置预设已成功导出至: {path}",
|
||
"Config preset imported successfully.": "配置预设已成功导入。",
|
||
"AST scan completed, found {count} dependencies.": "AST 扫描完成,发现 {count} 个隐式依赖。",
|
||
"No log content.": "无日志内容。",
|
||
"Log saved to: {path}": "日志已保存: {path}",
|
||
"Attention: Please check the log for details.": "提示: 请检查下方日志排查详细原因。",
|
||
"System Default ({sys_native})": "系统默认 ({sys_native})",
|
||
"Independent Developer": "独立开发者",
|
||
"Desktop Application": "桌面应用程序",
|
||
"Invalid syntax": "无效语法",
|
||
"OK": "确定",
|
||
"QPyPack Developer": "QPyPack 开发者",
|
||
"\\nProgram execution completed, press Enter to exit...": "\\n程序执行完成,按回车键退出...",
|
||
"Script and settings retained. Ready to rebuild.": "已保留当前配置,就绪等待重新构建。",
|
||
"[WARN] Timestamp server connection timed out, performing local fast signing...": "[WARN] 时间戳服务器连通超时,进行本地快速签名...",
|
||
"[INFO] Applied digital signature with timestamp ({tsa})...": "[INFO] 已通过时间戳服务器 ({tsa}) 完成数字签名与盖章...",
|
||
"[INFO] Reusing existing virtual environment: {path}": "[INFO] 复用本地隔离虚拟环境: {path}",
|
||
"[INFO] Creating virtual environment ({name})...": "[INFO] 创建隔离虚拟环境 ({name})...",
|
||
"[WARN] Python environment info detection fallback: {error}": "[WARN] Python 环境信息检测降级: {error}",
|
||
"[WARN] Failed to purge invalid environment, falling back to temp sandbox: {error}": "[WARN] 清理失效环境失败,降级为临时沙箱: {error}",
|
||
"Clear Local Venvs": "清理虚拟环境",
|
||
"Clear Virtual Environments": "清理项目虚拟环境",
|
||
"No local virtual environments found to clean.": "当前项目目录下未发现可清理的虚拟环境。",
|
||
"<b>Found {count} virtual environment(s) for the current project:</b><br><span style='color:#64748b; font-size:11px;'>{paths}</span><br><br>Are you sure you want to delete them to free up disk space?":
|
||
"<b>在当前项目下检测到 {count} 个隔离虚拟环境:</b><br><span style='color:#64748b; font-size:11px;'>{paths}</span><br><br>确定要全部清理以释放磁盘空间吗?",
|
||
"Delete All": "一键清空",
|
||
"Successfully cleaned {count} virtual environment(s), freed {size:.1f} MB disk space.": "已成功清理 {count} 个虚拟环境,共释放 {size:.1f} MB 磁盘空间。",
|
||
"[WARN] System Python write permission restricted. Retrying with '--user' mode...": "[WARN] 系统 Python 缺少写入权限,正在重试以 '--user' 用户模式安装...",
|
||
"<b>Python is not detected on your system!</b><br><br>QPyPack requires a Python environment to compile your code.<br>If you haven't installed Python, please download and install it.": "<b>您的系统未检测到Python!</b><br><br>QPyPack需要Python环境来编译您的代码。<br>如果您尚未安装Python,请下载并安装。",
|
||
"Deleting...": "正在删除...",
|
||
"Password:": "密码:",
|
||
"Scanning...": "正在扫描...",
|
||
"[INFO] Auto-detected existing virtual environment '{venv}'. 'Keep Local Venv' is checked.": "[INFO] 自动检测到现有虚拟环境'{venv}'。已勾选'保留本地虚拟环境'。",
|
||
"[INFO] Using MinGW64 compiler.": "[INFO] 使用MinGW64编译器。",
|
||
"Package Map": "包名映射与规则",
|
||
"Package Mapping & Remediation": "包名与规则",
|
||
"Obsolete Backport Exclusion Rules": "废弃兼容包拦截规则",
|
||
"Min Python Ver": "最低内置版本",
|
||
"Add Backport Rule": "添加拦截规则",
|
||
"Package Map": "包名映射与规则",
|
||
"Package Mappings & Rules": "包名映射与规则",
|
||
"Obsolete Backport Exclusion Rules": "废弃兼容包拦截规则",
|
||
"Min Python Ver": "最低内置版本",
|
||
"Add Backport Rule": "添加拦截规则",
|
||
"Environment Analysis": "运行环境分析",
|
||
"Environment Analysis & Remediation": "运行环境分析",
|
||
"Enable Automatic Backport Isolation": "开启废弃兼容包动态隔离",
|
||
"Inspect Interpreter Environment": "校验解释器环境",
|
||
"Remediate Detected Conflicts": "清理环境冲突依赖",
|
||
"Package Name (e.g. pathlib):": "包名 (如 pathlib):",
|
||
"Minimum Built-in Python Version (e.g. 3.4):": "最低内置 Python 版本 (如 3.4):",
|
||
"Status: Environment inspection ready.": "状态: 等待环境校验。",
|
||
"Interpreter inspection passed: No active backport conflicts detected.": "[OK] 解释器环境校验通过: 未检测到冲突模块。",
|
||
"Active backport conflicts detected ({count}): {pkgs}": "[WARN] 检测到与当前解释器冲突的模块 ({count}): {pkgs}",
|
||
"Remediation completed for packages: {pkgs}": "[OK] 已成功清理以下冲突模块: {pkgs}",
|
||
"No environment conflicts detected.": "未检测到环境冲突模块。",
|
||
"Backport exclusion rules have been reset to defaults.": "废弃兼容包拦截规则已恢复默认设置。",
|
||
"[INFO] Added '{name}' to Additional Resources.": "[INFO] 已将资源 '{name}' 自动添加至附加资源列表",
|
||
"[INFO] Added {count} resource item(s) to Additional Resources.": "[INFO] 已将 {count} 项附加资源自动导入",
|
||
"Script loaded. You can drag asset folders or data files directly here to bundle them.": "脚本已载入。您可将资源文件夹或文件直接拖入主区域进行捆绑打包。",
|
||
"Package Name": "包名称",
|
||
"[ERROR] Environment remediation failed: {error}": "[ERROR] 环境修复失败:{error}",
|
||
"[INFO] Auto-shielded obsolete backport module: '{pkg}' (Built-in in Python {ver})": "[INFO] 已自动屏蔽过时的反向移植模块:'{pkg}'(Python {ver} 内置)",
|
||
"[WARN] Failed to delete invalid venv, attempting to rename: {error}": "[WARN] 无法删除失效虚拟环境,尝试重命名: {error}",
|
||
"[INFO] Old venv renamed to: {name}": "[INFO] 旧虚拟环境已重命名为: {name}",
|
||
"[ERROR] Unable to clean up invalid virtual environment:\n Path: {path}\n Error: {error}\n\nPlease manually delete the directory or uncheck 'Keep Local Venv' and retry.": "[ERROR] 无法清理失效的虚拟环境:\n 路径: {path}\n 错误: {error}\n\n请手动删除该目录,或取消勾选'保留本地虚拟环境'后重试。",
|
||
"Shared environment name (optional, leave empty for isolated)": "共享环境名 (可选, 留空则独立)",
|
||
"Isolated Environment": "独立环境",
|
||
"Shared Environment": "共享环境",
|
||
"Select shared directory...": "请选择共享目录...",
|
||
"[WARN] Shared directory invalid, falling back to isolated environment mode.": "[WARN] 共享目录无效,降级为独立环境模式。",
|
||
"[WARN] Shared directory lacks write permissions, falling back to isolated mode.": "[WARN] 共享目录缺少写入权限,降级为独立环境模式。",
|
||
"[WARN] Low disk space detected: {free:.1f} GB available": "[WARN] 磁盘空间不足,当前可用: {free:.1f} GB。",
|
||
"Disk space critically low: {free:.1f}GB < {min:.1f}GB": "磁盘空间严重不足: {free:.1f}GB < {min:.1f}GB",
|
||
"RAM critically low: {free:.1f}GB < 1GB": "内存严重不足: {free:.1f}GB < 1GB",
|
||
"Python interpreter not found: {path}": "Python 解释器未找到: {path}",
|
||
"[ERROR] Pre-flight check failed:": "[ERROR] 预检查失败:",
|
||
"[INFO] Found compatible MSVC + Windows SDK environment, using native compiler.": "[INFO] 检测到兼容的 MSVC 及 Windows SDK 环境,使用原生编译器。",
|
||
"[INFO] Python 3.13+ detected: Using Zig compiler (--zig) as native backend.": "[INFO] 检测到 Python 3.13+:使用 Zig 编译器 (--zig) 作为编译后端。",
|
||
"[INFO] MSVC 14.3+ not detected. Nuitka will use/download MinGW64 toolchain (Python {v}).": "[INFO] 未检测到 MSVC 14.3+,Nuitka 将自动下载/使用 MinGW64 工具链 (Python {v})。",
|
||
"[WARN] Python 3.13+ (32-bit) requires Visual Studio 2022 with Windows SDK. Build will attempt with current environment.": "[WARN] Python 3.13+ (32位) 强制需要包含 Windows SDK 的 VS2022,正在尝试当前环境...",
|
||
"[Environment Error] Build failed: C++ compiler version is incompatible.": "[环境错误] 编译失败:C++ 编译器或 Windows SDK 版本不兼容。",
|
||
"[Environment Error] Python 3.13+ Compiler Requirement.": "[环境错误] Python 3.13+ 编译器兼容性限制。",
|
||
"Reason: Python 3.11+ requires Visual Studio 2022 (MSVC 14.3) with Windows SDK.": "原因:Python 3.11+ 使用 MSVC 时必须安装 Visual Studio 2022 (MSVC 14.3) 且勾选 Windows SDK。",
|
||
"Reason: MinGW64 is no longer supported on Python 3.13+. Please ensure Zig or VS 2022 is installed.": "原因:Python 3.13+ 已不再支持 MinGW64 编译器,需要 Zig (x64) 或 Visual Studio 2022。",
|
||
"Solution:\n1. Install Visual Studio 2022 (Check 'Desktop development with C++' and 'Windows SDK').\n2. Or switch the engine to [PyInstaller] in Build Settings.": "解决方法:\n1. 安装/更新 Visual Studio 2022(勾选“使用 C++ 的桌面开发”及 Windows SDK)。\n2. 或前往【构建设置】切换为 [PyInstaller] 引擎打包。",
|
||
"Solution:\n1. In Build Settings, switch to [PyInstaller] engine (Recommended).\n2. Or install Visual Studio 2022 with C++ support.": "解决方法:\n1. 前往【构建设置】切换为 [PyInstaller] 引擎(推荐)。\n2. 或安装 Visual Studio 2022 C++ 编译套件。",
|
||
"Icon file not found: {path}": "图标文件未找到:{path}",
|
||
"Output directory not writable: {error}": "输出目录不可写:{error}",
|
||
"Resource not found: {path}": "资源未找到:{path}",
|
||
"Script file is locked by cloud sync": "脚本文件被云同步锁定",
|
||
"[Syntax Error] Source code syntax parsing failed, build aborted:\n - File: {file}\n - Detail: {desc}\n\nTip: This is usually NOT a fault of the packaging tool.\nPlease ensure the [Build Python Version] you selected matches the version you used to [Write/Test the Code].\nUsing newer syntax (e.g., walrus operator :=, type unions |, match-case) in an older Python environment will trigger this error.\nWe recommend going to [Build Settings] -> [Engine] to switch to the correct Python version.": "[Syntax Error] 源代码语法解析失败,构建中止:\n - 文件:{file}\n - 详情:{desc}\n\n提示:这通常不是打包工具的故障。\n请确保您选择的 [构建 Python 版本] 与您用于 [编写/测试代码] 的版本匹配。\n在较旧的 Python 环境中使用较新的语法(例如海象运算符 :=、类型联合 |、match-case)将触发此错误。\n我们建议您前往 [构建设置] -> [引擎] 切换到正确的 Python 版本。",
|
||
"Unknown": "未知"
|
||
}
|
||
|
||
class TranslationEngine(QObject):
|
||
language_changed = Signal(str)
|
||
DEFAULT_LOCALE = "en_US"
|
||
|
||
LANG_META = {
|
||
"en_US": {"native": "English"},
|
||
"zh_CN": {"native": "简体中文"},
|
||
"zh_TW": {"native": "繁體中文"},
|
||
"ja_JP": {"native": "日本語"},
|
||
"ko_KR": {"native": "한국어"},
|
||
"de_DE": {"native": "Deutsch"},
|
||
"fr_FR": {"native": "Français"},
|
||
"es_ES": {"native": "Español"},
|
||
"ru_RU": {"native": "Русский"},
|
||
"pt_BR": {"native": "Português (Brasil)"},
|
||
"it_IT": {"native": "Italiano"},
|
||
"nl_NL": {"native": "Nederlands"},
|
||
"pl_PL": {"native": "Polski"},
|
||
"tr_TR": {"native": "Türkçe"},
|
||
"vi_VN": {"native": "Tiếng Việt"},
|
||
"th_TH": {"native": "ไทย"},
|
||
"ar_SA": {"native": "العربية"},
|
||
}
|
||
|
||
def __init__(self, locales_dir: Path):
|
||
super().__init__()
|
||
self.locales_dir = locales_dir
|
||
self.current_lang = self.DEFAULT_LOCALE
|
||
self.translations = {}
|
||
self.fallback_zh_cn = ZH_CN_DICT
|
||
self.qt_translator = QTranslator()
|
||
|
||
def init_locale(self):
|
||
self.load_all_locales()
|
||
self.current_lang = self.detect_system_language()
|
||
|
||
def normalize_locale(self, locale_str: str) -> str:
|
||
if not locale_str or locale_str == "auto":
|
||
return self.detect_system_language()
|
||
clean = locale_str.replace("-", "_")
|
||
parts = clean.split("_")
|
||
if len(parts) >= 2:
|
||
return f"{parts[0].lower()}_{parts[1].upper()}"
|
||
elif len(parts) == 1:
|
||
lang = parts[0].lower()
|
||
mapping = {"zh": "zh_CN", "en": "en_US", "ja": "ja_JP", "ko": "ko_KR", "de": "de_DE", "fr": "fr_FR", "es": "es_ES", "ru": "ru_RU", "pt": "pt_BR"}
|
||
return mapping.get(lang, self.DEFAULT_LOCALE)
|
||
return self.DEFAULT_LOCALE
|
||
|
||
def detect_system_language(self) -> str:
|
||
try:
|
||
sys_locale = QLocale.system().name()
|
||
except Exception:
|
||
sys_locale = ""
|
||
|
||
if sys_locale.startswith("zh"):
|
||
if any(k in sys_locale for k in ("TW", "HK", "MO", "Hant")): return "zh_TW"
|
||
return "zh_CN"
|
||
elif sys_locale.startswith("ja"): return "ja_JP"
|
||
elif sys_locale.startswith("ko"): return "ko_KR"
|
||
elif sys_locale.startswith("de"): return "de_DE"
|
||
elif sys_locale.startswith("fr"): return "fr_FR"
|
||
elif sys_locale.startswith("es"): return "es_ES"
|
||
elif sys_locale.startswith("ru"): return "ru_RU"
|
||
elif sys_locale.startswith("pt"): return "pt_BR"
|
||
return "en_US"
|
||
|
||
def load_all_locales(self):
|
||
self.translations.clear()
|
||
if self.locales_dir.exists():
|
||
for p in self.locales_dir.glob("*.json"):
|
||
try:
|
||
data = json.loads(p.read_text(encoding='utf-8'))
|
||
code = self.normalize_locale(p.stem)
|
||
self.translations[code] = data
|
||
except Exception as e:
|
||
logger.warning("[i18n] Failed to load %s: %s", p.name, e)
|
||
|
||
def get_available_languages(self) -> dict:
|
||
sys_code = self.detect_system_language()
|
||
if sys_code in self.LANG_META:
|
||
sys_native = self.LANG_META[sys_code]["native"].split(" (")[0]
|
||
else:
|
||
qloc = QLocale(sys_code)
|
||
sys_native = qloc.nativeLanguageName().capitalize() or sys_code
|
||
|
||
langs = {"auto": self.t("System Default ({sys_native})", sys_native=sys_native)}
|
||
all_codes = set(self.translations.keys()) | {self.DEFAULT_LOCALE, "zh_CN"}
|
||
|
||
for code in sorted(all_codes):
|
||
if code in self.LANG_META:
|
||
langs[code] = self.LANG_META[code]["native"]
|
||
else:
|
||
qloc = QLocale(code)
|
||
native_name = qloc.nativeLanguageName()
|
||
if not native_name or native_name == code:
|
||
native_name = qloc.languageToString(qloc.language())
|
||
langs[code] = native_name.capitalize() if native_name else code
|
||
|
||
return langs
|
||
|
||
def update_qt_translator(self, lang_code: str):
|
||
app = QApplication.instance()
|
||
if not app:
|
||
return
|
||
|
||
app.removeTranslator(self.qt_translator)
|
||
trans_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
|
||
|
||
target_locale = self.normalize_locale(lang_code)
|
||
short_lang = target_locale.split("_")[0] if "_" in target_locale else target_locale
|
||
|
||
candidates = [
|
||
f"qtbase_{target_locale}",
|
||
f"qtbase_{short_lang}",
|
||
f"qt_{target_locale}",
|
||
f"qt_{short_lang}"
|
||
]
|
||
|
||
for name in candidates:
|
||
if self.qt_translator.load(name, trans_path):
|
||
app.installTranslator(self.qt_translator)
|
||
break
|
||
|
||
app.setLayoutDirection(Qt.LayoutDirection.LeftToRight)
|
||
|
||
def set_language(self, lang_code: str):
|
||
target = self.normalize_locale(lang_code)
|
||
self.update_qt_translator(target)
|
||
if target != self.current_lang:
|
||
self.current_lang = target
|
||
self.language_changed.emit(self.current_lang)
|
||
|
||
def t(self, text: str, **kwargs) -> str:
|
||
val = text
|
||
if self.current_lang in self.translations and text in self.translations[self.current_lang]:
|
||
val = self.translations[self.current_lang][text]
|
||
elif self.current_lang == "zh_CN" and text in self.fallback_zh_cn:
|
||
val = self.fallback_zh_cn[text]
|
||
if kwargs:
|
||
try: return val.format(**kwargs)
|
||
except Exception: pass
|
||
return val
|
||
|
||
_LOCALES_DIR = Path(__file__).parent / "locales"
|
||
if not _LOCALES_DIR.exists():
|
||
_LOCALES_DIR = _CONFIG_DIR / "locales"
|
||
|
||
I18N = TranslationEngine(_LOCALES_DIR)
|
||
I18N.init_locale()
|
||
|
||
def _(text: str, **kwargs) -> str:
|
||
return I18N.t(text, **kwargs)
|
||
|
||
PYPI_MIRRORS_GLOBAL = [
|
||
("PyPI Official (Global Default)", "https://pypi.org/simple"),
|
||
("Python TestPyPI", "https://test.pypi.org/simple"),
|
||
("AWS PyPI Mirror (US/EU)", "https://pypi.org/simple"),
|
||
("Tsinghua University (China)", "https://pypi.tuna.tsinghua.edu.cn/simple"),
|
||
("Aliyun Cloud (China)", "https://mirrors.aliyun.com/pypi/simple/"),
|
||
("Tencent Cloud (China)", "https://mirrors.cloud.tencent.com/pypi/simple/"),
|
||
("Huawei Cloud (China)", "https://repo.huaweicloud.com/repository/pypi/simple/"),
|
||
("USTC (China)", "https://mirrors.ustc.edu.cn/pypi/simple/"),
|
||
]
|
||
|
||
DEFAULT_MAPPINGS = {
|
||
'acoustid': 'pyacoustid', 'cv2': 'opencv-python', 'PIL': 'pillow', 'Pillow': 'pillow',
|
||
'skimage': 'scikit-image', 'vlc': 'python-vlc', 'pyzbar': 'pyzbar', 'OpenGL': 'PyOpenGL',
|
||
|
||
'pyside6_addons': 'PySide6', 'pyside6_essentials': 'PySide6',
|
||
'pyside6-addons': 'PySide6', 'pyside6-essentials': 'PySide6',
|
||
'pyqt5-plugins': 'PyQt5', 'pyqt5-tools': 'PyQt5', 'pyqt5_plugins': 'PyQt5',
|
||
|
||
'fitz': 'pymupdf', 'docx': 'python-docx', 'pptx': 'python-pptx',
|
||
'bs4': 'beautifulsoup4', 'barcode': 'python-barcode', 'pdfplumber': 'pdfplumber',
|
||
|
||
'win32com': 'pywin32', 'win32api': 'pywin32', 'win32con': 'pywin32',
|
||
'win32gui': 'pywin32', 'win32clipboard': 'pywin32', 'win32print': 'pywin32', 'win32file': 'pywin32',
|
||
'win32security': 'pywin32', 'win32process': 'pywin32', 'win32evtlog': 'pywin32', 'win32service': 'pywin32',
|
||
'win32pipe': 'pywin32', 'win32net': 'pywin32', 'win32crypt': 'pywin32', 'pythoncom': 'pywin32',
|
||
'pywintypes': 'pywin32',
|
||
|
||
'serial': 'pyserial', 'usb': 'pyusb', 'bluetooth': 'pybluez', 'dns': 'dnspython',
|
||
'websocket': 'websocket-client', 'paho': 'paho-mqtt', 'socketio': 'python-socketio',
|
||
'engineio': 'python-engineio', 'kafka': 'kafka-python',
|
||
|
||
'sklearn': 'scikit-learn', 'yaml': 'pyyaml', 'dateutil': 'python-dateutil', 'jwt': 'PyJWT',
|
||
'Crypto': 'pycryptodome', 'wx': 'wxPython', 'desktop_notifier': 'desktop-notifier',
|
||
'dotenv': 'python-dotenv', 'telegram': 'python-telegram-bot', 'git': 'GitPython',
|
||
'github': 'PyGithub', 'gitlab': 'python-gitlab', 'discord': 'discord.py',
|
||
'OpenSSL': 'pyOpenSSL', 'ldap': 'python-ldap', 'magic': 'python-magic', 'slugify': 'python-slugify',
|
||
'snappy': 'python-snappy',
|
||
|
||
'attr': 'attrs', 'psycopg2': 'psycopg2-binary'
|
||
}
|
||
|
||
DEFAULT_BACKPORT_RULES = {
|
||
"pathlib": "3.4",
|
||
"pathlib2": "3.4",
|
||
"enum34": "3.4",
|
||
"typing": "3.5",
|
||
"ipaddress": "3.3",
|
||
"asyncio": "3.4",
|
||
"argparse": "3.2",
|
||
"dataclasses": "3.7",
|
||
"importlib-metadata": "3.8",
|
||
"importlib-resources": "3.9",
|
||
"zoneinfo": "3.9",
|
||
"tomllib": "3.11",
|
||
"exceptiongroup": "3.11"
|
||
}
|
||
|
||
MATERIAL_ICONS = {
|
||
'settings': 'M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.73,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.43-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.49-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z',
|
||
'refresh': 'M17.65,6.35C16.2,4.9,14.21,4,12,4c-4.42,0-7.99,3.58-7.99,8s3.57,8,7.99,8c3.73,0,6.84-2.55,7.73-6h-2.08 c-0.82,2.33-3.04,4-5.65,4c-3.31,0-6-2.69-6-6s2.69-6,6-6c1.66,0,3.14,0.69,4.22,1.78L13,11h7V4L17.65,6.35z',
|
||
'play': 'M8 5v14l11-7z',
|
||
'stop': 'M6 6h12v12H6z',
|
||
'folder': 'M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z',
|
||
'expand_more': 'M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z',
|
||
'expand_less': 'M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z',
|
||
'check': 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z',
|
||
'package': 'M20,2H4C3,2,2,2.9,2,4v3.01C2,7.73,2.43,8.35,3,8.7V20c0,1.1,1.1,2,2,2h14c0.9,0,2-0.9,2-2V8.7c0.57-0.35,1-0.97,1-1.69V4 C22,2.9,21,2,20,2z M19,20H5V9h14V20z M20,7H4V4h16V7z M9,12h6v2H9V12z',
|
||
'back': 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
|
||
'info': 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z',
|
||
'python': 'M12.06,1.48c-3.14,0-3.52,0.67-3.52,0.67l-0.01,2.44h3.63v0.52H7.43C5.12,5.11,4.5,6.58,4.5,8.81c0,2.34,0.38,3.48,2.3,3.48 h1.14v-1.62c0-1.48,1.23-2.65,2.7-2.65h3.69c1.47,0,2.66-1.19,2.66-2.65V3.88C16.99,1.83,14.67,1.48,12.06,1.48z M10.22,2.83 c0.41,0,0.73,0.33,0.73,0.74c0,0.41-0.33,0.74-0.73,0.74c-0.4,0-0.73-0.33-0.73-0.74C9.49,3.16,9.82,2.83,10.22,2.83z M16.71,9.89 v1.62c0,1.48-1.23,2.65-2.7,2.65H10.3c-1.47,0-2.66,1.19-2.66,2.65v1.49c0,2.05,2.32,2.41,4.92,2.41c3.14,0,3.52-0.67,3.52-0.67 l0.01-2.44h-3.63v-0.52h4.73c2.31,0,2.93-1.47,2.93-3.7c0-2.34-0.38-3.48-2.3-3.48H16.71z M13.88,18.96c0.41,0,0.73,0.33,0.73,0.74c0,0.41-0.33,0.74-0.73,0.74c-0.4,0-0.73-0.33-0.73-0.74C13.15,19.29,13.48,18.96,13.88,18.96z',
|
||
'close': 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
|
||
'engine': 'M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.6C.4 7 1 10 3 12c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.4-.4.4-1.1 0-1.3z',
|
||
'bolt': 'M11 21h-1l1-7H7.5c-.58 0-.57-.32-.38-.66s.06-.11.08-.15C8.22 11.23 10.3 7.6 13.43 2.15c.18-.32.37-.15.37.15l-1 7h3.5c.58 0 .57.32.38.66s-.06.12-.08.16L11 21z',
|
||
'link': 'M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z'
|
||
}
|
||
|
||
def load_config(retry=True):
|
||
config = configparser.ConfigParser()
|
||
default_mirror = "https://pypi.org/simple"
|
||
default_backup = "https://test.pypi.org/simple"
|
||
|
||
if I18N.detect_system_language() == "zh_CN":
|
||
default_mirror = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||
default_backup = "https://mirrors.aliyun.com/pypi/simple/"
|
||
|
||
if not os.path.exists(CONFIG_FILE):
|
||
config['Mappings'] = DEFAULT_MAPPINGS
|
||
config['Settings'] = {
|
||
'language': 'auto',
|
||
'engine': 'PyInstaller',
|
||
'pip_index': default_mirror,
|
||
'pip_index_backup': default_backup,
|
||
'onefile': 'True', 'noconsole': 'True', 'clean_all': 'True',
|
||
'auto_icon': 'True', 'use_venv': 'True', 'use_reqs': 'True',
|
||
'use_pipreqs': 'True', 'use_pipreqs_dir': 'False', 'upx': 'False', 'concise_log': 'True',
|
||
'cpu_cores': str(os.cpu_count() or 2), 'upx_path': '',
|
||
'exclude_modules': '', 'out_mode': '0', 'custom_out_dir': '',
|
||
'temp_sandbox_mode': '0',
|
||
'sound_notify': 'True', 'auto_save_log': 'False',
|
||
'use_reqs_file': '', 'add_data_list': '', 'custom_python_path': '',
|
||
'pyi_version': '6.21.0', 'nuitka_version': '4.1.3',
|
||
'enable_backport_shield': 'True',
|
||
'lite_mode': 'False'
|
||
}
|
||
try:
|
||
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
|
||
config.write(f)
|
||
except: pass
|
||
else:
|
||
try:
|
||
config.read(CONFIG_FILE, encoding='utf-8')
|
||
except Exception:
|
||
if retry:
|
||
try:
|
||
if os.path.exists(CONFIG_FILE):
|
||
os.remove(CONFIG_FILE)
|
||
except Exception:
|
||
pass
|
||
return load_config(retry=False)
|
||
else:
|
||
if 'Mappings' not in config: config['Mappings'] = DEFAULT_MAPPINGS
|
||
if 'Settings' not in config: config['Settings'] = {}
|
||
if 'BackportRules' not in config: config['BackportRules'] = DEFAULT_BACKPORT_RULES
|
||
|
||
if 'Mappings' not in config:
|
||
config['Mappings'] = DEFAULT_MAPPINGS
|
||
else:
|
||
updated_map = False
|
||
for k, v in DEFAULT_MAPPINGS.items():
|
||
if k not in config['Mappings']:
|
||
config['Mappings'][k] = v
|
||
updated_map = True
|
||
if updated_map:
|
||
try: save_config(config)
|
||
except: pass
|
||
|
||
if 'Settings' not in config: config['Settings'] = {}
|
||
|
||
updated = False
|
||
default_updates = {
|
||
'language': 'auto',
|
||
'keep_venv': 'False',
|
||
'enable_sign': 'True' if sys.platform == 'darwin' else 'False',
|
||
'cert_path': '',
|
||
'cert_pass': '',
|
||
'pip_index': default_mirror,
|
||
'pip_index_backup': default_backup,
|
||
'concise_log': 'True',
|
||
'cpu_cores': str(os.cpu_count() or 2),
|
||
'upx_path': '',
|
||
'exclude_modules': '',
|
||
'out_mode': '0',
|
||
'custom_out_dir': '',
|
||
'temp_sandbox_mode': '0',
|
||
'sound_notify': 'True',
|
||
'auto_save_log': 'False',
|
||
'use_reqs_file': '',
|
||
'add_data_list': '',
|
||
'custom_python_path': '',
|
||
'pyi_version': '6.21.0',
|
||
'nuitka_version': '4.1.3',
|
||
'venv_mode': 'isolated',
|
||
'shared_venv_dir': '',
|
||
'lite_mode': 'False'
|
||
}
|
||
for k, v in default_updates.items():
|
||
if k not in config['Settings']:
|
||
config['Settings'][k] = v
|
||
updated = True
|
||
|
||
if updated:
|
||
try: save_config(config)
|
||
except: pass
|
||
|
||
if 'BackportRules' not in config:
|
||
config['BackportRules'] = DEFAULT_BACKPORT_RULES
|
||
else:
|
||
updated_rules = False
|
||
for k, v in DEFAULT_BACKPORT_RULES.items():
|
||
if k not in config['BackportRules']:
|
||
config['BackportRules'][k] = v
|
||
updated_rules = True
|
||
if updated_rules:
|
||
try: save_config(config)
|
||
except Exception: pass
|
||
lang_pref = config['Settings'].get('language', 'auto')
|
||
I18N.set_language(lang_pref)
|
||
return config
|
||
|
||
def save_config(config):
|
||
import time
|
||
temp_file = CONFIG_FILE + ".tmp"
|
||
try:
|
||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||
config.write(f)
|
||
except Exception:
|
||
return
|
||
|
||
for _ in range(5):
|
||
try:
|
||
if os.path.exists(CONFIG_FILE):
|
||
os.replace(temp_file, CONFIG_FILE)
|
||
else:
|
||
os.rename(temp_file, CONFIG_FILE)
|
||
break
|
||
except PermissionError:
|
||
time.sleep(0.2)
|
||
except Exception:
|
||
break
|
||
|
||
def create_pleasant_audio_files():
|
||
success_wav = _CONFIG_DIR / "sound_success.wav"
|
||
failure_wav = _CONFIG_DIR / "sound_failure.wav"
|
||
sample_rate = 44100
|
||
|
||
if not success_wav.exists():
|
||
try:
|
||
notes = [(523.25, 0.0, 0.35), (659.25, 0.09, 0.35), (784.00, 0.18, 0.35), (1046.50, 0.27, 0.55)]
|
||
total_duration = 0.75
|
||
n_samples = int(sample_rate * total_duration)
|
||
samples = [0.0] * n_samples
|
||
|
||
for freq, start, dur in notes:
|
||
start_idx = int(start * sample_rate)
|
||
dur_samples = int(dur * sample_rate)
|
||
for i in range(dur_samples):
|
||
idx = start_idx + i
|
||
if idx >= n_samples: break
|
||
t = i / sample_rate
|
||
env = math.sin(math.pi * min(1.0, t / 0.025)) * math.exp(-4.2 * (t / dur))
|
||
val = (math.sin(2 * math.pi * freq * t) * 0.75 +
|
||
math.sin(2 * math.pi * freq * 2 * t) * 0.2 +
|
||
math.sin(2 * math.pi * freq * 3 * t) * 0.05) * env
|
||
samples[idx] += val * 0.28
|
||
|
||
with wave.open(success_wav.as_posix(), 'w') as wf:
|
||
wf.setnchannels(1)
|
||
wf.setsampwidth(2)
|
||
wf.setframerate(sample_rate)
|
||
packed = b''.join(struct.pack('<h', int(max(-1.0, min(1.0, s)) * 32767)) for s in samples)
|
||
wf.writeframes(packed)
|
||
except Exception: pass
|
||
|
||
if not failure_wav.exists():
|
||
try:
|
||
notes = [(392.00, 0.0, 0.3), (311.13, 0.12, 0.45)]
|
||
total_duration = 0.6
|
||
n_samples = int(sample_rate * total_duration)
|
||
samples = [0.0] * n_samples
|
||
|
||
for freq, start, dur in notes:
|
||
start_idx = int(start * sample_rate)
|
||
dur_samples = int(dur * sample_rate)
|
||
for i in range(dur_samples):
|
||
idx = start_idx + i
|
||
if idx >= n_samples: break
|
||
t = i / sample_rate
|
||
env = math.sin(math.pi * min(1.0, t / 0.02)) * math.exp(-3.2 * (t / dur))
|
||
val = (math.sin(2 * math.pi * freq * t) * 0.8 +
|
||
math.sin(2 * math.pi * freq * 2 * t) * 0.2) * env
|
||
samples[idx] += val * 0.32
|
||
|
||
with wave.open(failure_wav.as_posix(), 'w') as wf:
|
||
wf.setnchannels(1)
|
||
wf.setsampwidth(2)
|
||
wf.setframerate(sample_rate)
|
||
packed = b''.join(struct.pack('<h', int(max(-1.0, min(1.0, s)) * 32767)) for s in samples)
|
||
wf.writeframes(packed)
|
||
except Exception: pass
|
||
|
||
_AUDIO_EFFECT_REF = None
|
||
|
||
def play_alert(success=True, sound_enabled=True):
|
||
global _AUDIO_EFFECT_REF
|
||
if not sound_enabled:
|
||
return
|
||
|
||
try:
|
||
sound_file = _CONFIG_DIR / ("sound_success.wav" if success else "sound_failure.wav")
|
||
if not sound_file.exists():
|
||
create_pleasant_audio_files()
|
||
|
||
if sound_file.exists():
|
||
if HAS_QT_AUDIO:
|
||
_AUDIO_EFFECT_REF = QSoundEffect()
|
||
_AUDIO_EFFECT_REF.setSource(QUrl.fromLocalFile(sound_file.as_posix()))
|
||
_AUDIO_EFFECT_REF.setVolume(0.75)
|
||
_AUDIO_EFFECT_REF.play()
|
||
elif os.name == 'nt':
|
||
import winsound
|
||
winsound.PlaySound(sound_file.as_posix(), winsound.SND_FILENAME | winsound.SND_ASYNC)
|
||
except Exception:
|
||
pass
|
||
|
||
def is_cloud_locked(filepath):
|
||
try:
|
||
with open(filepath, 'rb') as f:
|
||
return b"__CLOUDSYNC_ENC__" in f.read(1024)
|
||
except Exception:
|
||
return False
|
||
|
||
def is_cloud_sync_path(path_obj: Path) -> bool:
|
||
path_str = path_obj.resolve().as_posix().lower()
|
||
cloud_keywords = [
|
||
'onedrive', 'dropbox', 'icloud', 'google drive', 'googledrive', 'gdrive',
|
||
'box sync', 'box.com', 'pcloud', 'mega', 'megasync', 'nextcloud', 'owncloud',
|
||
'synology', 'seafile', 'tresorit', 'yandex',
|
||
'nutstore', '坚果云', '百度云', '百度网盘', '阿里云盘', 'aliyun', '115', '微云'
|
||
]
|
||
return any(kw in path_str for kw in cloud_keywords)
|
||
|
||
def extract_project_imports_via_ast(target_path, scan_dir: bool = False) -> set:
|
||
imports = set()
|
||
target_path = Path(target_path)
|
||
|
||
if not scan_dir or not target_path.is_dir():
|
||
files_to_scan = [target_path] if target_path.is_file() else []
|
||
else:
|
||
files_to_scan = []
|
||
for root, _, files in os.walk(target_path):
|
||
path_parts = set(Path(root).parts)
|
||
if path_parts & {'__pycache__', '.qpypack_build', '.qpypack_venv', '.venv', 'venv', 'build', 'dist', 'env', '.env'}:
|
||
continue
|
||
for file in files:
|
||
if file.endswith(('.py', '.pyw')):
|
||
files_to_scan.append(Path(root) / file)
|
||
|
||
for file_p in files_to_scan:
|
||
try:
|
||
code = safe_read_text(file_p)
|
||
tree = ast.parse(code, filename=file_p.as_posix())
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Import):
|
||
imports.update(n.name.split('.')[0] for n in node.names)
|
||
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||
imports.add(node.module.split('.')[0])
|
||
except Exception:
|
||
pass
|
||
return imports
|
||
|
||
def query_target_env_packages(python_exe: str) -> dict:
|
||
if not python_exe or not os.path.exists(python_exe):
|
||
return {}
|
||
|
||
code = (
|
||
"import json\n"
|
||
"try:\n"
|
||
" try:\n"
|
||
" from importlib.metadata import packages_distributions\n"
|
||
" except ImportError:\n"
|
||
" from importlib_metadata import packages_distributions\n"
|
||
" res = {k: v[0] for k, v in packages_distributions().items() if v}\n"
|
||
" print(json.dumps(res))\n"
|
||
"except Exception:\n"
|
||
" print('{}')\n"
|
||
)
|
||
try:
|
||
clean_env = os.environ.copy()
|
||
clean_env.pop("PYTHONHOME", None)
|
||
clean_env.pop("PYTHONPATH", None)
|
||
kwargs = {"capture_output": True, "text": True, "env": clean_env, "timeout": 8, "errors": "ignore"}
|
||
if os.name == 'nt':
|
||
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
|
||
proc = subprocess.run([python_exe, "-c", code], **kwargs)
|
||
if proc.returncode == 0 and proc.stdout.strip():
|
||
return json.loads(proc.stdout.strip())
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
def find_system_python():
|
||
candidates = []
|
||
for name in ('python', 'python3', 'pythonw'):
|
||
p = shutil.which(name)
|
||
if p: candidates.append(p)
|
||
|
||
if winreg is not None:
|
||
try:
|
||
py = shutil.which("py")
|
||
if py:
|
||
clean_env = os.environ.copy()
|
||
clean_env.pop("PYTHONHOME", None)
|
||
clean_env.pop("PYTHONPATH", None)
|
||
clean_env["PYTHONUTF8"] = "1"
|
||
proc = subprocess.Popen(
|
||
[py, "-c", "import sys; print(sys.executable)"],
|
||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
|
||
text=True, encoding="utf-8", errors="ignore", env=clean_env, creationflags=subprocess.CREATE_NO_WINDOW
|
||
)
|
||
out, _err = proc.communicate(timeout=3)
|
||
if out and os.path.exists(out.strip()): candidates.append(out.strip())
|
||
except: pass
|
||
|
||
try:
|
||
for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):
|
||
try:
|
||
with winreg.OpenKey(hive, r"SOFTWARE\Python\PythonCore") as core_key:
|
||
for i in range(winreg.QueryInfoKey(core_key)[0]):
|
||
sub = winreg.EnumKey(core_key, i)
|
||
try:
|
||
with winreg.OpenKey(core_key, rf"{sub}\InstallPath") as pkey:
|
||
path = winreg.QueryValueEx(pkey, "")[0]
|
||
exe = os.path.join(path, "python.exe")
|
||
if os.path.exists(exe): candidates.append(exe)
|
||
except: pass
|
||
except: pass
|
||
except: pass
|
||
|
||
search_paths = [
|
||
os.environ.get("LOCALAPPDATA", "") + r"\Programs\Python",
|
||
os.environ.get("PROGRAMFILES", "") + r"\Python",
|
||
os.environ.get("PROGRAMFILES(X86)", "") + r"\Python",
|
||
r"C:\Python", r"C:\Program Files\Python", r"C:\Program Files (x86)\Python"
|
||
]
|
||
for base in search_paths:
|
||
if base and os.path.exists(base):
|
||
try:
|
||
for d in os.listdir(base):
|
||
if d.lower().startswith("python"):
|
||
exe = os.path.join(base, d, "python.exe")
|
||
if os.path.exists(exe): candidates.append(exe)
|
||
except: pass
|
||
|
||
user_profile = os.environ.get("USERPROFILE", "")
|
||
if user_profile:
|
||
for c_dir in ("miniconda3", "anaconda3", "Miniconda3", "Anaconda3"):
|
||
for base in (user_profile, "C:\\", "D:\\"):
|
||
exe = os.path.join(base, c_dir, "python.exe")
|
||
if os.path.exists(exe): candidates.append(exe)
|
||
else:
|
||
unix_bases = [
|
||
"/usr/bin", "/usr/local/bin", "/opt/homebrew/bin",
|
||
"/Library/Frameworks/Python.framework/Versions/Current/bin",
|
||
os.path.expanduser("~/.pyenv/shims"), os.path.expanduser("~/.local/bin")
|
||
]
|
||
user_profile = os.environ.get("HOME", "") or os.environ.get("USERPROFILE", "")
|
||
if user_profile:
|
||
for c_dir in ("miniconda3", "anaconda3", "mambaforge", ".conda"):
|
||
base_dir = os.path.join(user_profile, c_dir)
|
||
if os.path.exists(base_dir):
|
||
exe = os.path.join(base_dir, "bin", "python")
|
||
if os.path.exists(exe): unix_bases.append(os.path.join(base_dir, "bin"))
|
||
envs_dir = os.path.join(base_dir, "envs")
|
||
if os.path.exists(envs_dir):
|
||
try:
|
||
for env in os.listdir(envs_dir):
|
||
p_bin = os.path.join(envs_dir, env, "bin")
|
||
if os.path.exists(p_bin): unix_bases.append(p_bin)
|
||
except: pass
|
||
|
||
for base in unix_bases:
|
||
if os.path.exists(base):
|
||
try:
|
||
for f in os.listdir(base):
|
||
if f.startswith("python3") or f == "python":
|
||
exe = os.path.join(base, f)
|
||
if os.path.isfile(exe) and os.access(exe, os.X_OK): candidates.append(exe)
|
||
except: pass
|
||
|
||
seen = set()
|
||
unique_candidates = []
|
||
for cand in candidates:
|
||
cand = os.path.normpath(cand)
|
||
if cand not in seen:
|
||
seen.add(cand)
|
||
unique_candidates.append(cand)
|
||
|
||
for cand in unique_candidates:
|
||
if not os.path.exists(cand): continue
|
||
|
||
if getattr(sys, 'frozen', False) or '__compiled__' in globals():
|
||
try:
|
||
if os.path.samefile(cand, sys.executable):
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
if os.name == 'nt' and "WindowsApps" in cand:
|
||
try:
|
||
if os.path.getsize(cand) == 0: continue
|
||
except: continue
|
||
|
||
clean_env = os.environ.copy()
|
||
clean_env.pop("PYTHONHOME", None)
|
||
clean_env.pop("PYTHONPATH", None)
|
||
|
||
try:
|
||
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "encoding": "utf-8", "errors": "ignore", "env": clean_env, "timeout": 3}
|
||
if os.name == 'nt': kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
proc = subprocess.run([cand, "-V"], **kwargs)
|
||
if proc.returncode == 0: return cand
|
||
except: continue
|
||
|
||
return ""
|
||
|
||
def get_svg_icon(name, color="#5F6368", size=24):
|
||
path_data = MATERIAL_ICONS.get(name, "")
|
||
svg_str = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="{color}" d="{path_data}"/></svg>'
|
||
renderer = QSvgRenderer()
|
||
renderer.load(svg_str.encode('utf-8'))
|
||
|
||
render_size = max(size * 4, 128)
|
||
pixmap = QPixmap(render_size, render_size)
|
||
pixmap.fill(Qt.GlobalColor.transparent)
|
||
|
||
painter = QPainter(pixmap)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||
renderer.render(painter)
|
||
painter.end()
|
||
|
||
return QIcon(pixmap)
|
||
|
||
def get_svg_pixmap(name, color="#5F6368", size=64):
|
||
return get_svg_icon(name, color, size).pixmap(size, size)
|
||
|
||
_ARROW_ICON_PATH = (_CONFIG_DIR / "dropdown_arrow.png").as_posix()
|
||
|
||
def ensure_arrow_icon():
|
||
if not os.path.exists(_ARROW_ICON_PATH):
|
||
try:
|
||
pix = get_svg_pixmap('expand_more', color="#5F6368", size=32)
|
||
pix.save(_ARROW_ICON_PATH, "PNG")
|
||
except Exception:
|
||
pass
|
||
|
||
class CustomInputDialog(QDialog):
|
||
def __init__(self, parent, title, label_text, text=""):
|
||
super().__init__(parent)
|
||
self.setWindowTitle(title)
|
||
self.setMinimumWidth(320)
|
||
self.setStyleSheet("""
|
||
QDialog { background-color: #ffffff; }
|
||
QLabel { font-size: 12px; color: #111827; }
|
||
QLineEdit { padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 12px; }
|
||
QLineEdit:focus { border: 1px solid #2563eb; }
|
||
QPushButton { background-color: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 12px; font-weight: bold; padding: 6px 16px; min-width: 70px; }
|
||
QPushButton:hover { background-color: #e2e8f0; }
|
||
QPushButton#btnOk { background-color: #2563eb; color: #ffffff; border: none; }
|
||
QPushButton#btnOk:hover { background-color: #1d4ed8; }
|
||
""")
|
||
|
||
lay = QVBoxLayout(self)
|
||
lay.setSpacing(10)
|
||
lay.setContentsMargins(20, 20, 20, 20)
|
||
|
||
lay.addWidget(QLabel(label_text))
|
||
|
||
self.edit = QLineEdit(text)
|
||
lay.addWidget(self.edit)
|
||
|
||
lay.addSpacing(10)
|
||
|
||
btn_lay = QHBoxLayout()
|
||
btn_lay.addStretch()
|
||
|
||
self.btn_ok = QPushButton(_("OK"))
|
||
self.btn_ok.setObjectName("btnOk")
|
||
self.btn_ok.clicked.connect(self.accept)
|
||
|
||
self.btn_cancel = QPushButton(_("Cancel"))
|
||
self.btn_cancel.clicked.connect(self.reject)
|
||
|
||
btn_lay.addWidget(self.btn_ok)
|
||
btn_lay.addWidget(self.btn_cancel)
|
||
lay.addLayout(btn_lay)
|
||
|
||
def get_text(self):
|
||
return self.edit.text()
|
||
|
||
class GenCertDialog(QDialog):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle(_("Create Self-Signed Cert"))
|
||
self.setMinimumWidth(380)
|
||
self.setStyleSheet("""
|
||
QDialog { background-color: #ffffff; }
|
||
QLabel { font-size: 12px; color: #111827; }
|
||
QLineEdit { padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 12px; }
|
||
QLineEdit:focus { border: 1px solid #2563eb; }
|
||
QPushButton { background-color: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 12px; font-weight: bold; padding: 6px 16px; min-width: 70px; }
|
||
QPushButton:hover { background-color: #e2e8f0; }
|
||
QPushButton#btnOk { background-color: #2563eb; color: #ffffff; border: none; }
|
||
QPushButton#btnOk:hover { background-color: #1d4ed8; }
|
||
""")
|
||
|
||
lay = QVBoxLayout(self)
|
||
lay.setSpacing(10)
|
||
lay.setContentsMargins(20, 20, 20, 20)
|
||
|
||
form_lay = QFormLayout()
|
||
|
||
self.subject_edit = QLineEdit(_("QPyPack Developer"))
|
||
self.pass_edit = QLineEdit()
|
||
self.pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||
self.confirm_edit = QLineEdit()
|
||
self.confirm_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||
|
||
form_lay.addRow(QLabel(_("Certificate Subject (e.g. My Company):")), self.subject_edit)
|
||
form_lay.addRow(QLabel(_("Password:")), self.pass_edit)
|
||
form_lay.addRow(QLabel(_("Confirm Password:")), self.confirm_edit)
|
||
|
||
lay.addLayout(form_lay)
|
||
|
||
self.hint_label = QLabel(_("Recommendation: 6+ chars, alphanumeric, '-' and '_' only. Avoid spaces or Chinese to prevent signing errors."))
|
||
self.hint_label.setWordWrap(True)
|
||
self.hint_label.setStyleSheet("color: #64748b; font-size: 11px; margin-top: 2px;")
|
||
lay.addWidget(self.hint_label)
|
||
|
||
lay.addSpacing(10)
|
||
|
||
btn_lay = QHBoxLayout()
|
||
btn_lay.addStretch()
|
||
|
||
self.btn_ok = QPushButton(_("Generate"))
|
||
self.btn_ok.setObjectName("btnOk")
|
||
self.btn_ok.clicked.connect(self.on_generate)
|
||
|
||
self.btn_cancel = QPushButton(_("Cancel"))
|
||
self.btn_cancel.clicked.connect(self.reject)
|
||
|
||
btn_lay.addWidget(self.btn_ok)
|
||
btn_lay.addWidget(self.btn_cancel)
|
||
lay.addLayout(btn_lay)
|
||
|
||
self.cert_info = None
|
||
|
||
def on_generate(self):
|
||
subject = self.subject_edit.text().strip()
|
||
pwd = self.pass_edit.text()
|
||
confirm = self.confirm_edit.text()
|
||
|
||
if not subject:
|
||
return
|
||
|
||
if not pwd:
|
||
QMessageBox.warning(self, _("Error"), _("Password cannot be empty!"))
|
||
return
|
||
|
||
if pwd != confirm:
|
||
QMessageBox.warning(self, _("Error"), _("Passwords do not match!"))
|
||
return
|
||
|
||
self.cert_info = (subject, pwd)
|
||
self.accept()
|
||
|
||
class ComboItemDelegate(QStyledItemDelegate):
|
||
def sizeHint(self, option, index):
|
||
s = super().sizeHint(option, index)
|
||
return QSize(s.width(), max(s.height() + 12, 30))
|
||
|
||
class TableItemDelegate(QStyledItemDelegate):
|
||
def createEditor(self, parent, option, index):
|
||
editor = QLineEdit(parent)
|
||
editor.setAutoFillBackground(True)
|
||
editor.setStyleSheet("""
|
||
QLineEdit {
|
||
background-color: #ffffff !important;
|
||
color: #111827;
|
||
border: 2px solid #2563eb;
|
||
border-radius: 4px;
|
||
padding: 2px 6px;
|
||
font-size: 12px;
|
||
font-family: Consolas, "Segoe UI", sans-serif;
|
||
selection-background-color: #2563eb;
|
||
selection-color: #ffffff;
|
||
}
|
||
""")
|
||
return editor
|
||
|
||
def setup_combo_white_theme(combo: QComboBox, min_view_width: int = None):
|
||
ensure_arrow_icon()
|
||
list_view = QListView(combo)
|
||
combo.setView(list_view)
|
||
combo.setItemDelegate(ComboItemDelegate(combo))
|
||
|
||
combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
|
||
|
||
if min_view_width and combo.view():
|
||
combo.view().setMinimumWidth(min_view_width)
|
||
combo.view().setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
|
||
arrow_url = _ARROW_ICON_PATH.replace("\\", "/")
|
||
combo.setStyleSheet(f"""
|
||
QComboBox {{
|
||
combobox-popup: 0; background-color: #ffffff; color: #111827;
|
||
border: 1px solid #d1d5db; border-radius: 6px; padding: 5px 26px 5px 10px;
|
||
font-size: 12px; min-height: 22px; font-family: Consolas, "Segoe UI", sans-serif;
|
||
selection-background-color: #2563eb; selection-color: #ffffff;
|
||
}}
|
||
QComboBox:hover {{ border-color: #9ca3af; }}
|
||
QComboBox:focus {{ border-color: #2563eb; }}
|
||
QComboBox::drop-down {{ subcontrol-origin: padding; subcontrol-position: top right; width: 24px; border: none; background: transparent; }}
|
||
QComboBox::down-arrow {{ image: url("{arrow_url}"); width: 14px; height: 14px; }}
|
||
QComboBox QLineEdit {{ background-color: #ffffff; color: #111827; font-size: 12px; selection-background-color: #2563eb; selection-color: #ffffff; }}
|
||
""")
|
||
combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
combo.setMinimumWidth(150)
|
||
|
||
class DropListWidget(QListWidget):
|
||
itemsDropped = Signal(list)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setAcceptDrops(True)
|
||
|
||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||
if event.mimeData().hasUrls(): event.acceptProposedAction()
|
||
|
||
def dragMoveEvent(self, event):
|
||
if event.mimeData().hasUrls(): event.acceptProposedAction()
|
||
|
||
def dropEvent(self, event: QDropEvent):
|
||
urls = event.mimeData().urls()
|
||
if urls:
|
||
paths = [u.toLocalFile() for u in urls if u.toLocalFile()]
|
||
if paths: self.itemsDropped.emit(paths)
|
||
|
||
def get_stdlib_names():
|
||
libs = set(sys.builtin_module_names)
|
||
|
||
if hasattr(sys, 'stdlib_module_names'):
|
||
libs.update(sys.stdlib_module_names)
|
||
|
||
try:
|
||
import sysconfig
|
||
stdlib_dir = sysconfig.get_path('stdlib')
|
||
if stdlib_dir and os.path.exists(stdlib_dir):
|
||
for f in os.listdir(stdlib_dir):
|
||
if f.endswith('.py'):
|
||
libs.add(f[:-3])
|
||
elif os.path.isdir(os.path.join(stdlib_dir, f)) and os.path.exists(os.path.join(stdlib_dir, f, '__init__.py')):
|
||
libs.add(f)
|
||
except Exception:
|
||
pass
|
||
|
||
known_std = {
|
||
'abc', 'aifc', 'argparse', 'array', 'ast', 'asynchat', 'asyncio', 'asyncore', 'atexit',
|
||
'audioop', 'base64', 'bdb', 'binascii', 'binhex', 'bisect', 'builtins', 'bz2',
|
||
'calendar', 'cgi', 'cgitb', 'chunk', 'cmap', 'cmath', 'cmd', 'code', 'codecs', 'codeop',
|
||
'collections', 'colorsys', 'compileall', 'concurrent', 'configparser', 'contextlib',
|
||
'contextvars', 'copy', 'copyreg', 'crypt', 'csv', 'ctypes', 'curses', 'dataclasses',
|
||
'datetime', 'dbm', 'decimal', 'difflib', 'dis', 'distutils', 'doctest', 'dummy_threading',
|
||
'email', 'encodings', 'ensurepip', 'enum', 'errno', 'faulthandler', 'fcntl', 'filecmp',
|
||
'fileinput', 'fnmatch', 'formatter', 'fractions', 'ftplib', 'functools', 'gc', 'genericpath',
|
||
'getopt', 'getpass', 'gettext', 'glob', 'graphlib', 'grp', 'gzip', 'hashlib', 'heapq',
|
||
'hmac', 'html', 'http', 'imaplib', 'imghdr', 'imp', 'importlib', 'inspect', 'io',
|
||
'ipaddress', 'itertools', 'json', 'keyword', 'lib2to3', 'linecache', 'locale', 'logging',
|
||
'lzma', 'macpath', 'mailbox', 'mailcap', 'marshal', 'math', 'mimetypes', 'mmap', 'modulefinder',
|
||
'msvcrt', 'multiprocessing', 'netrc', 'nis', 'nntplib', 'numbers', 'opcode', 'operator',
|
||
'optparse', 'os', 'ossaudiodev', 'pathlib', 'pdb', 'pickle', 'pickletools', 'pipes',
|
||
'pkgutil', 'platform', 'plistlib', 'poplib', 'posix', 'posixpath', 'pprint', 'profile',
|
||
'pstats', 'pty', 'pwd', 'py_compile', 'pyclbr', 'pydoc', 'queue', 'quopri', 'random',
|
||
're', 'readline', 'reprlib', 'resource', 'rlcompleter', 'runpy', 'sched', 'secrets',
|
||
'select', 'selectors', 'shelve', 'shlex', 'shutil', 'signal', 'site', 'smtpd', 'smtplib',
|
||
'sndhdr', 'socket', 'socketserver', 'spwd', 'sqlite3', 'ssl', 'stat', 'statistics',
|
||
'string', 'stringprep', 'struct', 'subprocess', 'sunau', 'symbol', 'symtable', 'sys',
|
||
'sysconfig', 'syslog', 'tabnanny', 'tarfile', 'telnetlib', 'tempfile', 'termios', 'textwrap',
|
||
'threading', 'time', 'timeit', 'tkinter', 'token', 'tokenize', 'tomllib', 'trace',
|
||
'tracemalloc', 'tty', 'turtle', 'turtledemo', 'types', 'typing', 'unicodedata', 'unittest',
|
||
'urllib', 'uu', 'uuid', 'venv', 'wave', 'weakref', 'webbrowser', 'winreg', 'winsound',
|
||
'wsgiref', 'xdrlib', 'xml', 'xmlrpc', 'zipapp', 'zipfile', 'zipimport', 'zlib', 'zoneinfo',
|
||
'__future__', '_thread', '_asyncio'
|
||
}
|
||
libs.update(known_std)
|
||
return libs
|
||
|
||
STD_LIBS = get_stdlib_names()
|
||
|
||
def get_resource_path(relative_path):
|
||
if hasattr(sys, '_MEIPASS'):
|
||
base_path = sys._MEIPASS
|
||
elif '__compiled__' in globals():
|
||
base_path = os.path.dirname(os.path.abspath(__file__))
|
||
else:
|
||
base_path = os.path.dirname(os.path.abspath(__file__))
|
||
return os.path.join(base_path, relative_path)
|
||
|
||
def get_python_executable():
|
||
try:
|
||
config = load_config()
|
||
custom_path = config['Settings'].get('custom_python_path', '').strip()
|
||
if custom_path and os.path.exists(custom_path) and os.path.isfile(custom_path):
|
||
return custom_path
|
||
except Exception: pass
|
||
|
||
if getattr(sys, 'frozen', False) or '__compiled__' in globals():
|
||
return find_system_python()
|
||
|
||
exe_name = Path(sys.executable).name.lower()
|
||
if exe_name in ('python.exe', 'python3.exe', 'pythonw.exe', 'python', 'python3'):
|
||
return sys.executable
|
||
|
||
return find_system_python()
|
||
|
||
def safe_read_text(path: Path) -> str:
|
||
try:
|
||
raw = path.read_bytes()
|
||
for enc in ('utf-8-sig', 'utf-8', 'gbk', 'gb18030', 'big5', 'latin-1'):
|
||
try:
|
||
return raw.decode(enc)
|
||
except UnicodeDecodeError:
|
||
continue
|
||
return raw.decode(locale.getpreferredencoding(), errors='ignore')
|
||
except Exception:
|
||
return ""
|
||
|
||
def remove_readonly(func, path, exc_info=None):
|
||
try:
|
||
os.chmod(path, stat.S_IWRITE)
|
||
func(path)
|
||
except Exception: pass
|
||
|
||
def robust_rmtree(path: Path, retries=15, delay=0.8):
|
||
if not path.exists():
|
||
return True
|
||
|
||
for attempt in range(retries):
|
||
try:
|
||
if sys.version_info >= (3, 12):
|
||
shutil.rmtree(path, onexc=lambda func, p, exc: remove_readonly(func, p))
|
||
else:
|
||
shutil.rmtree(path, onerror=remove_readonly)
|
||
if not path.exists():
|
||
return True
|
||
except PermissionError as e:
|
||
if attempt == retries - 1:
|
||
logger.warning(f"Failed to remove {path} after {retries} attempts: {e}")
|
||
time.sleep(delay)
|
||
except Exception as e:
|
||
logger.warning(f"Unexpected error removing {path}: {e}")
|
||
if attempt == retries - 1:
|
||
return False
|
||
time.sleep(delay)
|
||
|
||
return False
|
||
|
||
def convert_image_to_format(src_path, dest_path, dest_format):
|
||
src = Path(src_path).resolve()
|
||
dst = Path(dest_path).resolve()
|
||
fmt = dest_format.lower()
|
||
|
||
try:
|
||
from PIL import Image
|
||
img = Image.open(src.as_posix())
|
||
if fmt == "ico":
|
||
ico_sizes = [(256, 256), (128, 128), (64, 64), (48, 48), (32, 32), (16, 16)]
|
||
resample_filter = getattr(Image.Resampling, 'LANCZOS', getattr(Image, 'LANCZOS', Image.BICUBIC))
|
||
img.save(dst.as_posix(), format="ICO", sizes=ico_sizes, resample=resample_filter)
|
||
return True
|
||
elif fmt == "icns":
|
||
img.save(dst.as_posix(), format="ICNS", sizes=[(512, 512), (256, 256), (128, 128), (64, 64)])
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
img = QImage(src.as_posix())
|
||
if not img.isNull():
|
||
if fmt == "ico":
|
||
img = img.scaled(256, 256, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||
writer = QImageWriter(dst.as_posix(), fmt.upper().encode('utf-8'))
|
||
if writer.write(img):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
if fmt == "ico":
|
||
try:
|
||
png_bytes = None
|
||
img = QImage(src.as_posix())
|
||
if not img.isNull():
|
||
img_scaled = img.scaled(256, 256, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||
temp_png = Path(tempfile.gettempdir()) / f"qpypack_ico_fallback_{int(time.time())}.png"
|
||
if img_scaled.save(temp_png.as_posix(), "PNG"):
|
||
png_bytes = temp_png.read_bytes()
|
||
temp_png.unlink(missing_ok=True)
|
||
elif src.suffix.lower() == '.png':
|
||
png_bytes = src.read_bytes()
|
||
|
||
if png_bytes:
|
||
header = struct.pack('<HHH', 0, 1, 1)
|
||
entry = struct.pack('<BBBBHHII', 0, 0, 0, 0, 1, 32, len(png_bytes), 22)
|
||
dst.write_bytes(header + entry + png_bytes)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
return False
|
||
|
||
def get_free_disk_gb(path="."):
|
||
try:
|
||
total, used, free = shutil.disk_usage(path)
|
||
return free / (1024 ** 3)
|
||
except Exception:
|
||
return 10.0
|
||
|
||
def get_free_ram_gb():
|
||
if os.name == 'nt':
|
||
try:
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
class MEMORYSTATUSEX(ctypes.Structure):
|
||
_fields_ = [
|
||
('dwLength', wintypes.DWORD), ('dwMemoryLoad', wintypes.DWORD),
|
||
('ullTotalPhys', ctypes.c_uint64), ('ullAvailPhys', ctypes.c_uint64),
|
||
('ullTotalPageFile', ctypes.c_uint64), ('ullAvailPageFile', ctypes.c_uint64),
|
||
('ullTotalVirtual', ctypes.c_uint64), ('ullAvailVirtual', ctypes.c_uint64),
|
||
('sullAvailExtendedVirtual', ctypes.c_uint64),
|
||
]
|
||
stat = MEMORYSTATUSEX()
|
||
stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
|
||
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
|
||
return stat.ullAvailPhys / (1024 ** 3)
|
||
except Exception: pass
|
||
elif sys.platform == 'darwin':
|
||
try:
|
||
out = subprocess.check_output(["vm_stat"], text=True, errors="ignore")
|
||
pages_free = 0
|
||
pages_inactive = 0
|
||
page_size = 16384
|
||
for line in out.splitlines():
|
||
if "page size of" in line:
|
||
m = re.search(r'(\d+)', line)
|
||
if m: page_size = int(m.group(1))
|
||
elif "Pages free" in line:
|
||
m = re.search(r'(\d+)', line)
|
||
if m: pages_free = int(m.group(1))
|
||
elif "Pages inactive" in line:
|
||
m = re.search(r'(\d+)', line)
|
||
if m: pages_inactive = int(m.group(1))
|
||
return ((pages_free + pages_inactive) * page_size) / (1024 ** 3)
|
||
except Exception: pass
|
||
|
||
return 8.0
|
||
|
||
class AnimatedButton(QPushButton):
|
||
def __init__(self, text="", parent=None):
|
||
super().__init__(text, parent)
|
||
self.opacity_effect = QGraphicsOpacityEffect(self)
|
||
self.setGraphicsEffect(self.opacity_effect)
|
||
self.opacity_effect.setOpacity(1.0)
|
||
|
||
self.op_anim = QPropertyAnimation(self.opacity_effect, b"opacity")
|
||
self.op_anim.setDuration(200)
|
||
self.op_anim.setEasingCurve(QEasingCurve.Type.InOutCubic)
|
||
self.is_hovered = False
|
||
|
||
def enterEvent(self, event):
|
||
if not self.is_hovered and self.isEnabled():
|
||
self.is_hovered = True
|
||
self.op_anim.stop()
|
||
self.op_anim.setStartValue(self.opacity_effect.opacity())
|
||
self.op_anim.setEndValue(0.80)
|
||
self.op_anim.start()
|
||
super().enterEvent(event)
|
||
|
||
def leaveEvent(self, event):
|
||
if self.is_hovered and self.isEnabled():
|
||
self.is_hovered = False
|
||
self.op_anim.stop()
|
||
self.op_anim.setStartValue(self.opacity_effect.opacity())
|
||
self.op_anim.setEndValue(1.0)
|
||
self.op_anim.start()
|
||
super().leaveEvent(event)
|
||
|
||
class TargetIconWidget(QWidget):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setFixedSize(160, 160)
|
||
self.pixmap = None
|
||
self.base_pixmap = None
|
||
self.file_pixmap = None
|
||
self.current_size = 88
|
||
|
||
self.is_building = False
|
||
self.spin_angle = 0
|
||
self.pulse_value = 0
|
||
|
||
self.anim_timer = QTimer(self)
|
||
self.anim_timer.setInterval(16)
|
||
self.anim_timer.timeout.connect(self._update_frame)
|
||
|
||
self.success_effect = QGraphicsDropShadowEffect(self)
|
||
self.success_effect.setOffset(0, 0)
|
||
self.success_effect.setColor(QColor(0, 0, 0, 0))
|
||
self.success_effect.setBlurRadius(0)
|
||
self.setGraphicsEffect(self.success_effect)
|
||
|
||
self.burst_value = 0.0
|
||
self.burst_anim = QVariantAnimation(self)
|
||
self.burst_anim.setDuration(600)
|
||
self.burst_anim.setLoopCount(1)
|
||
self.burst_anim.setStartValue(0.0)
|
||
self.burst_anim.setEndValue(1.0)
|
||
self.burst_anim.setEasingCurve(QEasingCurve.Type.OutQuad)
|
||
self.burst_anim.valueChanged.connect(self._animate_burst)
|
||
|
||
self.shake_offset = 0
|
||
self.shake_anim = QVariantAnimation(self)
|
||
self.shake_anim.setDuration(500)
|
||
self.shake_anim.setStartValue(0)
|
||
self.shake_anim.setEndValue(0)
|
||
self.shake_anim.setKeyValueAt(0.0, 0)
|
||
self.shake_anim.setKeyValueAt(0.1, -12)
|
||
self.shake_anim.setKeyValueAt(0.3, 12)
|
||
self.shake_anim.setKeyValueAt(0.5, -12)
|
||
self.shake_anim.setKeyValueAt(0.7, 12)
|
||
self.shake_anim.setKeyValueAt(0.9, -6)
|
||
self.shake_anim.setKeyValueAt(1.0, 0)
|
||
self.shake_anim.valueChanged.connect(self._animate_shake)
|
||
|
||
def set_default_pixmap(self, pixmap, size=88):
|
||
self.base_pixmap = pixmap
|
||
self.pixmap = pixmap
|
||
self.current_size = size
|
||
self.update()
|
||
|
||
def set_custom_pixmap(self, pixmap, size=88):
|
||
self.pixmap = pixmap
|
||
self.current_size = size
|
||
self.update()
|
||
|
||
def set_file_pixmap(self, pixmap, size=88):
|
||
self.file_pixmap = pixmap
|
||
self.pixmap = pixmap
|
||
self.current_size = size
|
||
self.update()
|
||
|
||
def start_building(self):
|
||
if getattr(self, 'file_pixmap', None) and not self.file_pixmap.isNull():
|
||
self.pixmap = self.file_pixmap
|
||
self.current_size = 88
|
||
|
||
self.is_building = True
|
||
self.spin_angle = 0
|
||
self.pulse_value = 0
|
||
self.burst_value = 0.0
|
||
self.shake_offset = 0
|
||
self.success_effect.setColor(QColor(0, 0, 0, 0))
|
||
self.burst_anim.stop()
|
||
self.shake_anim.stop()
|
||
self.anim_timer.start()
|
||
|
||
def stop_building(self):
|
||
self.is_building = False
|
||
self.anim_timer.stop()
|
||
self.update()
|
||
|
||
def start_success(self):
|
||
self.stop_building()
|
||
self.success_effect.setBlurRadius(40)
|
||
self.success_effect.setColor(QColor(255, 193, 7, 180))
|
||
self.burst_anim.start()
|
||
|
||
def start_failure(self):
|
||
self.stop_building()
|
||
self.success_effect.setBlurRadius(40)
|
||
self.success_effect.setColor(QColor(217, 48, 37, 180))
|
||
self.shake_anim.start()
|
||
|
||
def reset(self):
|
||
self.stop_building()
|
||
self.burst_anim.stop()
|
||
self.shake_anim.stop()
|
||
self.burst_value = 0.0
|
||
self.shake_offset = 0
|
||
self.success_effect.setColor(QColor(0, 0, 0, 0))
|
||
self.file_pixmap = None
|
||
self.pixmap = self.base_pixmap
|
||
self.current_size = 88
|
||
self.update()
|
||
|
||
def _update_frame(self):
|
||
self.spin_angle = (self.spin_angle + 4) % 360
|
||
self.pulse_value += 0.05
|
||
self.update()
|
||
|
||
def _animate_burst(self, val):
|
||
self.burst_value = val
|
||
self.update()
|
||
|
||
def _animate_shake(self, val):
|
||
self.shake_offset = val
|
||
self.update()
|
||
|
||
def paintEvent(self, event):
|
||
if not self.pixmap or self.pixmap.isNull(): return
|
||
painter = QPainter(self)
|
||
try:
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||
|
||
center = self.rect().center()
|
||
center_x = center.x() + int(self.shake_offset)
|
||
icon_center_y = center.y()
|
||
draw_size = self.current_size
|
||
|
||
pop_scale = 1.0
|
||
if hasattr(self, 'pop_value') and self.pop_value > 0.0 and self.pop_value < 1.0:
|
||
pop_scale = 1.0 + math.sin(self.pop_value * math.pi) * 0.22
|
||
|
||
ripple_radius = (self.current_size / 2.0) + self.pop_value * 35.0
|
||
alpha = int(180 * (1.0 - self.pop_value))
|
||
pen = QPen(QColor(26, 115, 232, alpha))
|
||
pen.setWidth(2)
|
||
painter.setPen(pen)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
painter.drawEllipse(QPointF(center_x, icon_center_y), ripple_radius, ripple_radius)
|
||
|
||
draw_size = int(self.current_size * pop_scale)
|
||
|
||
if self.is_building:
|
||
radius = (self.current_size / 2) + 12
|
||
pen = QPen(QColor(26, 115, 232, 200))
|
||
pen.setWidth(4)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
painter.setPen(pen)
|
||
|
||
rect = QRectF(center_x - radius, center.y() - radius, radius * 2, radius * 2)
|
||
span_angle = int((140 + 60 * math.sin(self.pulse_value * 1.5)) * 16)
|
||
start_angle = int(-self.spin_angle * 16)
|
||
painter.drawArc(rect, start_angle, span_angle)
|
||
|
||
pix_rect = QRectF(center_x - draw_size / 2.0, icon_center_y - draw_size / 2.0, float(draw_size), float(draw_size))
|
||
scaled_pix = self.pixmap.scaled(
|
||
int(draw_size * self.devicePixelRatioF()),
|
||
int(draw_size * self.devicePixelRatioF()),
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation
|
||
)
|
||
scaled_pix.setDevicePixelRatio(self.devicePixelRatioF())
|
||
painter.drawPixmap(QPointF(pix_rect.x(), pix_rect.y()), scaled_pix)
|
||
finally:
|
||
painter.end()
|
||
|
||
def trigger_drop_pop(self):
|
||
self.pop_value = 0.0
|
||
self.pop_anim = QVariantAnimation(self)
|
||
self.pop_anim.setDuration(450)
|
||
self.pop_anim.setStartValue(0.0)
|
||
self.pop_anim.setEndValue(1.0)
|
||
self.pop_anim.setEasingCurve(QEasingCurve.Type.OutBack)
|
||
self.pop_anim.valueChanged.connect(self._animate_pop)
|
||
self.pop_anim.start()
|
||
|
||
def _animate_pop(self, val):
|
||
self.pop_value = val
|
||
self.update()
|
||
|
||
class DropArea(QFrame):
|
||
fileDropped = Signal(str)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setObjectName("DropArea")
|
||
self.setAcceptDrops(True)
|
||
self.setFrameStyle(QFrame.Shape.NoFrame)
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.current_filename = None
|
||
self.setStyleSheet("""
|
||
#DropArea { background-color: #f8f9fa; border: 2px dashed #dadce0; border-radius: 12px; }
|
||
#DropArea:hover { background-color: #f1f3f4; border: 2px dashed #bdc1c6; }
|
||
""")
|
||
self.init_ui()
|
||
I18N.language_changed.connect(self.retranslate_ui)
|
||
|
||
def _get_default_pixmap(self, size=88):
|
||
icon_path = get_resource_path("icon.ico")
|
||
if os.path.exists(icon_path):
|
||
pixmap = QIcon(icon_path).pixmap(256, 256)
|
||
if not pixmap.isNull(): return pixmap
|
||
return get_svg_pixmap('python', color="#9AA0A6", size=256)
|
||
|
||
def init_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(10, 20, 10, 20)
|
||
layout.setSpacing(0)
|
||
layout.addStretch(1)
|
||
|
||
self.icon_widget = TargetIconWidget(self)
|
||
self.icon_widget.set_default_pixmap(self._get_default_pixmap(88))
|
||
|
||
h_layout = QHBoxLayout()
|
||
h_layout.addStretch(1)
|
||
h_layout.addWidget(self.icon_widget)
|
||
h_layout.addStretch(1)
|
||
layout.addLayout(h_layout)
|
||
layout.addSpacing(18)
|
||
|
||
self.label = QLabel(_("Python Packaging, Reimagined."))
|
||
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.label.setWordWrap(True)
|
||
self.label.setStyleSheet("QLabel { background: transparent; color: #5F6368; font-size: 16px; font-weight: bold; border: none; }")
|
||
layout.addWidget(self.label)
|
||
|
||
layout.addSpacing(8)
|
||
|
||
self.sub_label = QLabel()
|
||
self.sub_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.sub_label.setWordWrap(True)
|
||
self.sub_label.setStyleSheet("QLabel { background: transparent; color: #9AA0A6; font-size: 13px; border: none; }")
|
||
layout.addWidget(self.sub_label)
|
||
self.chips_container = QWidget()
|
||
self.chips_layout = QHBoxLayout(self.chips_container)
|
||
self.chips_layout.setContentsMargins(0, 8, 0, 0)
|
||
self.chips_layout.setSpacing(8)
|
||
self.chips_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
layout.addWidget(self.chips_container)
|
||
layout.addStretch(1)
|
||
|
||
self.retranslate_ui()
|
||
|
||
def retranslate_ui(self):
|
||
if not self.current_filename:
|
||
self.label.setText(_("Python Packaging, Reimagined."))
|
||
self.sub_label.setText(_("Drop Python source code to start"))
|
||
|
||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
self.setStyleSheet("#DropArea { background-color: #E8F0FE; border: 2px dashed #1A73E8; border-radius: 12px; }")
|
||
|
||
def dragLeaveEvent(self, event):
|
||
self.setStyleSheet("#DropArea { background-color: #f8f9fa; border: 2px dashed #dadce0; border-radius: 12px; } #DropArea:hover { background-color: #f1f3f4; border: 2px dashed #bdc1c6; }")
|
||
|
||
def dropEvent(self, event: QDropEvent):
|
||
self.dragLeaveEvent(event)
|
||
urls = event.mimeData().urls()
|
||
if not urls: return
|
||
|
||
paths = [u.toLocalFile() for u in urls if u.toLocalFile()]
|
||
if not paths: return
|
||
|
||
py_files = [p for p in paths if p.lower().endswith(('.py', '.pyw'))]
|
||
|
||
if py_files:
|
||
self.fileDropped.emit(py_files[0])
|
||
else:
|
||
parent_win = self.window()
|
||
if hasattr(parent_win, 'on_main_resources_dropped'):
|
||
parent_win.on_main_resources_dropped(paths)
|
||
|
||
def mousePressEvent(self, event):
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
fp, _filter = QFileDialog.getOpenFileName(
|
||
self,
|
||
_("Drag & Drop Python script (.py/.pyw) here\nor Click to Browse"),
|
||
"",
|
||
"Python Scripts (*.py *.pyw);;All Files (*)",
|
||
options=QFileDialog.Option.DontUseNativeDialog
|
||
)
|
||
if fp: self.fileDropped.emit(fp)
|
||
|
||
def set_loading(self, filename):
|
||
self.current_filename = filename
|
||
pixmap = get_svg_pixmap('package', color="#1A73E8", size=88)
|
||
self.icon_widget.set_file_pixmap(pixmap, 88)
|
||
self.label.setText(_("Loaded: {filename}", filename=filename))
|
||
self.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
|
||
self.sub_label.setText(_("Parsing metadata..."))
|
||
|
||
def set_success(self, filename, custom_icon_path=None):
|
||
self.current_filename = filename
|
||
pixmap = None
|
||
if custom_icon_path and Path(custom_icon_path).exists():
|
||
pixmap = QIcon(str(custom_icon_path)).pixmap(256, 256)
|
||
if pixmap.isNull(): pixmap = None
|
||
|
||
if not pixmap:
|
||
pixmap = get_svg_pixmap('package', color="#1A73E8", size=256)
|
||
|
||
self.icon_widget.set_file_pixmap(pixmap, 88)
|
||
self.label.setText(_("Loaded: {filename}", filename=filename))
|
||
self.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
|
||
self.sub_label.setText(_("Ready, waiting for build."))
|
||
|
||
def start_build_anim(self):
|
||
self.label.setText(_("Initializing build..."))
|
||
self.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
|
||
self.sub_label.setText(_("Preparing engine..."))
|
||
self.icon_widget.start_building()
|
||
|
||
def stop_build_anim(self):
|
||
self.icon_widget.stop_building()
|
||
|
||
def show_success(self, custom_icon_path=None):
|
||
size = 128
|
||
pixmap_set = False
|
||
if custom_icon_path and Path(custom_icon_path).exists():
|
||
pix = QIcon(str(custom_icon_path)).pixmap(256, 256)
|
||
if not pix.isNull():
|
||
self.icon_widget.set_custom_pixmap(pix, size)
|
||
pixmap_set = True
|
||
|
||
if not pixmap_set:
|
||
if self.icon_widget.base_pixmap and not self.icon_widget.base_pixmap.isNull():
|
||
self.icon_widget.set_custom_pixmap(self.icon_widget.base_pixmap, size)
|
||
else:
|
||
self.icon_widget.set_custom_pixmap(get_svg_pixmap('check', color="#1E8E3E", size=size), size)
|
||
|
||
self.icon_widget.start_success()
|
||
self.label.setText(_("Build Successful"))
|
||
self.label.setStyleSheet("QLabel { background: transparent; color: #1E8E3E; font-size: 20px; font-weight: bold; border: none; }")
|
||
self.sub_label.setText(_("Open output directory or reset workspace."))
|
||
|
||
def show_failure(self):
|
||
size = 128
|
||
self.icon_widget.set_custom_pixmap(get_svg_pixmap('close', color="#D93025", size=size), size)
|
||
self.icon_widget.start_failure()
|
||
|
||
self.label.setText(_("Build Failed"))
|
||
self.label.setStyleSheet("QLabel { background: transparent; color: #D93025; font-size: 20px; font-weight: bold; border: none; }")
|
||
self.sub_label.setText(_("Check log output below for troubleshooting."))
|
||
|
||
def reset(self):
|
||
self.current_filename = None
|
||
self.icon_widget.reset()
|
||
self.retranslate_ui()
|
||
self.label.setStyleSheet("QLabel { background: transparent; color: #5F6368; font-size: 16px; font-weight: bold; border: none; }")
|
||
|
||
def render_asset_chips(self, script_path=None, add_data_list=None):
|
||
while self.chips_layout.count():
|
||
item = self.chips_layout.takeAt(0)
|
||
if item.widget():
|
||
item.widget().deleteLater()
|
||
|
||
if not script_path and not add_data_list:
|
||
return
|
||
|
||
chips = []
|
||
if script_path:
|
||
chips.append(("🐍 " + Path(script_path).name, "#eff6ff", "#1d4ed8", "#bfdbfe"))
|
||
|
||
if add_data_list:
|
||
for item in add_data_list[:3]:
|
||
r_type, src, dst = item
|
||
p = Path(src)
|
||
if r_type == 'dir':
|
||
chips.append(("📁 " + p.name, "#f0fdf4", "#15803d", "#bbf7d0"))
|
||
elif p.suffix.lower() in ('.ico', '.png', '.jpg', '.jpeg', '.svg'):
|
||
chips.append(("🖼️ " + p.name, "#fef3c7", "#b45309", "#fde68a"))
|
||
else:
|
||
chips.append(("📄 " + p.name, "#f3f4f6", "#374151", "#e5e7eb"))
|
||
|
||
if len(add_data_list) > 3:
|
||
chips.append((f"+{len(add_data_list) - 3} ...", "#f1f5f9", "#475569", "#cbd5e1"))
|
||
|
||
for text, bg, color, border in chips:
|
||
chip = QLabel(text)
|
||
chip.setStyleSheet(f"""
|
||
QLabel {{
|
||
background-color: {bg};
|
||
color: {color};
|
||
border: 1px solid {border};
|
||
border-radius: 12px;
|
||
padding: 3px 10px;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
font-family: Consolas, "Segoe UI", sans-serif;
|
||
}}
|
||
""")
|
||
self.chips_layout.addWidget(chip)
|
||
|
||
class PythonScannerThread(QThread):
|
||
scan_done = Signal(dict)
|
||
|
||
def run(self):
|
||
candidates = set()
|
||
for name in ('python', 'python3', 'pythonw'):
|
||
p = shutil.which(name)
|
||
if p: candidates.add(os.path.normpath(p))
|
||
|
||
if winreg is not None:
|
||
try:
|
||
for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):
|
||
try:
|
||
with winreg.OpenKey(hive, r"SOFTWARE\Python\PythonCore") as core_key:
|
||
for i in range(winreg.QueryInfoKey(core_key)[0]):
|
||
sub = winreg.EnumKey(core_key, i)
|
||
try:
|
||
with winreg.OpenKey(core_key, rf"{sub}\InstallPath") as pkey:
|
||
path = winreg.QueryValueEx(pkey, "")[0]
|
||
exe = os.path.join(path, "python.exe")
|
||
if os.path.exists(exe): candidates.add(os.path.normpath(exe))
|
||
except: pass
|
||
except: pass
|
||
except: pass
|
||
|
||
search_paths = [
|
||
os.environ.get("LOCALAPPDATA", "") + r"\Programs\Python",
|
||
os.environ.get("PROGRAMFILES", "") + r"\Python",
|
||
os.environ.get("PROGRAMFILES(X86)", "") + r"\Python",
|
||
r"C:\Python", r"C:\Program Files\Python", r"C:\Program Files (x86)\Python"
|
||
]
|
||
for base in search_paths:
|
||
if base and os.path.exists(base):
|
||
try:
|
||
for d in os.listdir(base):
|
||
if d.lower().startswith("python"):
|
||
exe = os.path.join(base, d, "python.exe")
|
||
if os.path.exists(exe): candidates.add(os.path.normpath(exe))
|
||
except: pass
|
||
|
||
user_profile = os.environ.get("USERPROFILE", "")
|
||
if user_profile:
|
||
for c_dir in ("miniconda3", "anaconda3", "Miniconda3", "Anaconda3", ".conda"):
|
||
for base in (user_profile, "C:\\", "D:\\"):
|
||
base_dir = os.path.join(base, c_dir)
|
||
if os.path.exists(base_dir):
|
||
exe = os.path.join(base_dir, "python.exe")
|
||
if os.path.exists(exe): candidates.add(os.path.normpath(exe))
|
||
envs_dir = os.path.join(base_dir, "envs")
|
||
if os.path.exists(envs_dir):
|
||
try:
|
||
for env in os.listdir(envs_dir):
|
||
exe = os.path.join(envs_dir, env, "python.exe")
|
||
if os.path.exists(exe): candidates.add(os.path.normpath(exe))
|
||
except: pass
|
||
else:
|
||
unix_bases = [
|
||
"/usr/bin", "/usr/local/bin", "/opt/homebrew/bin",
|
||
os.path.expanduser("~/.pyenv/shims"), os.path.expanduser("~/.local/bin")
|
||
]
|
||
user_profile = os.environ.get("HOME", "") or os.environ.get("USERPROFILE", "")
|
||
if user_profile:
|
||
for c_dir in ("miniconda3", "anaconda3", "mambaforge", ".conda"):
|
||
base_dir = os.path.join(user_profile, c_dir)
|
||
if os.path.exists(base_dir):
|
||
exe = os.path.join(base_dir, "bin", "python")
|
||
if os.path.exists(exe): unix_bases.append(os.path.join(base_dir, "bin"))
|
||
envs_dir = os.path.join(base_dir, "envs")
|
||
if os.path.exists(envs_dir):
|
||
try:
|
||
for env in os.listdir(envs_dir):
|
||
p_bin = os.path.join(envs_dir, env, "bin")
|
||
if os.path.exists(p_bin): unix_bases.append(p_bin)
|
||
except: pass
|
||
|
||
for base in unix_bases:
|
||
if os.path.exists(base):
|
||
try:
|
||
for f in os.listdir(base):
|
||
if f.startswith("python3") or f == "python":
|
||
exe = os.path.join(base, f)
|
||
if os.path.isfile(exe) and os.access(exe, os.X_OK):
|
||
candidates.add(os.path.normpath(exe))
|
||
except: pass
|
||
|
||
resolved_candidates = set()
|
||
for cand in candidates:
|
||
try:
|
||
resolved_path = str(Path(cand).resolve())
|
||
if resolved_path.startswith("\\\\?\\UNC\\"):
|
||
resolved_path = "\\\\" + resolved_path[8:]
|
||
elif resolved_path.startswith("\\\\?\\"):
|
||
resolved_path = resolved_path[4:]
|
||
resolved_candidates.add(resolved_path)
|
||
except:
|
||
resolved_candidates.add(os.path.normpath(cand))
|
||
|
||
valid_pythons = {}
|
||
|
||
def check_candidate(cand):
|
||
if getattr(sys, 'frozen', False) or '__compiled__' in globals():
|
||
try:
|
||
if os.path.samefile(cand, sys.executable):
|
||
return None
|
||
except Exception:
|
||
pass
|
||
|
||
if os.name == 'nt' and "WindowsApps" in cand:
|
||
try:
|
||
if os.path.getsize(cand) == 0: return None
|
||
except: return None
|
||
try:
|
||
clean_env = os.environ.copy()
|
||
clean_env.pop("PYTHONHOME", None)
|
||
clean_env.pop("PYTHONPATH", None)
|
||
clean_env["PYTHONUTF8"] = "1"
|
||
clean_env["PYTHONIOENCODING"] = "utf-8"
|
||
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "encoding": "utf-8", "errors": "ignore", "env": clean_env, "timeout": 2}
|
||
if os.name == 'nt': kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
proc = subprocess.run([cand, "-c", "import sys; print(sys.version.split()[0])"], **kwargs)
|
||
if proc.returncode == 0:
|
||
return (cand, proc.stdout.strip())
|
||
except: pass
|
||
return None
|
||
|
||
with ThreadPoolExecutor(max_workers=min(12, len(resolved_candidates) or 1)) as executor:
|
||
results = executor.map(check_candidate, resolved_candidates)
|
||
for res in results:
|
||
if res:
|
||
valid_pythons[res[0]] = res[1]
|
||
|
||
self.scan_done.emit(valid_pythons)
|
||
|
||
class VenvCleanerTaskThread(QThread):
|
||
scan_done = Signal(list, int)
|
||
delete_done = Signal(int, float)
|
||
|
||
def __init__(self, mode, target_dir=None, venvs_to_delete=None):
|
||
super().__init__()
|
||
self.mode = mode
|
||
self.target_dir = Path(target_dir) if target_dir else None
|
||
self.venvs_to_delete = venvs_to_delete or []
|
||
|
||
def run(self):
|
||
if self.mode == "scan":
|
||
venvs = []
|
||
freed_bytes = 0
|
||
if self.target_dir and self.target_dir.exists():
|
||
try:
|
||
for p in self.target_dir.iterdir():
|
||
if p.is_dir() and p.name.startswith(".qpypack_venv"):
|
||
venvs.append(p)
|
||
except Exception: pass
|
||
|
||
for venv in venvs:
|
||
try:
|
||
for f in venv.rglob('*'):
|
||
if f.is_file():
|
||
freed_bytes += f.stat().st_size
|
||
except Exception: pass
|
||
|
||
self.scan_done.emit(venvs, freed_bytes)
|
||
|
||
elif self.mode == "delete":
|
||
success_count = 0
|
||
total_bytes = 0
|
||
for venv in self.venvs_to_delete:
|
||
v_bytes = 0
|
||
try:
|
||
for f in venv.rglob('*'):
|
||
if f.is_file(): v_bytes += f.stat().st_size
|
||
except Exception: pass
|
||
|
||
if robust_rmtree(venv):
|
||
success_count += 1
|
||
total_bytes += v_bytes
|
||
|
||
self.delete_done.emit(success_count, total_bytes / (1024 * 1024))
|
||
|
||
class SettingsPanel(QWidget):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.parent_win = parent
|
||
self.upx_check = None
|
||
self.upx_path_container = None
|
||
self.out_dir_container = None
|
||
|
||
self.setStyleSheet("""
|
||
SettingsPanel { background-color: #f9fafb; }
|
||
QLabel { color: #111827; font-size: 13px; font-weight: 600; background: transparent; }
|
||
QLineEdit, QSpinBox { color: #111827; font-size: 12px; padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: #ffffff; min-height: 22px; }
|
||
QLineEdit:hover, QSpinBox:hover { border-color: #9ca3af; }
|
||
QLineEdit:focus, QSpinBox:focus { border: 1px solid #2563eb; background: #ffffff; }
|
||
QCheckBox { font-size: 13px; color: #1f2937; spacing: 8px; background: transparent; }
|
||
QCheckBox::indicator { width: 16px; height: 16px; border: 1px solid #d1d5db; border-radius: 4px; background: #ffffff; }
|
||
QCheckBox::indicator:hover { border-color: #2563eb; }
|
||
QCheckBox::indicator:checked { background: #2563eb; border-color: #2563eb; }
|
||
QRadioButton { font-size: 13px; color: #1f2937; spacing: 8px; background: transparent; }
|
||
QRadioButton::indicator { width: 16px; height: 16px; border: 1px solid #d1d5db; border-radius: 8px; background: #ffffff; }
|
||
QRadioButton::indicator:hover { border-color: #2563eb; }
|
||
QRadioButton::indicator:checked { background: #2563eb; border-color: #2563eb; }
|
||
QFrame#SettingCard { background-color: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; }
|
||
QFrame#SettingCard:hover { border-color: #cbd5e1; }
|
||
QPushButton.ToolBtn { background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 6px; padding: 6px 12px; color: #374151; font-weight: 600; font-size: 12px; min-width: 68px; }
|
||
QPushButton.ToolBtn:hover { background: #e5e7eb; color: #111827; border-color: #d1d5db; }
|
||
QPushButton.ToolBtn:pressed { background: #d1d5db; }
|
||
QTabWidget#MainTabWidget::pane { border: none; background: transparent; }
|
||
QTabWidget#MainTabWidget::tab-bar { alignment: center; }
|
||
QTabBar#MainTabBar::tab { background: transparent; border: none; padding: 10px 24px; color: #6b7280; font-weight: 600; font-size: 14px; border-bottom: 2px solid transparent; margin: 0 16px; }
|
||
QTabBar#MainTabBar::tab:selected { color: #2563eb; font-weight: 700; border-bottom: 2px solid #2563eb; }
|
||
QTabBar#MainTabBar::tab:hover:!selected { color: #111827; }
|
||
QTabWidget#SubTabWidget::pane { border: none; background: transparent; }
|
||
QTabWidget#SubTabWidget::tab-bar { alignment: center; }
|
||
QTabBar#SubTabBar::tab { background: #f3f4f6; border: 1px solid #e5e7eb; padding: 6px 16px; color: #4b5563; font-weight: 600; font-size: 12px; margin: 0 4px 10px 4px; border-radius: 6px; }
|
||
QTabBar#SubTabBar::tab:selected { background: #eff6ff; color: #2563eb; border: 1px solid #bfdbfe; font-weight: 700; }
|
||
QTabBar#SubTabBar::tab:hover:!selected { background: #e5e7eb; color: #111827; }
|
||
QListWidget, QTableWidget { border: 1px solid #e5e7eb; border-radius: 6px; background-color: #ffffff; outline: none; font-size: 12px; gridline-color: #f3f4f6; }
|
||
QListWidget::item { padding: 6px 10px; border-bottom: 1px solid #f3f4f6; color: #1f2937; }
|
||
QListWidget::item:selected { background-color: #eff6ff; color: #2563eb; font-weight: 600; }
|
||
QTableWidget::item { padding: 4px 8px; color: #1f2937; border-bottom: 1px solid #f3f4f6; background-color: #ffffff; }
|
||
QTableWidget::item:selected { background-color: #eff6ff; color: #2563eb; font-weight: 600; }
|
||
QHeaderView { background-color: #f8fafc; }
|
||
QHeaderView::section { background-color: #f8fafc; color: #475569; font-size: 12px; font-weight: 700; padding: 6px 10px; border: none; border-bottom: 1px solid #e2e8f0; border-right: 1px solid #f1f5f9; }
|
||
QHeaderView::section:horizontal { border-top: none; }
|
||
QScrollBar:vertical { border: none; background: transparent; width: 6px; margin: 0px; }
|
||
QScrollBar::handle:vertical { background: #d1d5db; min-height: 20px; border-radius: 3px; }
|
||
QScrollBar::handle:vertical:hover { background: #9ca3af; }
|
||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; }
|
||
""")
|
||
self.init_ui()
|
||
I18N.language_changed.connect(self.retranslate_ui)
|
||
self.load_from_config()
|
||
self.retranslate_ui()
|
||
if hasattr(self, 'btn_clean_venvs'):
|
||
self.btn_clean_venvs.setText(_("Clear Local Venvs"))
|
||
self.scanner_thread = PythonScannerThread()
|
||
self.scanner_thread.scan_done.connect(self.populate_python_combo)
|
||
self.scanner_thread.start()
|
||
|
||
def _create_card(self, title_text=""):
|
||
card = QFrame()
|
||
card.setObjectName("SettingCard")
|
||
lay = QVBoxLayout(card)
|
||
lay.setContentsMargins(20, 20, 20, 20)
|
||
lay.setSpacing(12)
|
||
if title_text:
|
||
lbl = QLabel(title_text)
|
||
lbl.setObjectName("CardTitle")
|
||
lbl.setStyleSheet("font-size: 15px; font-weight: 800; color: #1a73e8; margin-bottom: 6px;")
|
||
lay.addWidget(lbl)
|
||
return card, lay
|
||
|
||
def _create_scroll_tab(self):
|
||
scroll = QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
scroll.setStyleSheet("QScrollArea { background: transparent; }")
|
||
|
||
content = QWidget()
|
||
content.setObjectName("ScrollContent")
|
||
content.setStyleSheet("QWidget#ScrollContent { background: transparent; }")
|
||
|
||
lay = QVBoxLayout(content)
|
||
lay.setContentsMargins(0, 5, 0, 15)
|
||
lay.setSpacing(15)
|
||
|
||
scroll.setWidget(content)
|
||
return scroll, content, lay
|
||
|
||
def on_mode_type_changed(self):
|
||
is_folder = self.rb_onedir.isChecked()
|
||
self.contents_dir_container.setVisible(is_folder)
|
||
|
||
def _get_url_from_combo(self, combo):
|
||
idx = combo.currentIndex()
|
||
if idx >= 0 and combo.currentText() == combo.itemText(idx):
|
||
data = combo.itemData(idx)
|
||
if data: return data
|
||
text = combo.currentText().strip()
|
||
m = re.search(r'https?://[^\s]+', text)
|
||
if m:
|
||
return m.group(0).rstrip('/')
|
||
if not text.startswith(('http://', 'https://')):
|
||
return ''
|
||
return text
|
||
|
||
def _set_combo_value(self, combo, url_val):
|
||
url_val = (url_val or '').strip()
|
||
if not url_val: return
|
||
for i in range(combo.count()):
|
||
data = combo.itemData(i)
|
||
if data and data.rstrip('/') == url_val.rstrip('/'):
|
||
combo.setCurrentIndex(i)
|
||
return
|
||
combo.setCurrentText(url_val)
|
||
|
||
def init_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
layout.setSpacing(15)
|
||
layout.setContentsMargins(20, 10, 20, 20)
|
||
|
||
self.tabs = QTabWidget()
|
||
self.tabs.setObjectName("MainTabWidget")
|
||
self.tabs.tabBar().setObjectName("MainTabBar")
|
||
self.tabs.tabBar().setExpanding(False)
|
||
|
||
self.tab_build = QWidget()
|
||
self.tab_pref_scroll, _cnt_pref, self.lay_pref = self._create_scroll_tab()
|
||
self.tab_about_scroll, _cnt_abt, self.lay_about = self._create_scroll_tab()
|
||
|
||
self.tabs.addTab(self.tab_build, get_svg_icon('package', "#5F6368", 16), _("Build Settings"))
|
||
self.tabs.addTab(self.tab_pref_scroll, get_svg_icon('settings', "#5F6368", 16), _("Preferences"))
|
||
self.tabs.addTab(self.tab_about_scroll, get_svg_icon('info', "#5F6368", 16), _("About"))
|
||
|
||
|
||
self.build_build_master_tab()
|
||
self.build_pref_tab()
|
||
self.build_about_tab()
|
||
layout.addWidget(self.tabs)
|
||
|
||
btn_lay = QHBoxLayout()
|
||
btn_lay.setContentsMargins(0, 5, 0, 0)
|
||
btn_lay.setSpacing(12)
|
||
|
||
self.btn_reset = AnimatedButton("")
|
||
self.btn_reset.setFixedSize(44, 44)
|
||
self.btn_reset.setIcon(get_svg_icon('refresh', "#5F6368"))
|
||
self.btn_reset.setToolTip(_("Reset to Default Config"))
|
||
self.btn_reset.setStyleSheet(self.parent_win.icon_btn_style)
|
||
self.btn_reset.clicked.connect(self.parent_win.reset_all)
|
||
btn_lay.addWidget(self.btn_reset)
|
||
|
||
self.btn_save = AnimatedButton(_("Save & Return"))
|
||
self.btn_save.setFixedHeight(44)
|
||
self.btn_save.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
self.btn_save.setIcon(get_svg_icon('check', "white"))
|
||
self.btn_save.setStyleSheet(self.parent_win.primary_btn_style)
|
||
self.btn_save.clicked.connect(self.parent_win.save_settings_and_return)
|
||
btn_lay.addWidget(self.btn_save)
|
||
|
||
self.btn_back = AnimatedButton("")
|
||
self.btn_back.setFixedSize(44, 44)
|
||
self.btn_back.setIcon(get_svg_icon('back', "#5F6368"))
|
||
self.btn_back.setToolTip(_("Cancel & Return"))
|
||
self.btn_back.setStyleSheet(self.parent_win.icon_btn_style)
|
||
self.btn_back.clicked.connect(self.parent_win.show_main)
|
||
btn_lay.addWidget(self.btn_back)
|
||
|
||
layout.addLayout(btn_lay)
|
||
|
||
def build_build_master_tab(self):
|
||
main_lay = QVBoxLayout(self.tab_build)
|
||
main_lay.setContentsMargins(0, 10, 0, 0)
|
||
|
||
self.sub_tabs = QTabWidget()
|
||
self.sub_tabs.setObjectName("SubTabWidget")
|
||
self.sub_tabs.tabBar().setObjectName("SubTabBar")
|
||
self.sub_tabs.tabBar().setExpanding(False)
|
||
|
||
sub_scroll1, _cnt1, lay_sub1 = self._create_scroll_tab()
|
||
sub_scroll2, _cnt2, lay_sub2 = self._create_scroll_tab()
|
||
sub_scroll3, _cnt3, lay_sub3 = self._create_scroll_tab()
|
||
sub_scroll4, _cnt4, lay_sub4 = self._create_scroll_tab()
|
||
sub_scroll5, _cnt5, lay_sub5 = self._create_scroll_tab()
|
||
|
||
self.sub_tabs.addTab(sub_scroll1, get_svg_icon('engine', "#5F6368", 16), _("Engine"))
|
||
self.sub_tabs.addTab(sub_scroll2, get_svg_icon('package', "#5F6368", 16), _("Dependencies"))
|
||
self.sub_tabs.addTab(sub_scroll3, get_svg_icon('folder', "#5F6368", 16), _("Resources"))
|
||
self.sub_tabs.addTab(sub_scroll4, get_svg_icon('bolt', "#5F6368", 16), _("Optimization & Security"))
|
||
self.sub_tabs.addTab(sub_scroll5, get_svg_icon('link', "#5F6368", 16), _("Package Map"))
|
||
|
||
self.card_engine, c_lay_engine = self._create_card(_("Engine & Environment"))
|
||
self.form_engine = QFormLayout()
|
||
self.form_engine.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||
self.form_engine.setSpacing(15)
|
||
|
||
self.engine_combo = QComboBox()
|
||
self.engine_combo.addItems(["PyInstaller", "Nuitka"])
|
||
setup_combo_white_theme(self.engine_combo)
|
||
self.engine_combo.currentIndexChanged.connect(self.on_engine_changed)
|
||
|
||
self.engine_desc_lbl = QLabel()
|
||
self.engine_desc_lbl.setWordWrap(True)
|
||
self.engine_desc_lbl.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||
self.engine_desc_lbl.setStyleSheet("""
|
||
QLabel {
|
||
background-color: #f8fafc;
|
||
color: #334155;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 6px;
|
||
padding: 8px 12px;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
""")
|
||
|
||
self.python_desc_lbl = QLabel()
|
||
self.python_desc_lbl.setOpenExternalLinks(True)
|
||
self.python_desc_lbl.setWordWrap(True)
|
||
self.python_desc_lbl.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||
self.python_desc_lbl.setStyleSheet("""
|
||
QLabel {
|
||
background-color: #ffffff;
|
||
color: #334155;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
padding: 8px 14px;
|
||
font-size: 12px;
|
||
}
|
||
""")
|
||
|
||
engine_cont = QWidget()
|
||
lay_eng = QVBoxLayout(engine_cont)
|
||
lay_eng.setContentsMargins(0, 0, 0, 0)
|
||
lay_eng.setSpacing(4)
|
||
lay_eng.addWidget(self.engine_combo)
|
||
lay_eng.addWidget(self.engine_desc_lbl)
|
||
|
||
self.python_path_combo = QComboBox()
|
||
self.python_path_combo.setEditable(True)
|
||
self.python_path_combo.setPlaceholderText(_("Leave blank to auto-detect system default Python"))
|
||
setup_combo_white_theme(self.python_path_combo, min_view_width=520)
|
||
self.python_path_combo.currentTextChanged.connect(self.on_python_path_changed)
|
||
|
||
self.btn_python_path = QPushButton(_("Browse"))
|
||
self.btn_python_path.setProperty("class", "ToolBtn")
|
||
self.btn_python_path.clicked.connect(self.select_python_path)
|
||
|
||
self.btn_download_py = QPushButton(_("View Python"))
|
||
self.btn_download_py.setProperty("class", "ToolBtn")
|
||
self.btn_download_py.setToolTip("")
|
||
self.btn_download_py.clicked.connect(self.on_download_python_clicked)
|
||
|
||
py_cont = QWidget()
|
||
lay_py = QVBoxLayout(py_cont)
|
||
lay_py.setContentsMargins(0, 0, 0, 0)
|
||
lay_py.setSpacing(4)
|
||
|
||
h_py_input = QHBoxLayout()
|
||
h_py_input.setContentsMargins(0, 0, 0, 0)
|
||
h_py_input.addWidget(self.python_path_combo, 1)
|
||
h_py_input.addWidget(self.btn_python_path)
|
||
h_py_input.addWidget(self.btn_download_py)
|
||
|
||
lay_py.addLayout(h_py_input)
|
||
lay_py.addWidget(self.python_desc_lbl)
|
||
|
||
self.name_edit = QLineEdit()
|
||
self.name_edit.setPlaceholderText(_("Leave blank to auto-match script name"))
|
||
|
||
self.icon_edit = QLineEdit()
|
||
|
||
self.icon_preview = QLabel()
|
||
self.icon_preview.setFixedSize(24, 24)
|
||
self.icon_preview.setScaledContents(True)
|
||
self.icon_edit.textChanged.connect(self.update_icon_preview)
|
||
|
||
self.btn_icon = QPushButton(_("Browse"))
|
||
self.btn_icon.setProperty("class", "ToolBtn")
|
||
self.btn_icon.clicked.connect(self.select_icon)
|
||
|
||
icon_cont = QWidget()
|
||
h_icon = QHBoxLayout(icon_cont)
|
||
h_icon.setContentsMargins(0,0,0,0)
|
||
h_icon.addWidget(self.icon_edit, 1)
|
||
h_icon.addWidget(self.icon_preview)
|
||
h_icon.addWidget(self.btn_icon)
|
||
|
||
self.rb_compat_mode = QRadioButton(_("Compatibility Mode (Default)"))
|
||
self.rb_lite_mode = QRadioButton(_("Lite Mode (Smaller size)"))
|
||
self.rb_compat_mode.setChecked(True)
|
||
|
||
pack_mode_cont = QWidget()
|
||
h_pack_mode = QHBoxLayout(pack_mode_cont)
|
||
h_pack_mode.setContentsMargins(0, 0, 0, 0)
|
||
h_pack_mode.addWidget(self.rb_compat_mode)
|
||
h_pack_mode.addWidget(self.rb_lite_mode)
|
||
h_pack_mode.addStretch()
|
||
|
||
self.lbl_eng_title = QLabel(_("Build Engine:"))
|
||
self.lbl_pack_mode_title = QLabel(_("Packaging Mode:"))
|
||
self.lbl_py_title = QLabel(_("Interpreter:"))
|
||
self.lbl_app_title = QLabel(_("Output Name:"))
|
||
self.lbl_icon_title = QLabel(_("App Icon:"))
|
||
|
||
self.form_engine.addRow(self.lbl_eng_title, engine_cont)
|
||
self.form_engine.addRow(self.lbl_pack_mode_title, pack_mode_cont)
|
||
self.form_engine.addRow(self.lbl_py_title, py_cont)
|
||
self.form_engine.addRow(self.lbl_app_title, self.name_edit)
|
||
self.form_engine.addRow(self.lbl_icon_title, icon_cont)
|
||
c_lay_engine.addLayout(self.form_engine)
|
||
|
||
self.card_mode, c_lay_mode = self._create_card(_("Execution Mode"))
|
||
v_mode = QVBoxLayout()
|
||
h_radio = QHBoxLayout()
|
||
h_radio.setSpacing(10)
|
||
|
||
self.rb_onefile = QRadioButton(_("One-File Mode (--onefile)"))
|
||
self.rb_onedir = QRadioButton(_("Folder Mode (--onedir)"))
|
||
self.noconsole_check = QCheckBox(_("Hide Console (--noconsole)"))
|
||
|
||
h_radio.addWidget(self.rb_onefile)
|
||
h_radio.addWidget(self.rb_onedir)
|
||
h_radio.addStretch()
|
||
h_radio.addWidget(self.noconsole_check)
|
||
|
||
self.lbl_contents_dir = QLabel(_("Contents Directory (--contents-directory):"))
|
||
self.contents_dir_edit = QLineEdit("_internal")
|
||
self.contents_dir_edit.setPlaceholderText(_("Internal directory name for dependencies (default: _internal)"))
|
||
|
||
self.contents_dir_container = QWidget()
|
||
h_contents = QHBoxLayout(self.contents_dir_container)
|
||
h_contents.setContentsMargins(0, 5, 0, 0)
|
||
h_contents.addWidget(self.lbl_contents_dir)
|
||
h_contents.addWidget(self.contents_dir_edit, 1)
|
||
self.contents_dir_container.setVisible(False)
|
||
|
||
self.rb_onefile.toggled.connect(self.on_mode_type_changed)
|
||
self.rb_onedir.toggled.connect(self.on_mode_type_changed)
|
||
|
||
v_mode.addLayout(h_radio)
|
||
v_mode.addWidget(self.contents_dir_container)
|
||
c_lay_mode.addLayout(v_mode)
|
||
|
||
lay_sub1.addWidget(self.card_engine)
|
||
lay_sub1.addWidget(self.card_mode)
|
||
lay_sub1.addStretch()
|
||
|
||
self.card_deps, c_lay_deps = self._create_card(_("Mirrors & Scanner"))
|
||
self.form_deps = QFormLayout()
|
||
self.form_deps.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||
self.form_deps.setSpacing(15)
|
||
|
||
self.pip_source_combo = QComboBox()
|
||
self.pip_source_combo.setEditable(True)
|
||
setup_combo_white_theme(self.pip_source_combo, min_view_width=520)
|
||
|
||
self.pip_backup_combo = QComboBox()
|
||
self.pip_backup_combo.setEditable(True)
|
||
setup_combo_white_theme(self.pip_backup_combo, min_view_width=520)
|
||
|
||
for name, url in PYPI_MIRRORS_GLOBAL:
|
||
display_text = f"{_(name)}: {url}"
|
||
self.pip_source_combo.addItem(display_text, url)
|
||
self.pip_backup_combo.addItem(display_text, url)
|
||
|
||
self.pip_source_combo.currentTextChanged.connect(self._check_pip_mirrors)
|
||
self.pip_backup_combo.currentTextChanged.connect(self._check_pip_mirrors)
|
||
|
||
self.reqs_file_edit = QLineEdit()
|
||
self.reqs_file_edit.setPlaceholderText(_("Leave blank to auto-search requirements.txt in current directory"))
|
||
self.btn_reqs = QPushButton(_("Browse"))
|
||
self.btn_reqs.setProperty("class", "ToolBtn")
|
||
self.btn_reqs.clicked.connect(self.select_reqs_file)
|
||
reqs_cont = QWidget()
|
||
h_reqs = QHBoxLayout(reqs_cont)
|
||
h_reqs.setContentsMargins(0,0,0,0)
|
||
h_reqs.addWidget(self.reqs_file_edit, 1)
|
||
h_reqs.addWidget(self.btn_reqs)
|
||
|
||
self.hidden_edit = QLineEdit()
|
||
self.hidden_edit.setPlaceholderText(_("Comma separated (e.g. pandas, PyQt5)"))
|
||
self.btn_scan = QPushButton(_("AST Scan"))
|
||
self.btn_scan.setProperty("class", "ToolBtn")
|
||
self.btn_scan.clicked.connect(self.auto_scan_hidden)
|
||
|
||
hid_cont = QWidget()
|
||
h_hid = QHBoxLayout(hid_cont)
|
||
h_hid.setContentsMargins(0,0,0,0)
|
||
h_hid.addWidget(self.hidden_edit, 1)
|
||
h_hid.addWidget(self.btn_scan)
|
||
|
||
self.exclude_edit = QLineEdit()
|
||
self.exclude_edit.setPlaceholderText(_("Comma separated (e.g. tkinter, matplotlib)"))
|
||
|
||
self.lbl_pip_main = QLabel(_("Primary PIP Index:"))
|
||
self.lbl_pip_backup = QLabel(_("Backup PIP Index:"))
|
||
self.lbl_reqs = QLabel(_("Requirements File:"))
|
||
self.lbl_hidden = QLabel(_("Hidden Imports:"))
|
||
self.lbl_exclude = QLabel(_("Exclude Modules:"))
|
||
|
||
self.form_deps.addRow(self.lbl_pip_main, self.pip_source_combo)
|
||
self.form_deps.addRow(self.lbl_pip_backup, self.pip_backup_combo)
|
||
self.form_deps.addRow(self.lbl_reqs, reqs_cont)
|
||
self.form_deps.addRow(self.lbl_hidden, hid_cont)
|
||
self.form_deps.addRow(self.lbl_exclude, self.exclude_edit)
|
||
c_lay_deps.addLayout(self.form_deps)
|
||
|
||
c_lay_deps.addSpacing(5)
|
||
g_dep = QGridLayout()
|
||
g_dep.setSpacing(10)
|
||
self.venv_check = QCheckBox(_("Use Virtual Environment"))
|
||
self.keep_venv_check = QCheckBox(_("Keep Local Venv"))
|
||
self.venv_check.toggled.connect(self.keep_venv_check.setEnabled)
|
||
|
||
self.venv_mode_container = QWidget()
|
||
self.venv_mode_container.setVisible(False)
|
||
h_venv_mode = QHBoxLayout(self.venv_mode_container)
|
||
h_venv_mode.setContentsMargins(0, 0, 0, 0)
|
||
h_venv_mode.setSpacing(8)
|
||
|
||
self.rb_venv_isolated = QRadioButton(_("Isolated Environment"))
|
||
self.rb_venv_shared = QRadioButton(_("Shared Environment"))
|
||
|
||
self.rb_venv_isolated.setChecked(True)
|
||
|
||
self.shared_venv_dir_edit = QLineEdit()
|
||
self.shared_venv_dir_edit.setPlaceholderText(_("Select shared directory..."))
|
||
self.shared_venv_dir_edit.setEnabled(False)
|
||
self.shared_venv_dir_edit.setReadOnly(True)
|
||
|
||
self.btn_browse_venv_dir = QPushButton(_("Browse"))
|
||
self.btn_browse_venv_dir.setProperty("class", "ToolBtn")
|
||
self.btn_browse_venv_dir.setEnabled(False)
|
||
self.btn_browse_venv_dir.clicked.connect(self.select_shared_venv_dir)
|
||
|
||
self.rb_venv_shared.toggled.connect(self.shared_venv_dir_edit.setEnabled)
|
||
self.rb_venv_shared.toggled.connect(self.btn_browse_venv_dir.setEnabled)
|
||
self.rb_venv_shared.clicked.connect(self._on_shared_venv_radio_clicked)
|
||
self.keep_venv_check.toggled.connect(self.venv_mode_container.setVisible)
|
||
|
||
h_venv_mode.addWidget(self.rb_venv_isolated)
|
||
h_venv_mode.addWidget(self.rb_venv_shared)
|
||
h_venv_mode.addWidget(self.shared_venv_dir_edit, 1)
|
||
h_venv_mode.addWidget(self.btn_browse_venv_dir)
|
||
|
||
self.reqs_check = QCheckBox(_("Install requirements.txt"))
|
||
self.pipreqs_check = QCheckBox(_("Analyze Dependencies (AST)"))
|
||
self.pipreqs_dir_check = QCheckBox(_("Scan Entire Folder"))
|
||
|
||
self.btn_clean_venvs = QPushButton(_("Clear Local Venvs"))
|
||
self.btn_clean_venvs.setProperty("class", "ToolBtn")
|
||
self.btn_clean_venvs.clicked.connect(self.clean_local_venvs)
|
||
|
||
g_dep.addWidget(self.venv_check, 0, 0)
|
||
g_dep.addWidget(self.keep_venv_check, 0, 1)
|
||
g_dep.addWidget(self.btn_clean_venvs, 0, 2)
|
||
g_dep.addWidget(self.venv_mode_container, 1, 0, 1, 3)
|
||
g_dep.addWidget(self.reqs_check, 2, 0)
|
||
g_dep.addWidget(self.pipreqs_check, 2, 1)
|
||
g_dep.addWidget(self.pipreqs_dir_check, 3, 0)
|
||
c_lay_deps.addLayout(g_dep)
|
||
|
||
self.card_env, c_lay_env = self._create_card(_("Environment Analysis"))
|
||
|
||
btn_env_lay = QHBoxLayout()
|
||
self.btn_inspect_env = QPushButton(_("Inspect Interpreter Environment"))
|
||
self.btn_inspect_env.setProperty("class", "ToolBtn")
|
||
self.btn_inspect_env.clicked.connect(self.inspect_current_python_env)
|
||
|
||
self.btn_remediate_env = QPushButton(_("Remediate Detected Conflicts"))
|
||
self.btn_remediate_env.setProperty("class", "ToolBtn")
|
||
self.btn_remediate_env.setStyleSheet("QPushButton { color: #d93025; font-weight: bold; }")
|
||
self.btn_remediate_env.clicked.connect(self.remediate_environment_conflicts)
|
||
|
||
btn_env_lay.addWidget(self.btn_inspect_env)
|
||
btn_env_lay.addWidget(self.btn_remediate_env)
|
||
btn_env_lay.addStretch()
|
||
c_lay_env.addLayout(btn_env_lay)
|
||
self.lbl_env_status = QLabel(_("Status: Environment inspection ready."))
|
||
self.lbl_env_status.setStyleSheet("color: #64748b; font-size: 11px; margin-top: 4px;")
|
||
c_lay_env.addWidget(self.lbl_env_status)
|
||
|
||
lay_sub2.addWidget(self.card_deps)
|
||
lay_sub2.addWidget(self.card_env)
|
||
lay_sub2.addStretch()
|
||
|
||
self.card_res, c_lay_res = self._create_card(_("Additional Resources (Drag & Drop Supported)"))
|
||
|
||
self.lbl_res_hint = QLabel(_("Project-specific. Not saved to global preferences. Use 'Export Preset' to save config."))
|
||
self.lbl_res_hint.setWordWrap(True)
|
||
self.lbl_res_hint.setStyleSheet("font-size: 12px; color: #6b7280; font-weight: normal; margin-top: -2px; margin-bottom: 6px; line-height: 1.4;")
|
||
c_lay_res.addWidget(self.lbl_res_hint)
|
||
|
||
self.add_data_list = DropListWidget()
|
||
self.add_data_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
||
self.add_data_list.setMinimumHeight(120)
|
||
self.add_data_list.setToolTip(_("Double-click to edit target path; Drag & drop supported"))
|
||
|
||
self.add_data_list.itemDoubleClicked.connect(self.edit_resource)
|
||
self.add_data_list.itemsDropped.connect(self.on_resources_dropped)
|
||
c_lay_res.addWidget(self.add_data_list)
|
||
|
||
btn_res_lay = QHBoxLayout()
|
||
self.btn_add_file = QPushButton(_("Add File"))
|
||
self.btn_add_file.setProperty("class", "ToolBtn")
|
||
self.btn_add_file.clicked.connect(self.add_resource_files)
|
||
|
||
self.btn_add_dir = QPushButton(_("Add Dir"))
|
||
self.btn_add_dir.setProperty("class", "ToolBtn")
|
||
self.btn_add_dir.clicked.connect(self.add_resource_dir)
|
||
|
||
self.btn_del_res = QPushButton(_("Remove Selected"))
|
||
self.btn_del_res.setProperty("class", "ToolBtn")
|
||
self.btn_del_res.clicked.connect(self.del_resource)
|
||
|
||
self.btn_clear_res = QPushButton(_("Clear All"))
|
||
self.btn_clear_res.setProperty("class", "ToolBtn")
|
||
self.btn_clear_res.clicked.connect(self.clear_resource)
|
||
|
||
btn_res_lay.addWidget(self.btn_add_file)
|
||
btn_res_lay.addWidget(self.btn_add_dir)
|
||
btn_res_lay.addWidget(self.btn_del_res)
|
||
btn_res_lay.addWidget(self.btn_clear_res)
|
||
btn_res_lay.addStretch()
|
||
c_lay_res.addLayout(btn_res_lay)
|
||
|
||
lay_sub3.addWidget(self.card_res)
|
||
lay_sub3.addStretch()
|
||
|
||
self.card_opt, c_lay_opt = self._create_card(_("Performance Optimization"))
|
||
self.form_opt = QFormLayout()
|
||
self.form_opt.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||
self.form_opt.setSpacing(15)
|
||
|
||
self.cores_spin = QSpinBox()
|
||
self.cores_spin.setRange(1, os.cpu_count() or 4)
|
||
self.cores_spin.setValue(os.cpu_count() or 2)
|
||
|
||
self.lbl_cpu_cores = QLabel(_("CPU Cores:"))
|
||
self.form_opt.addRow(self.lbl_cpu_cores, self.cores_spin)
|
||
|
||
self.upx_check = QCheckBox(_("Enable UPX Compression"))
|
||
self.upx_check.toggled.connect(self.on_upx_toggled)
|
||
|
||
self.upx_path_edit = QLineEdit()
|
||
self.upx_path_edit.setPlaceholderText(_("Leave blank to auto-detect from environment variables"))
|
||
|
||
self.btn_upx = QPushButton(_("Browse"))
|
||
self.btn_upx.setProperty("class", "ToolBtn")
|
||
self.btn_upx.clicked.connect(self.select_upx_path)
|
||
|
||
self.upx_path_container = QWidget()
|
||
h_upx = QHBoxLayout(self.upx_path_container)
|
||
h_upx.setContentsMargins(0,0,0,0)
|
||
h_upx.addWidget(self.upx_path_edit, 1)
|
||
h_upx.addWidget(self.btn_upx)
|
||
self.upx_path_container.setVisible(False)
|
||
|
||
h_upx_row = QHBoxLayout()
|
||
h_upx_row.addWidget(self.upx_check)
|
||
h_upx_row.addWidget(self.upx_path_container)
|
||
|
||
self.lbl_upx_path = QLabel(_("UPX Path:"))
|
||
self.form_opt.addRow(self.lbl_upx_path, h_upx_row)
|
||
c_lay_opt.addLayout(self.form_opt)
|
||
|
||
self.card_sign, c_lay_sign = self._create_card(_("Code Signing"))
|
||
self.enable_sign_check = QCheckBox(_("Enable Smart Code Signing"))
|
||
self.enable_sign_check.setToolTip(_("Auto-applies digital signature to built app, eliminating 'Unknown Publisher' warning"))
|
||
c_lay_sign.addWidget(self.enable_sign_check)
|
||
|
||
self.adv_sign_container = QWidget()
|
||
form_adv_sign = QFormLayout(self.adv_sign_container)
|
||
form_adv_sign.setContentsMargins(0, 5, 0, 0)
|
||
form_adv_sign.setSpacing(10)
|
||
|
||
self.cert_path_edit = QLineEdit()
|
||
self.cert_path_edit.setPlaceholderText(_("Select .pfx / .p12 certificate file"))
|
||
|
||
self.btn_cert = QPushButton(_("Browse"))
|
||
self.btn_cert.setProperty("class", "ToolBtn")
|
||
self.btn_cert.clicked.connect(self.select_cert_file)
|
||
|
||
self.btn_gen_cert = QPushButton(_("Create"))
|
||
self.btn_gen_cert.setProperty("class", "ToolBtn")
|
||
self.btn_gen_cert.setToolTip(_("Create Self-Signed Cert"))
|
||
self.btn_gen_cert.clicked.connect(self.create_self_signed_cert)
|
||
|
||
cert_cont = QWidget()
|
||
h_cert = QHBoxLayout(cert_cont)
|
||
h_cert.setContentsMargins(0, 0, 0, 0)
|
||
h_cert.setSpacing(6)
|
||
h_cert.addWidget(self.cert_path_edit, 1)
|
||
h_cert.addWidget(self.btn_cert)
|
||
h_cert.addWidget(self.btn_gen_cert)
|
||
|
||
self.cert_pass_edit = QLineEdit()
|
||
self.cert_pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||
self.cert_pass_edit.setPlaceholderText(_("Certificate password"))
|
||
|
||
self.lbl_cert_path = QLabel(_("Commercial PFX File:"))
|
||
self.lbl_cert_pass = QLabel(_("Cert Password:"))
|
||
form_adv_sign.addRow(self.lbl_cert_path, cert_cont)
|
||
form_adv_sign.addRow(self.lbl_cert_pass, self.cert_pass_edit)
|
||
self.adv_sign_container.setVisible(False)
|
||
|
||
self.btn_toggle_adv_sign = QPushButton(_("Use Custom Commercial Cert (Optional) ▸"))
|
||
self.btn_toggle_adv_sign.setFlat(True)
|
||
self.btn_toggle_adv_sign.setStyleSheet("QPushButton { color: #2563eb; font-size: 12px; text-align: left; background: transparent; border: none; font-weight: bold; } QPushButton:hover { text-decoration: underline; }")
|
||
self.btn_toggle_adv_sign.clicked.connect(self.toggle_adv_sign_ui)
|
||
|
||
c_lay_sign.addWidget(self.btn_toggle_adv_sign)
|
||
c_lay_sign.addWidget(self.adv_sign_container)
|
||
|
||
self.card_ver, c_lay_ver = self._create_card(_("Lock Core Dependencies"))
|
||
self.form_ver = QFormLayout()
|
||
self.form_ver.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||
self.form_ver.setSpacing(15)
|
||
|
||
self.pyi_ver_edit = QLineEdit()
|
||
self.nuitka_ver_edit = QLineEdit()
|
||
|
||
self.lbl_pyi_ver = QLabel(_("PyInstaller Version:"))
|
||
self.lbl_nuitka_ver = QLabel(_("Nuitka Version:"))
|
||
|
||
self.form_ver.addRow(self.lbl_pyi_ver, self.pyi_ver_edit)
|
||
self.form_ver.addRow(self.lbl_nuitka_ver, self.nuitka_ver_edit)
|
||
c_lay_ver.addLayout(self.form_ver)
|
||
|
||
lay_sub4.addWidget(self.card_opt)
|
||
lay_sub4.addWidget(self.card_sign)
|
||
lay_sub4.addWidget(self.card_ver)
|
||
lay_sub4.addStretch()
|
||
|
||
self.card_map, c_lay_map = self._create_card(_("Package Name Mappings"))
|
||
self.mapping_table = QTableWidget()
|
||
self.mapping_table.setItemDelegate(TableItemDelegate(self.mapping_table))
|
||
self.mapping_table.setColumnCount(2)
|
||
self.mapping_table.setHorizontalHeaderLabels([_("Import Name"), _("PyPI Package Name")])
|
||
self.mapping_table.verticalHeader().setVisible(False)
|
||
self.mapping_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||
self.mapping_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||
self.mapping_table.setMinimumHeight(120)
|
||
c_lay_map.addWidget(self.mapping_table)
|
||
|
||
btn_map_lay = QHBoxLayout()
|
||
self.btn_add_map = QPushButton(_("Add Mapping"))
|
||
self.btn_add_map.setProperty("class", "ToolBtn")
|
||
self.btn_add_map.clicked.connect(self.add_mapping_item)
|
||
|
||
self.btn_del_map = QPushButton(_("Remove Selected"))
|
||
self.btn_del_map.setProperty("class", "ToolBtn")
|
||
self.btn_del_map.clicked.connect(self.delete_mapping_item)
|
||
|
||
self.btn_reset_map = QPushButton(_("Restore Defaults"))
|
||
self.btn_reset_map.setProperty("class", "ToolBtn")
|
||
self.btn_reset_map.clicked.connect(self.reset_mapping_default)
|
||
|
||
btn_map_lay.addWidget(self.btn_add_map)
|
||
btn_map_lay.addWidget(self.btn_del_map)
|
||
btn_map_lay.addWidget(self.btn_reset_map)
|
||
btn_map_lay.addStretch()
|
||
c_lay_map.addLayout(btn_map_lay)
|
||
|
||
self.card_rules, c_lay_rules = self._create_card(_("Obsolete Backport Exclusion Rules"))
|
||
|
||
self.enable_shield_check = QCheckBox(_("Enable Automatic Backport Isolation"))
|
||
self.enable_shield_check.setChecked(True)
|
||
c_lay_rules.addWidget(self.enable_shield_check)
|
||
|
||
self.backport_table = QTableWidget()
|
||
self.backport_table.setItemDelegate(TableItemDelegate(self.backport_table))
|
||
self.backport_table.setColumnCount(2)
|
||
self.backport_table.setHorizontalHeaderLabels([_("Package Name"), _("Min Python Ver")])
|
||
self.backport_table.verticalHeader().setVisible(False)
|
||
self.backport_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||
self.backport_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||
self.backport_table.setMinimumHeight(120)
|
||
c_lay_rules.addWidget(self.backport_table)
|
||
|
||
btn_rules_lay = QHBoxLayout()
|
||
self.btn_add_rule = QPushButton(_("Add Backport Rule"))
|
||
self.btn_add_rule.setProperty("class", "ToolBtn")
|
||
self.btn_add_rule.clicked.connect(self.add_backport_rule_item)
|
||
|
||
self.btn_del_rule = QPushButton(_("Remove Selected"))
|
||
self.btn_del_rule.setProperty("class", "ToolBtn")
|
||
self.btn_del_rule.clicked.connect(self.delete_backport_rule_item)
|
||
|
||
self.btn_reset_rules = QPushButton(_("Restore Defaults"))
|
||
self.btn_reset_rules.setProperty("class", "ToolBtn")
|
||
self.btn_reset_rules.clicked.connect(self.reset_backport_rules_default)
|
||
|
||
btn_rules_lay.addWidget(self.btn_add_rule)
|
||
btn_rules_lay.addWidget(self.btn_del_rule)
|
||
btn_rules_lay.addWidget(self.btn_reset_rules)
|
||
btn_rules_lay.addStretch()
|
||
c_lay_rules.addLayout(btn_rules_lay)
|
||
|
||
lay_sub5.addWidget(self.card_map)
|
||
lay_sub5.addWidget(self.card_rules)
|
||
lay_sub5.addStretch()
|
||
|
||
main_lay.addWidget(self.sub_tabs)
|
||
self.on_engine_changed()
|
||
|
||
def build_pref_tab(self):
|
||
self.card_lang, c_lay_lang = self._create_card(_("UI Language:"))
|
||
form_lang = QFormLayout()
|
||
self.lang_combo = QComboBox()
|
||
|
||
for code, name in I18N.get_available_languages().items():
|
||
self.lang_combo.addItem(name, code)
|
||
|
||
setup_combo_white_theme(self.lang_combo)
|
||
|
||
self.lbl_lang_title = QLabel(_("UI Language:"))
|
||
form_lang.addRow(self.lbl_lang_title, self.lang_combo)
|
||
c_lay_lang.addLayout(form_lang)
|
||
|
||
self.card_meta, c_lay_meta = self._create_card(_("App Metadata & Presets"))
|
||
self.form_meta = QFormLayout()
|
||
self.form_meta.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||
self.form_meta.setSpacing(15)
|
||
|
||
self.ver_ver = QLineEdit("1.0.0")
|
||
self.ver_comp = QLineEdit(_("Independent Developer"))
|
||
self.ver_desc = QLineEdit(_("Desktop Application"))
|
||
|
||
self.lbl_ver_title = QLabel(_("Version:"))
|
||
self.lbl_company_title = QLabel(_("Author/Company:"))
|
||
self.lbl_desc_title = QLabel(_("Description:"))
|
||
|
||
self.form_meta.addRow(self.lbl_ver_title, self.ver_ver)
|
||
self.form_meta.addRow(self.lbl_company_title, self.ver_comp)
|
||
self.form_meta.addRow(self.lbl_desc_title, self.ver_desc)
|
||
c_lay_meta.addLayout(self.form_meta)
|
||
|
||
c_lay_meta.addSpacing(10)
|
||
|
||
g_preset = QGridLayout()
|
||
g_preset.setSpacing(8)
|
||
self.btn_exp_preset = QPushButton(_("Export Preset..."))
|
||
self.btn_exp_preset.setProperty("class", "ToolBtn")
|
||
self.btn_exp_preset.clicked.connect(self.export_preset)
|
||
|
||
self.btn_imp_preset = QPushButton(_("Import Preset..."))
|
||
self.btn_imp_preset.setProperty("class", "ToolBtn")
|
||
self.btn_imp_preset.clicked.connect(self.import_preset)
|
||
|
||
self.btn_reset_config = QPushButton(_("Reset to Default Config"))
|
||
self.btn_reset_config.setProperty("class", "ToolBtn")
|
||
self.btn_reset_config.clicked.connect(self.reset_global_config)
|
||
|
||
g_preset.addWidget(self.btn_exp_preset, 0, 0)
|
||
g_preset.addWidget(self.btn_imp_preset, 0, 1)
|
||
g_preset.addWidget(self.btn_reset_config, 1, 0, 1, 2)
|
||
c_lay_meta.addLayout(g_preset)
|
||
|
||
self.card1, lay1 = self._create_card(_("Output Location"))
|
||
self.out_mode_combo = QComboBox()
|
||
self.out_mode_combo.addItems([_("Source File Directory"), _("Custom Directory")])
|
||
setup_combo_white_theme(self.out_mode_combo)
|
||
self.out_mode_combo.currentIndexChanged.connect(self.on_out_mode_changed)
|
||
|
||
self.sandbox_mode_combo = QComboBox()
|
||
self.sandbox_mode_combo.addItems([
|
||
_("Source Directory (.qpypack_build)"),
|
||
_("System Temp Directory")
|
||
])
|
||
setup_combo_white_theme(self.sandbox_mode_combo)
|
||
|
||
self.out_dir_edit = QLineEdit()
|
||
self.btn_out_dir = QPushButton(_("Browse"))
|
||
self.btn_out_dir.setProperty("class", "ToolBtn")
|
||
self.btn_out_dir.clicked.connect(self.select_out_dir)
|
||
|
||
self.out_dir_container = QWidget()
|
||
h_out_dir = QHBoxLayout(self.out_dir_container)
|
||
h_out_dir.setContentsMargins(0, 0, 0, 0)
|
||
h_out_dir.addWidget(self.out_dir_edit, 1)
|
||
h_out_dir.addWidget(self.btn_out_dir)
|
||
self.out_dir_container.setVisible(False)
|
||
|
||
self.form_out = QFormLayout()
|
||
self.form_out.setVerticalSpacing(15)
|
||
self.form_out.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||
|
||
self.lbl_out_rule_title = QLabel(_("Output Location:"))
|
||
self.lbl_target_out_title = QLabel(_("Target Directory:"))
|
||
self.lbl_sandbox_title = QLabel(_("Temporary Directory:"))
|
||
|
||
self.form_out.addRow(self.lbl_out_rule_title, self.out_mode_combo)
|
||
self.form_out.addRow(self.lbl_target_out_title, self.out_dir_container)
|
||
self.form_out.addRow(self.lbl_sandbox_title, self.sandbox_mode_combo)
|
||
lay1.addLayout(self.form_out)
|
||
|
||
self.card2, lay2 = self._create_card(_("Preferences & System Behavior"))
|
||
lay2.setSpacing(16)
|
||
|
||
self.concise_log_check = QCheckBox(_("Concise Log Output"))
|
||
self.auto_save_log_check = QCheckBox(_("Auto-save Build Log"))
|
||
self.auto_icon_check = QCheckBox(_("Auto Extract Icon"))
|
||
self.clean_all_check = QCheckBox(_("Clean Temporary Cache After Build"))
|
||
self.sound_notify_check = QCheckBox(_("Sound Notification"))
|
||
|
||
for chk in (self.concise_log_check, self.auto_save_log_check, self.auto_icon_check, self.clean_all_check, self.sound_notify_check):
|
||
lay2.addWidget(chk)
|
||
|
||
self.lay_pref.addWidget(self.card_lang)
|
||
self.lay_pref.addWidget(self.card_meta)
|
||
self.lay_pref.addWidget(self.card1)
|
||
self.lay_pref.addWidget(self.card2)
|
||
self.lay_pref.addStretch()
|
||
|
||
def build_about_tab(self):
|
||
main_lay = self.lay_about
|
||
main_lay.setContentsMargins(40, 10, 40, 10)
|
||
main_lay.setSpacing(15)
|
||
main_lay.addStretch(1)
|
||
|
||
logo_lbl = QLabel()
|
||
icon_path = get_resource_path("icon.ico")
|
||
if os.path.exists(icon_path):
|
||
high_res_pixmap = QIcon(icon_path).pixmap(256, 256)
|
||
if not high_res_pixmap.isNull():
|
||
logo_pixmap = high_res_pixmap.scaled(96, 96, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||
logo_lbl.setPixmap(logo_pixmap)
|
||
else:
|
||
logo_lbl.setPixmap(get_svg_pixmap('package', color="#1A73E8", size=96))
|
||
else:
|
||
logo_lbl.setPixmap(get_svg_pixmap('package', color="#1A73E8", size=96))
|
||
|
||
logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
main_lay.addWidget(logo_lbl)
|
||
|
||
text_vlay = QVBoxLayout()
|
||
text_vlay.setSpacing(6)
|
||
text_vlay.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
title_lbl = QLabel(__app_name__)
|
||
title_lbl.setStyleSheet("font-size: 32px; font-weight: 900; color: #202124; letter-spacing: -1px;")
|
||
title_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
text_vlay.addWidget(title_lbl)
|
||
|
||
self.about_desc_lbl = QLabel(_("Modern Cross-Platform Python Packaging GUI Powered by PyInstaller & Nuitka"))
|
||
self.about_desc_lbl.setStyleSheet("font-size: 14px; color: #3c4043;")
|
||
self.about_desc_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
text_vlay.addWidget(self.about_desc_lbl)
|
||
|
||
text_vlay.addSpacing(6)
|
||
|
||
current_year = time.localtime().tm_year
|
||
year_str = f"2026-{current_year}" if current_year > 2026 else "2026"
|
||
ver_lbl = QLabel(f"Version {__version__} · GPL-3.0 · Copyright © {year_str} {__author__}")
|
||
ver_lbl.setStyleSheet("font-size: 12px; color: #8b929a; font-weight: bold;")
|
||
ver_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
text_vlay.addWidget(ver_lbl)
|
||
|
||
main_lay.addLayout(text_vlay)
|
||
main_lay.addSpacing(25)
|
||
|
||
btn_lay = QHBoxLayout()
|
||
btn_lay.setSpacing(12)
|
||
btn_lay.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
def create_link_btn(text, url, svg_path, icon_color, bg_color, hover_bg, pressed_bg):
|
||
btn = QPushButton(" " + text)
|
||
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
|
||
svg_str = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="{icon_color}" d="{svg_path}"/></svg>'
|
||
renderer = QSvgRenderer()
|
||
renderer.load(svg_str.encode('utf-8'))
|
||
pixmap = QPixmap(48, 48)
|
||
pixmap.fill(Qt.GlobalColor.transparent)
|
||
painter = QPainter(pixmap)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||
renderer.render(painter)
|
||
painter.end()
|
||
btn.setIcon(QIcon(pixmap))
|
||
|
||
btn.setStyleSheet(f"""
|
||
QPushButton {{ background-color: {bg_color}; color: #3c4043; border: none; border-radius: 8px; padding: 8px 16px; font-size: 13px; font-weight: bold; }}
|
||
QPushButton:hover {{ background-color: {hover_bg}; color: {icon_color}; }}
|
||
QPushButton:pressed {{ background-color: {pressed_bg}; }}
|
||
""")
|
||
btn.clicked.connect(lambda: __import__('webbrowser').open(url))
|
||
return btn
|
||
|
||
p_github = "M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.379.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.161 22 16.416 22 12c0-5.523-4.477-10-10-10z"
|
||
p_issue = "M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"
|
||
p_pypi = "M12.06,1.48c-3.14,0-3.52,0.67-3.52,0.67l-0.01,2.44h3.63v0.52H7.43C5.12,5.11,4.5,6.58,4.5,8.81c0,2.34,0.38,3.48,2.3,3.48 h1.14v-1.62c0-1.48,1.23-2.65,2.7-2.65h3.69c1.47,0,2.66-1.19,2.66-2.65V3.88C16.99,1.83,14.67,1.48,12.06,1.48z M10.22,2.83 c0.41,0,0.73,0.33,0.73,0.74c0,0.41-0.33,0.74-0.73,0.74C9.49,3.16,9.82,2.83,10.22,2.83z M16.71,9.89 v1.62c0,1.48-1.23,2.65-2.7,2.65H10.3c-1.47,0-2.66,1.19-2.66,2.65v1.49c0,2.05,2.32,2.41,4.92,2.41c3.14,0,3.52-0.67,3.52-0.67 l0.01-2.44h-3.63v-0.52h4.73c2.31,0,2.93-1.47,2.93-3.7c0-2.34-0.38-3.48-2.3-3.48H16.71z M13.88,18.96c0.41,0,0.73,0.33,0.73,0.74c0,0.41-0.33,0.74-0.73,0.74c-0.4,0-0.73-0.33-0.73-0.74C13.15,19.29,13.48,18.96,13.88,18.96z"
|
||
p_heart = "M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||
|
||
self.btn_github = create_link_btn(_("GitHub Repository"), "https://github.com/qwejay/QPyPack", p_github, "#24292e", "#f1f3f4", "#e8eaed", "#dadce0")
|
||
self.btn_issue = create_link_btn(_("Issues & Feedback"), "https://github.com/qwejay/QPyPack/issues", p_issue, "#d93025", "#f1f3f4", "#e8eaed", "#dadce0")
|
||
self.btn_pypi = create_link_btn(_("PyPI Home"), "https://pypi.org/project/qpypack/", p_pypi, "#1A73E8", "#f1f3f4", "#e8eaed", "#dadce0")
|
||
|
||
self.btn_sponsor = create_link_btn(_("Sponsor"), "https://www.ifdian.net/a/qwejay", p_heart, "#d93025", "#fce8e6", "#fad2cf", "#f6aea9")
|
||
self.btn_sponsor.setStyleSheet(self.btn_sponsor.styleSheet().replace("color: #3c4043;", "color: #d93025;"))
|
||
|
||
btn_lay.addWidget(self.btn_github)
|
||
btn_lay.addWidget(self.btn_issue)
|
||
btn_lay.addWidget(self.btn_pypi)
|
||
btn_lay.addWidget(self.btn_sponsor)
|
||
|
||
main_lay.addLayout(btn_lay)
|
||
main_lay.addSpacing(20)
|
||
|
||
sponsor_vlay = QVBoxLayout()
|
||
sponsor_vlay.setSpacing(12)
|
||
|
||
self.sponsor_desc_p1 = QLabel(_("QPyPack is a free and open-source tool. If it has improved your efficiency or solved packaging problems, consider buying the author a coffee!"))
|
||
self.sponsor_desc_p1.setWordWrap(True)
|
||
self.sponsor_desc_p1.setStyleSheet("font-size: 12px; color: #5f6368;")
|
||
self.sponsor_desc_p1.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
sponsor_vlay.addWidget(self.sponsor_desc_p1)
|
||
|
||
self.sponsor_desc_p2 = QLabel(_("* Sponsorship is completely voluntary, serves as an unconditional encouragement to the open-source community, and involves no commercial commitments. Thank you for your support!"))
|
||
self.sponsor_desc_p2.setWordWrap(True)
|
||
self.sponsor_desc_p2.setStyleSheet("font-size: 11px; color: #9aa0a6;")
|
||
self.sponsor_desc_p2.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
sponsor_vlay.addWidget(self.sponsor_desc_p2)
|
||
|
||
main_lay.addLayout(sponsor_vlay)
|
||
main_lay.addStretch(1)
|
||
|
||
|
||
def retranslate_ui(self):
|
||
self.tabs.setTabText(0, _("Build Settings"))
|
||
self.tabs.setTabText(1, _("Preferences"))
|
||
self.tabs.setTabText(2, _("About"))
|
||
|
||
self.sub_tabs.setTabText(0, _("Engine"))
|
||
self.sub_tabs.setTabText(1, _("Dependencies"))
|
||
self.sub_tabs.setTabText(2, _("Resources"))
|
||
self.sub_tabs.setTabText(3, _("Optimization & Security"))
|
||
self.sub_tabs.setTabText(4, _("Package Mappings & Rules"))
|
||
|
||
self.card_engine.findChild(QLabel, "CardTitle").setText(_("Engine & Environment"))
|
||
self.card_mode.findChild(QLabel, "CardTitle").setText(_("Execution Mode"))
|
||
self.card_deps.findChild(QLabel, "CardTitle").setText(_("Mirrors & Scanner"))
|
||
self.card_res.findChild(QLabel, "CardTitle").setText(_("Additional Resources (Drag & Drop Supported)"))
|
||
self.card_opt.findChild(QLabel, "CardTitle").setText(_("Performance Optimization"))
|
||
self.card_ver.findChild(QLabel, "CardTitle").setText(_("Lock Core Dependencies"))
|
||
self.card_map.findChild(QLabel, "CardTitle").setText(_("Package Name Mappings"))
|
||
|
||
if hasattr(self, 'card_rules'):
|
||
self.card_rules.findChild(QLabel, "CardTitle").setText(_("Obsolete Backport Exclusion Rules"))
|
||
if hasattr(self, 'card_env'):
|
||
self.card_env.findChild(QLabel, "CardTitle").setText(_("Environment Analysis"))
|
||
if hasattr(self, 'enable_shield_check'):
|
||
self.enable_shield_check.setText(_("Enable Automatic Backport Isolation"))
|
||
if hasattr(self, 'btn_inspect_env'):
|
||
self.btn_inspect_env.setText(_("Inspect Interpreter Environment"))
|
||
if hasattr(self, 'btn_remediate_env'):
|
||
self.btn_remediate_env.setText(_("Remediate Detected Conflicts"))
|
||
if hasattr(self, 'backport_table'):
|
||
self.backport_table.setHorizontalHeaderLabels([_("Package Name"), _("Min Python Ver")])
|
||
if hasattr(self, 'btn_add_rule'):
|
||
self.btn_add_rule.setText(_("Add Backport Rule"))
|
||
if hasattr(self, 'btn_del_rule'):
|
||
self.btn_del_rule.setText(_("Remove Selected"))
|
||
if hasattr(self, 'btn_reset_rules'):
|
||
self.btn_reset_rules.setText(_("Restore Defaults"))
|
||
|
||
self.card_lang.findChild(QLabel, "CardTitle").setText(_("UI Language:"))
|
||
self.card_meta.findChild(QLabel, "CardTitle").setText(_("App Metadata & Presets"))
|
||
self.card1.findChild(QLabel, "CardTitle").setText(_("Output Location"))
|
||
self.card2.findChild(QLabel, "CardTitle").setText(_("Preferences & System Behavior"))
|
||
|
||
self.lbl_eng_title.setText(_("Build Engine:"))
|
||
self.lbl_pack_mode_title.setText(_("Packaging Mode:"))
|
||
self.rb_compat_mode.setText(_("Compatibility Mode (Default)"))
|
||
self.rb_lite_mode.setText(_("Lite Mode (Smaller size)"))
|
||
self.rb_compat_mode.setToolTip(_("Maximum compatibility. Recommended for apps with complex dynamic imports."))
|
||
self.rb_lite_mode.setToolTip(_("Smaller output size. Recommended for 95% of standard apps."))
|
||
|
||
self.lbl_py_title.setText(_("Interpreter:"))
|
||
self.lbl_app_title.setText(_("Output Name:"))
|
||
self.lbl_icon_title.setText(_("App Icon:"))
|
||
self.lbl_pip_main.setText(_("Primary PIP Index:"))
|
||
self.lbl_pip_backup.setText(_("Backup PIP Index:"))
|
||
self.lbl_reqs.setText(_("Requirements File:"))
|
||
self.lbl_hidden.setText(_("Hidden Imports:"))
|
||
self.lbl_exclude.setText(_("Exclude Modules:"))
|
||
self.lbl_cpu_cores.setText(_("CPU Cores:"))
|
||
self.lbl_upx_path.setText(_("UPX Path:"))
|
||
self.lbl_pyi_ver.setText(_("PyInstaller Version:"))
|
||
self.lbl_nuitka_ver.setText(_("Nuitka Version:"))
|
||
self.lbl_lang_title.setText(_("UI Language:"))
|
||
self.lbl_ver_title.setText(_("Version:"))
|
||
self.lbl_company_title.setText(_("Author/Company:"))
|
||
self.lbl_desc_title.setText(_("Description:"))
|
||
self.lbl_out_rule_title.setText(_("Output Location:"))
|
||
self.lbl_target_out_title.setText(_("Target Directory:"))
|
||
self.lbl_sandbox_title.setText(_("Temporary Directory:"))
|
||
self.sandbox_mode_combo.setItemText(0, _("Source Directory (.qpypack_build)"))
|
||
self.sandbox_mode_combo.setItemText(1, _("System Temp Directory"))
|
||
|
||
if not self.ver_comp.isModified():
|
||
self.ver_comp.setText(_("Independent Developer"))
|
||
|
||
if not self.ver_desc.isModified():
|
||
self.ver_desc.setText(_("Desktop Application"))
|
||
|
||
if hasattr(self, 'pip_source_combo') and hasattr(self, 'pip_backup_combo'):
|
||
cur_main = self._get_url_from_combo(self.pip_source_combo)
|
||
cur_back = self._get_url_from_combo(self.pip_backup_combo)
|
||
|
||
self.pip_source_combo.blockSignals(True)
|
||
self.pip_backup_combo.blockSignals(True)
|
||
|
||
self.pip_source_combo.clear()
|
||
self.pip_backup_combo.clear()
|
||
|
||
for name, url in PYPI_MIRRORS_GLOBAL:
|
||
display_text = f"{_(name)}: {url}"
|
||
self.pip_source_combo.addItem(display_text, url)
|
||
self.pip_backup_combo.addItem(display_text, url)
|
||
|
||
self._set_combo_value(self.pip_source_combo, cur_main)
|
||
self._set_combo_value(self.pip_backup_combo, cur_back)
|
||
|
||
self.pip_source_combo.blockSignals(False)
|
||
self.pip_backup_combo.blockSignals(False)
|
||
|
||
self.btn_save.setText(_("Save & Return"))
|
||
self.btn_python_path.setText(_("Browse"))
|
||
self.btn_download_py.setText(_("View Python"))
|
||
self.btn_icon.setText(_("Browse"))
|
||
self.btn_cert.setText(_("Browse"))
|
||
self.btn_gen_cert.setText(_("Create"))
|
||
self.btn_gen_cert.setToolTip(_("Create Self-Signed Cert"))
|
||
self.cert_path_edit.setPlaceholderText(_("Select .pfx / .p12 certificate file"))
|
||
self.cert_pass_edit.setPlaceholderText(_("Certificate password"))
|
||
self.btn_reqs.setText(_("Browse"))
|
||
self.btn_scan.setText(_("AST Scan"))
|
||
self.btn_upx.setText(_("Browse"))
|
||
self.btn_out_dir.setText(_("Browse"))
|
||
self.btn_add_file.setText(_("Add File"))
|
||
self.btn_add_dir.setText(_("Add Dir"))
|
||
self.btn_del_res.setText(_("Remove Selected"))
|
||
self.btn_clear_res.setText(_("Clear All"))
|
||
self.btn_add_map.setText(_("Add Mapping"))
|
||
self.btn_del_map.setText(_("Remove Selected"))
|
||
self.btn_reset_map.setText(_("Restore Defaults"))
|
||
self.btn_exp_preset.setText(_("Export Preset..."))
|
||
self.btn_imp_preset.setText(_("Import Preset..."))
|
||
self.btn_reset_config.setText(_("Reset to Default Config"))
|
||
|
||
self.python_path_combo.setPlaceholderText(_("Leave blank to auto-detect system default Python"))
|
||
self.name_edit.setPlaceholderText(_("Leave blank to auto-match script name"))
|
||
self.reqs_file_edit.setPlaceholderText(_("Leave blank to auto-search requirements.txt in current directory"))
|
||
self.hidden_edit.setPlaceholderText(_("Comma separated (e.g. pandas, PyQt5)"))
|
||
self.exclude_edit.setPlaceholderText(_("Comma separated (e.g. tkinter, matplotlib)"))
|
||
self.upx_path_edit.setPlaceholderText(_("Leave blank to auto-detect from environment variables"))
|
||
|
||
self.rb_onefile.setText(_("One-File Mode (--onefile)"))
|
||
self.rb_onedir.setText(_("Folder Mode (--onedir)"))
|
||
self.lbl_contents_dir.setText(_("Contents Directory (--contents-directory):"))
|
||
self.contents_dir_edit.setPlaceholderText(_("Internal directory name for dependencies (default: _internal)"))
|
||
self.noconsole_check.setText(_("Hide Console (--noconsole)"))
|
||
self.venv_check.setText(_("Use Virtual Environment"))
|
||
self.keep_venv_check.setText(_("Keep Local Venv"))
|
||
self.reqs_check.setText(_("Install requirements.txt"))
|
||
self.pipreqs_check.setText(_("Analyze Dependencies (AST)"))
|
||
self.pipreqs_dir_check.setText(_("Scan Entire Folder"))
|
||
self.upx_check.setText(_("Enable UPX Compression"))
|
||
|
||
self.concise_log_check.setText(_("Concise Log Output"))
|
||
self.auto_save_log_check.setText(_("Auto-save Build Log"))
|
||
self.auto_icon_check.setText(_("Auto Extract Icon"))
|
||
self.clean_all_check.setText(_("Clean Temporary Cache After Build"))
|
||
self.sound_notify_check.setText(_("Sound Notification"))
|
||
|
||
self.out_mode_combo.setItemText(0, _("Source File Directory"))
|
||
self.out_mode_combo.setItemText(1, _("Custom Directory"))
|
||
self.mapping_table.setHorizontalHeaderLabels([_("Import Name"), _("PyPI Package Name")])
|
||
self.rb_venv_isolated.setText(_("Isolated Environment"))
|
||
self.rb_venv_shared.setText(_("Shared Environment"))
|
||
self.shared_venv_dir_edit.setPlaceholderText(_("Select shared directory..."))
|
||
|
||
if hasattr(self, 'sponsor_desc_p1'):
|
||
self.sponsor_desc_p1.setText(_("QPyPack is a free and open-source tool. If it has improved your efficiency or solved packaging problems, consider buying the author a coffee!"))
|
||
self.sponsor_desc_p2.setText(_("* Sponsorship is completely voluntary, serves as an unconditional encouragement to the open-source community, and involves no commercial commitments. Thank you for your support!"))
|
||
|
||
self.btn_reset.setToolTip(_("Reset to Default Config"))
|
||
self.btn_back.setToolTip(_("Cancel & Return"))
|
||
if hasattr(self, 'lbl_res_hint'):
|
||
self.lbl_res_hint.setText(_("Project-specific. Not saved to global preferences. Use 'Export Preset' to save config."))
|
||
self.add_data_list.setToolTip(_("Double-click to edit target path; Drag & drop supported. Use 'Export Preset' to save for reuse."))
|
||
|
||
self.btn_clean_venvs.setText(_("Clear Local Venvs"))
|
||
self.enable_sign_check.setText(_("Enable Smart Code Signing"))
|
||
self.enable_sign_check.setToolTip(_("Auto-applies digital signature to built app, eliminating 'Unknown Publisher' warning"))
|
||
|
||
if hasattr(self, 'lbl_cert_path'):
|
||
self.lbl_cert_path.setText(_("Commercial PFX File:"))
|
||
if hasattr(self, 'lbl_cert_pass'):
|
||
self.lbl_cert_pass.setText(_("Cert Password:"))
|
||
|
||
if self.adv_sign_container.isVisible():
|
||
self.btn_toggle_adv_sign.setText(_("Hide Custom Cert Settings ▾"))
|
||
else:
|
||
self.btn_toggle_adv_sign.setText(_("Use Custom Commercial Cert (Optional) ▸"))
|
||
|
||
if hasattr(self, 'lang_combo'):
|
||
current_lang = self.lang_combo.currentData()
|
||
self.lang_combo.blockSignals(True)
|
||
self.lang_combo.clear()
|
||
for code, name in I18N.get_available_languages().items():
|
||
self.lang_combo.addItem(name, code)
|
||
idx = self.lang_combo.findData(current_lang)
|
||
if idx >= 0:
|
||
self.lang_combo.setCurrentIndex(idx)
|
||
self.lang_combo.blockSignals(False)
|
||
|
||
if hasattr(self, 'lbl_env_status'):
|
||
if "ready." in self.lbl_env_status.text() or "等待环境校验" in self.lbl_env_status.text():
|
||
self.lbl_env_status.setText(_("Status: Environment inspection ready."))
|
||
|
||
self.on_engine_changed()
|
||
self.on_python_path_changed()
|
||
|
||
def populate_python_combo(self, py_dict):
|
||
current_text = self.python_path_combo.currentText().strip()
|
||
self.python_path_combo.clear()
|
||
for path, ver in py_dict.items():
|
||
self.python_path_combo.addItem(f"{path} (Python {ver})", path)
|
||
|
||
if not current_text: current_text = get_python_executable()
|
||
|
||
if current_text:
|
||
clean_text = current_text
|
||
if " (Python " in clean_text: clean_text = clean_text.split(" (Python ")[0].strip()
|
||
clean_text = os.path.normpath(clean_text).lower()
|
||
|
||
found = False
|
||
for idx in range(self.python_path_combo.count()):
|
||
item_path = self.python_path_combo.itemData(idx)
|
||
if item_path and os.path.normpath(item_path).lower() == clean_text:
|
||
self.python_path_combo.setCurrentIndex(idx)
|
||
found = True
|
||
break
|
||
if not found: self.python_path_combo.setCurrentText(current_text)
|
||
|
||
fm = self.python_path_combo.fontMetrics()
|
||
max_width = 520
|
||
for i in range(self.python_path_combo.count()):
|
||
text_w = fm.horizontalAdvance(self.python_path_combo.itemText(i))
|
||
if text_w > max_width:
|
||
max_width = text_w
|
||
|
||
if self.python_path_combo.view():
|
||
self.python_path_combo.view().setMinimumWidth(max_width + 36)
|
||
|
||
self.on_python_path_changed()
|
||
|
||
def populate_mapping_table(self, mappings_dict):
|
||
self.mapping_table.setRowCount(0)
|
||
for imp_name, pypi_name in mappings_dict.items():
|
||
row = self.mapping_table.rowCount()
|
||
self.mapping_table.insertRow(row)
|
||
self.mapping_table.setItem(row, 0, QTableWidgetItem(imp_name))
|
||
self.mapping_table.setItem(row, 1, QTableWidgetItem(pypi_name))
|
||
|
||
def add_mapping_item(self):
|
||
dlg1 = CustomInputDialog(self, _("Add Mapping"), _("Import name (e.g. cv2):"))
|
||
if dlg1.exec() != QDialog.DialogCode.Accepted: return
|
||
imp_name = dlg1.get_text().strip()
|
||
if not imp_name: return
|
||
|
||
dlg2 = CustomInputDialog(self, _("Add Mapping"), _("PyPI package name for [{imp_name}]:", imp_name=imp_name), imp_name)
|
||
if dlg2.exec() != QDialog.DialogCode.Accepted: return
|
||
pypi_name = dlg2.get_text().strip()
|
||
if not pypi_name: return
|
||
|
||
row = self.mapping_table.rowCount()
|
||
self.mapping_table.insertRow(row)
|
||
self.mapping_table.setItem(row, 0, QTableWidgetItem(imp_name))
|
||
self.mapping_table.setItem(row, 1, QTableWidgetItem(pypi_name))
|
||
|
||
def delete_mapping_item(self):
|
||
rows = set(item.row() for item in self.mapping_table.selectedItems())
|
||
for r in sorted(rows, reverse=True): self.mapping_table.removeRow(r)
|
||
|
||
def reset_mapping_default(self):
|
||
self.populate_mapping_table(DEFAULT_MAPPINGS)
|
||
if hasattr(self.parent_win, "show_notification"):
|
||
self.parent_win.show_notification(_("Package mappings have been reset to defaults."))
|
||
|
||
def populate_backport_table(self, rules_dict):
|
||
self.backport_table.setRowCount(0)
|
||
for pkg_name, ver_str in rules_dict.items():
|
||
row = self.backport_table.rowCount()
|
||
self.backport_table.insertRow(row)
|
||
self.backport_table.setItem(row, 0, QTableWidgetItem(pkg_name))
|
||
self.backport_table.setItem(row, 1, QTableWidgetItem(str(ver_str)))
|
||
|
||
def add_backport_rule_item(self):
|
||
dlg1 = CustomInputDialog(self, _("Add Backport Rule"), _("Package Name (e.g. pathlib):"))
|
||
if dlg1.exec() != QDialog.DialogCode.Accepted: return
|
||
pkg_name = dlg1.get_text().strip().lower()
|
||
if not pkg_name: return
|
||
|
||
dlg2 = CustomInputDialog(self, _("Add Backport Rule"), _("Minimum Built-in Python Version (e.g. 3.4):"), "3.4")
|
||
if dlg2.exec() != QDialog.DialogCode.Accepted: return
|
||
ver_str = dlg2.get_text().strip()
|
||
if not ver_str: ver_str = "3.4"
|
||
|
||
row = self.backport_table.rowCount()
|
||
self.backport_table.insertRow(row)
|
||
self.backport_table.setItem(row, 0, QTableWidgetItem(pkg_name))
|
||
self.backport_table.setItem(row, 1, QTableWidgetItem(ver_str))
|
||
|
||
def delete_backport_rule_item(self):
|
||
rows = set(item.row() for item in self.backport_table.selectedItems())
|
||
for r in sorted(rows, reverse=True): self.backport_table.removeRow(r)
|
||
|
||
def reset_backport_rules_default(self):
|
||
self.populate_backport_table(DEFAULT_BACKPORT_RULES)
|
||
if hasattr(self.parent_win, "show_notification"):
|
||
self.parent_win.show_notification(_("Backport exclusion rules have been reset to defaults."))
|
||
|
||
def get_selected_python_ver_tuple(self):
|
||
raw_py = self.python_path_combo.currentText().strip()
|
||
if " (Python " in raw_py: raw_py = raw_py.split(" (Python ")[0].strip()
|
||
if not raw_py: raw_py = get_python_executable()
|
||
|
||
try:
|
||
clean_env = os.environ.copy()
|
||
clean_env.pop("PYTHONHOME", None)
|
||
clean_env.pop("PYTHONPATH", None)
|
||
kw = {"capture_output": True, "text": True, "timeout": 3, "errors": "ignore"}
|
||
if os.name == 'nt': kw["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
res = subprocess.run([raw_py, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"], **kw)
|
||
if res.returncode == 0 and res.stdout.strip():
|
||
parts = res.stdout.strip().split('.')
|
||
return raw_py, (int(parts[0]), int(parts[1]))
|
||
except Exception: pass
|
||
return raw_py, (3, 8)
|
||
|
||
def get_current_active_backport_rules(self):
|
||
rules = {}
|
||
for r in range(self.backport_table.rowCount()):
|
||
item_k = self.backport_table.item(r, 0)
|
||
item_v = self.backport_table.item(r, 1)
|
||
pkg = item_k.text().strip().lower() if item_k else ""
|
||
ver_str = item_v.text().strip() if item_v else "3.4"
|
||
if pkg:
|
||
try:
|
||
parts = [int(x) for x in ver_str.split('.')]
|
||
major = parts[0] if len(parts) > 0 else 3
|
||
minor = parts[1] if len(parts) > 1 else 0
|
||
rules[pkg] = (major, minor)
|
||
except Exception:
|
||
rules[pkg] = (3, 4)
|
||
return rules
|
||
|
||
def inspect_current_python_env(self):
|
||
py_exe, ver_tuple = self.get_selected_python_ver_tuple()
|
||
if not py_exe or not os.path.exists(py_exe):
|
||
self.lbl_env_status.setText(_("[ERROR] Python interpreter is invalid or not found: {path}", path=py_exe or "None"))
|
||
return
|
||
|
||
installed = query_target_env_packages(py_exe)
|
||
current_rules = self.get_current_active_backport_rules()
|
||
conflicts_found = []
|
||
|
||
for pkg_name in installed.keys():
|
||
clean_pkg = pkg_name.lower().strip()
|
||
if clean_pkg in current_rules:
|
||
req_ver_tuple = current_rules[clean_pkg]
|
||
if ver_tuple >= req_ver_tuple:
|
||
conflicts_found.append(pkg_name)
|
||
|
||
if conflicts_found:
|
||
msg = _("Active backport conflicts detected ({count}): {pkgs}", count=len(conflicts_found), pkgs=", ".join(conflicts_found))
|
||
self.lbl_env_status.setText(f"<span style='color:#d93025; font-weight:bold;'>[WARN] {msg}</span>")
|
||
else:
|
||
self.lbl_env_status.setText(_("Interpreter inspection passed: No active backport conflicts detected."))
|
||
|
||
def remediate_environment_conflicts(self):
|
||
py_exe, ver_tuple = self.get_selected_python_ver_tuple()
|
||
installed = query_target_env_packages(py_exe)
|
||
current_rules = self.get_current_active_backport_rules()
|
||
|
||
conflicts_to_remove = []
|
||
for pkg_name in installed.keys():
|
||
clean_pkg = pkg_name.lower().strip()
|
||
if clean_pkg in current_rules and ver_tuple >= current_rules[clean_pkg]:
|
||
conflicts_to_remove.append(pkg_name)
|
||
|
||
if not conflicts_to_remove:
|
||
if hasattr(self.parent_win, "show_notification"):
|
||
self.parent_win.show_notification(_("No environment conflicts detected."))
|
||
return
|
||
|
||
uninst_cmd = [py_exe, "-m", "pip", "uninstall", "-y"] + conflicts_to_remove
|
||
try:
|
||
kw = {"capture_output": True, "text": True, "timeout": 20, "errors": "ignore"}
|
||
if os.name == 'nt': kw["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
subprocess.run(uninst_cmd, **kw)
|
||
self.inspect_current_python_env()
|
||
if hasattr(self.parent_win, "show_notification"):
|
||
self.parent_win.show_notification(_("Remediation completed for packages: {pkgs}", pkgs=", ".join(conflicts_to_remove)))
|
||
except Exception as e:
|
||
self.lbl_env_status.setText(_("[ERROR] Environment remediation failed: {error}", error=str(e)))
|
||
|
||
def toggle_adv_sign_ui(self):
|
||
is_visible = not self.adv_sign_container.isVisible()
|
||
self.adv_sign_container.setVisible(is_visible)
|
||
if is_visible:
|
||
self.btn_toggle_adv_sign.setText(_("Hide Custom Cert Settings ▾"))
|
||
else:
|
||
self.btn_toggle_adv_sign.setText(_("Use Custom Commercial Cert (Optional) ▸"))
|
||
|
||
def select_cert_file(self):
|
||
f, _filter = QFileDialog.getOpenFileName(self, _("Commercial PFX File:"), "", "PKCS#12 Certificate (*.pfx *.p12);;All Files (*)", options=QFileDialog.Option.DontUseNativeDialog)
|
||
if f: self.cert_path_edit.setText(Path(f).resolve().as_posix())
|
||
|
||
def create_self_signed_cert(self):
|
||
if os.name != 'nt':
|
||
if hasattr(self.parent_win, "show_notification"):
|
||
self.parent_win.show_notification(_("Only supported on Windows."))
|
||
return
|
||
|
||
dlg = GenCertDialog(self)
|
||
if dlg.exec() == QDialog.DialogCode.Accepted:
|
||
subject, pwd = dlg.cert_info
|
||
|
||
fp, _filter = QFileDialog.getSaveFileName(
|
||
self,
|
||
_("Save Certificate"),
|
||
"my_cert.pfx",
|
||
_("Self-Signed Certificate (*.pfx)"),
|
||
options=QFileDialog.Option.DontUseNativeDialog
|
||
)
|
||
|
||
if fp:
|
||
try:
|
||
fp_posix = Path(fp).resolve().as_posix()
|
||
safe_subject = subject.replace("'", "''")
|
||
safe_pwd = pwd.replace("'", "''")
|
||
ps_cmd = (
|
||
f'$subject = "CN={safe_subject}"; '
|
||
f'$cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject $subject -CertStoreLocation "Cert:\\CurrentUser\\My"; '
|
||
f'$pwd = ConvertTo-SecureString -String \'{safe_pwd}\' -Force -AsPlainText; '
|
||
f'Export-PfxCertificate -Cert $cert -FilePath "{fp_posix}" -Password $pwd; '
|
||
f'Remove-Item -Path "Cert:\\CurrentUser\\My\\$($cert.Thumbprint)"'
|
||
)
|
||
res = subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd], capture_output=True, text=True, creationflags=subprocess.CREATE_NO_WINDOW)
|
||
if res.returncode == 0 and Path(fp).exists():
|
||
self.cert_path_edit.setText(fp_posix)
|
||
self.cert_pass_edit.setText(pwd)
|
||
if hasattr(self.parent_win, "show_notification"):
|
||
self.parent_win.show_notification(_("Certificate generated successfully!"))
|
||
else:
|
||
if hasattr(self.parent_win, "show_error_log"):
|
||
self.parent_win.show_error_log(_("Failed to generate certificate.") + f" {res.stderr}")
|
||
except Exception as e:
|
||
if hasattr(self.parent_win, "show_error_log"):
|
||
self.parent_win.show_error_log(_("Failed to generate certificate.") + f" {str(e)}")
|
||
|
||
def export_preset(self):
|
||
fp, _filter = QFileDialog.getSaveFileName(self, _("Export Preset..."), "project_config.qpypack", "QPyPack Presets (*.qpypack *.json)", options=QFileDialog.Option.DontUseNativeDialog)
|
||
if fp:
|
||
try:
|
||
data = {
|
||
"engine": self.engine_combo.currentText(),
|
||
"lite_mode": self.rb_lite_mode.isChecked(),
|
||
"onefile": self.rb_onefile.isChecked(),
|
||
"noconsole": self.noconsole_check.isChecked(),
|
||
"contents_dir": self.contents_dir_edit.text().strip(),
|
||
"icon": self.icon_edit.text(),
|
||
"app_name": self.name_edit.text(),
|
||
"ver_ver": self.ver_ver.text(),
|
||
"ver_comp": self.ver_comp.text(),
|
||
"ver_desc": self.ver_desc.text(),
|
||
"hidden_imports": self.hidden_edit.text(),
|
||
"exclude_modules": self.exclude_edit.text(),
|
||
"use_venv": self.venv_check.isChecked(),
|
||
"keep_venv": self.keep_venv_check.isChecked(),
|
||
"use_reqs": self.reqs_check.isChecked(),
|
||
"use_pipreqs": self.pipreqs_check.isChecked(),
|
||
"use_pipreqs_dir": self.pipreqs_dir_check.isChecked(),
|
||
"reqs_file": self.reqs_file_edit.text(),
|
||
"pip_source": self._get_url_from_combo(self.pip_source_combo),
|
||
"pip_backup": self._get_url_from_combo(self.pip_backup_combo),
|
||
"add_data_list": [self.add_data_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.add_data_list.count())]
|
||
}
|
||
Path(fp).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
|
||
if hasattr(self.parent_win, "show_notification"):
|
||
self.parent_win.show_notification(_("Config preset exported to: {path}", path=fp))
|
||
except Exception as e:
|
||
if hasattr(self.parent_win, "show_error_log"):
|
||
self.parent_win.show_error_log(_("[ERROR] Failed to export preset file: {error}", error=str(e)))
|
||
|
||
def import_preset(self):
|
||
fp, _filter = QFileDialog.getOpenFileName(self, _("Import Preset..."), "", "QPyPack Presets (*.qpypack *.json);;All Files (*)", options=QFileDialog.Option.DontUseNativeDialog)
|
||
if fp:
|
||
try:
|
||
data = json.loads(Path(fp).read_text(encoding='utf-8'))
|
||
if "engine" in data: self.engine_combo.setCurrentText(data["engine"])
|
||
if "lite_mode" in data:
|
||
self.rb_lite_mode.setChecked(bool(data["lite_mode"]))
|
||
self.rb_compat_mode.setChecked(not bool(data["lite_mode"]))
|
||
if "onefile" in data:
|
||
if data["onefile"]:
|
||
self.rb_onefile.setChecked(True)
|
||
else:
|
||
self.rb_onedir.setChecked(True)
|
||
if "contents_dir" in data:
|
||
self.contents_dir_edit.setText(data["contents_dir"])
|
||
self.on_mode_type_changed()
|
||
if "noconsole" in data: self.noconsole_check.setChecked(bool(data["noconsole"]))
|
||
if "icon" in data: self.icon_edit.setText(data["icon"])
|
||
if "app_name" in data: self.name_edit.setText(data["app_name"])
|
||
if "ver_ver" in data: self.ver_ver.setText(data["ver_ver"])
|
||
if "ver_comp" in data: self.ver_comp.setText(data["ver_comp"])
|
||
if "ver_desc" in data: self.ver_desc.setText(data["ver_desc"])
|
||
if "hidden_imports" in data: self.hidden_edit.setText(data["hidden_imports"])
|
||
if "exclude_modules" in data: self.exclude_edit.setText(data["exclude_modules"])
|
||
if "use_venv" in data:
|
||
self.venv_check.setChecked(bool(data["use_venv"]))
|
||
self.keep_venv_check.setEnabled(bool(data["use_venv"]))
|
||
if "keep_venv" in data: self.keep_venv_check.setChecked(bool(data["keep_venv"]))
|
||
if "use_reqs" in data: self.reqs_check.setChecked(bool(data["use_reqs"]))
|
||
if "use_pipreqs" in data: self.pipreqs_check.setChecked(bool(data["use_pipreqs"]))
|
||
if "use_pipreqs_dir" in data: self.pipreqs_dir_check.setChecked(bool(data["use_pipreqs_dir"]))
|
||
if "reqs_file" in data: self.reqs_file_edit.setText(data["reqs_file"])
|
||
if "pip_source" in data: self._set_combo_value(self.pip_source_combo, data["pip_source"])
|
||
if "pip_backup" in data: self._set_combo_value(self.pip_backup_combo, data["pip_backup"])
|
||
if "add_data_list" in data:
|
||
self.add_data_list.clear()
|
||
for item in data["add_data_list"]:
|
||
if isinstance(item, (list, tuple)) and len(item) == 3:
|
||
self._add_resource_item(item[0], item[1], item[2])
|
||
if hasattr(self.parent_win, "show_notification"):
|
||
self.parent_win.show_notification(_("Config preset imported successfully."))
|
||
except Exception as e:
|
||
if hasattr(self.parent_win, "show_error_log"):
|
||
self.parent_win.show_error_log(_("[ERROR] Preset file format error or corrupted: {error}", error=str(e)))
|
||
|
||
def load_from_config(self):
|
||
config = load_config()
|
||
if 'Settings' in config:
|
||
s = config['Settings']
|
||
lang_code = s.get('language', 'auto')
|
||
for i in range(self.lang_combo.count()):
|
||
if self.lang_combo.itemData(i) == lang_code:
|
||
self.lang_combo.setCurrentIndex(i); break
|
||
|
||
self.engine_combo.setCurrentText(s.get('engine', 'PyInstaller'))
|
||
|
||
is_lite = s.getboolean('lite_mode', False)
|
||
self.rb_lite_mode.setChecked(is_lite)
|
||
self.rb_compat_mode.setChecked(not is_lite)
|
||
|
||
is_onefile = s.getboolean('onefile', True)
|
||
if is_onefile:
|
||
self.rb_onefile.setChecked(True)
|
||
else:
|
||
self.rb_onedir.setChecked(True)
|
||
self.contents_dir_edit.setText(s.get('contents_dir', '_internal'))
|
||
self.on_mode_type_changed()
|
||
self.noconsole_check.setChecked(s.getboolean('noconsole', True))
|
||
self.clean_all_check.setChecked(s.getboolean('clean_all', True))
|
||
self.auto_icon_check.setChecked(s.getboolean('auto_icon', True))
|
||
|
||
pip_main = s.get('pip_index', 'https://pypi.org/simple')
|
||
self._set_combo_value(self.pip_source_combo, pip_main)
|
||
|
||
pip_backup = s.get('pip_index_backup', 'https://test.pypi.org/simple')
|
||
self._set_combo_value(self.pip_backup_combo, pip_backup)
|
||
|
||
self.venv_check.setChecked(s.getboolean('use_venv', True))
|
||
|
||
self.keep_venv_check.setChecked(s.getboolean('keep_venv', False))
|
||
self.keep_venv_check.setEnabled(self.venv_check.isChecked())
|
||
|
||
venv_mode = s.get('venv_mode', 'isolated')
|
||
if venv_mode == 'shared':
|
||
self.rb_venv_shared.setChecked(True)
|
||
else:
|
||
self.rb_venv_isolated.setChecked(True)
|
||
self.shared_venv_dir_edit.setText(s.get('shared_venv_dir', ''))
|
||
|
||
self.reqs_check.setChecked(s.getboolean('use_reqs', True))
|
||
self.pipreqs_check.setChecked(s.getboolean('use_pipreqs', True))
|
||
self.pipreqs_dir_check.setChecked(s.getboolean('use_pipreqs_dir', False))
|
||
self.reqs_file_edit.setText(s.get('use_reqs_file', ''))
|
||
self.python_path_combo.setCurrentText(s.get('custom_python_path', ''))
|
||
|
||
if self.upx_check:
|
||
self.upx_check.setChecked(s.getboolean('upx', False))
|
||
self.on_upx_toggled(self.upx_check.isChecked())
|
||
self.upx_path_edit.setText(s.get('upx_path', ''))
|
||
self.cores_spin.setValue(s.getint('cpu_cores', os.cpu_count() or 2))
|
||
self.exclude_edit.setText(s.get('exclude_modules', ''))
|
||
self.out_mode_combo.setCurrentIndex(int(s.get('out_mode', '0')))
|
||
self.out_dir_edit.setText(s.get('custom_out_dir', ''))
|
||
self.sandbox_mode_combo.setCurrentIndex(int(s.get('temp_sandbox_mode', '0')))
|
||
self.on_out_mode_changed(self.out_mode_combo.currentIndex())
|
||
|
||
self.concise_log_check.setChecked(s.getboolean('concise_log', True))
|
||
self.sound_notify_check.setChecked(s.getboolean('sound_notify', True))
|
||
self.auto_save_log_check.setChecked(s.getboolean('auto_save_log', False))
|
||
|
||
self.pyi_ver_edit.setText(s.get('pyi_version', '6.21.0'))
|
||
self.nuitka_ver_edit.setText(s.get('nuitka_version', '4.1.3'))
|
||
|
||
default_sign_on_os = True if sys.platform == 'darwin' else False
|
||
self.enable_sign_check.setChecked(s.getboolean('enable_sign', default_sign_on_os))
|
||
|
||
self.cert_path_edit.setText(s.get('cert_path', ''))
|
||
self.cert_pass_edit.setText(s.get('cert_pass', ''))
|
||
if self.cert_path_edit.text():
|
||
self.adv_sign_container.setVisible(True)
|
||
self.btn_toggle_adv_sign.setText(_("Hide Custom Cert Settings ▾"))
|
||
|
||
self.add_data_list.clear()
|
||
|
||
if 'Mappings' in config:
|
||
self.populate_mapping_table(dict(config['Mappings']))
|
||
|
||
if 'BackportRules' in config:
|
||
self.populate_backport_table(dict(config['BackportRules']))
|
||
self.enable_shield_check.setChecked(s.getboolean('enable_backport_shield', True))
|
||
|
||
def save_to_config(self):
|
||
config = load_config()
|
||
if 'Settings' not in config: config['Settings'] = {}
|
||
s = config['Settings']
|
||
|
||
idx = self.lang_combo.currentIndex()
|
||
new_lang = self.lang_combo.itemData(idx)
|
||
s['language'] = new_lang
|
||
|
||
s['engine'] = self.engine_combo.currentText()
|
||
s['lite_mode'] = str(self.rb_lite_mode.isChecked())
|
||
|
||
s['onefile'] = str(self.rb_onefile.isChecked())
|
||
s['contents_dir'] = self.contents_dir_edit.text().strip() or '_internal'
|
||
s['noconsole'] = str(self.noconsole_check.isChecked())
|
||
s['clean_all'] = str(self.clean_all_check.isChecked())
|
||
s['auto_icon'] = str(self.auto_icon_check.isChecked())
|
||
|
||
s['pip_index'] = self._get_url_from_combo(self.pip_source_combo)
|
||
s['pip_index_backup'] = self._get_url_from_combo(self.pip_backup_combo)
|
||
|
||
s['use_venv'] = str(self.venv_check.isChecked())
|
||
s['keep_venv'] = str(self.keep_venv_check.isChecked())
|
||
s['venv_mode'] = 'shared' if self.rb_venv_shared.isChecked() else 'isolated'
|
||
s['shared_venv_dir'] = self.shared_venv_dir_edit.text().strip()
|
||
s['use_reqs'] = str(self.reqs_check.isChecked())
|
||
s['use_pipreqs'] = str(self.pipreqs_check.isChecked())
|
||
s['use_pipreqs_dir'] = str(self.pipreqs_dir_check.isChecked())
|
||
s['use_reqs_file'] = self.reqs_file_edit.text().strip()
|
||
|
||
raw_py = self.python_path_combo.currentText().strip()
|
||
if " (Python " in raw_py: raw_py = raw_py.split(" (Python ")[0].strip()
|
||
s['custom_python_path'] = raw_py
|
||
|
||
if self.upx_check: s['upx'] = str(self.upx_check.isChecked())
|
||
s['upx_path'] = self.upx_path_edit.text().strip()
|
||
s['cpu_cores'] = str(self.cores_spin.value())
|
||
s['exclude_modules'] = self.exclude_edit.text().strip()
|
||
s['out_mode'] = str(self.out_mode_combo.currentIndex())
|
||
s['custom_out_dir'] = self.out_dir_edit.text().strip()
|
||
s['temp_sandbox_mode'] = str(self.sandbox_mode_combo.currentIndex())
|
||
s['concise_log'] = str(self.concise_log_check.isChecked())
|
||
s['sound_notify'] = str(self.sound_notify_check.isChecked())
|
||
s['auto_save_log'] = str(self.auto_save_log_check.isChecked())
|
||
|
||
s['pyi_version'] = self.pyi_ver_edit.text().strip()
|
||
s['nuitka_version'] = self.nuitka_ver_edit.text().strip()
|
||
|
||
s['enable_sign'] = str(self.enable_sign_check.isChecked())
|
||
s['cert_path'] = self.cert_path_edit.text().strip()
|
||
s['cert_pass'] = self.cert_pass_edit.text().strip()
|
||
|
||
s['add_data_list'] = ''
|
||
|
||
config['Mappings'] = {}
|
||
for r in range(self.mapping_table.rowCount()):
|
||
item_k = self.mapping_table.item(r, 0)
|
||
item_v = self.mapping_table.item(r, 1)
|
||
k = item_k.text().strip() if item_k else ""
|
||
v = item_v.text().strip() if item_v else ""
|
||
if k and v: config['Mappings'][k] = v
|
||
|
||
s['enable_backport_shield'] = str(self.enable_shield_check.isChecked())
|
||
config['BackportRules'] = {}
|
||
for r in range(self.backport_table.rowCount()):
|
||
item_k = self.backport_table.item(r, 0)
|
||
item_v = self.backport_table.item(r, 1)
|
||
k = item_k.text().strip().lower() if item_k else ""
|
||
v = item_v.text().strip() if item_v else "3.4"
|
||
if k: config['BackportRules'][k] = v
|
||
|
||
save_config(config)
|
||
I18N.set_language(new_lang)
|
||
|
||
def _check_pip_mirrors(self):
|
||
sender = self.sender()
|
||
src_url = self._get_url_from_combo(self.pip_source_combo)
|
||
bak_url = self._get_url_from_combo(self.pip_backup_combo)
|
||
|
||
if src_url and bak_url and src_url.rstrip('/') == bak_url.rstrip('/'):
|
||
target_combo = self.pip_backup_combo if sender == self.pip_source_combo else self.pip_source_combo
|
||
for i in range(target_combo.count()):
|
||
item_url = target_combo.itemData(i) or target_combo.itemText(i)
|
||
if item_url and item_url.rstrip('/') != src_url.rstrip('/'):
|
||
target_combo.blockSignals(True)
|
||
target_combo.setCurrentIndex(i)
|
||
target_combo.blockSignals(False)
|
||
break
|
||
|
||
def on_engine_changed(self):
|
||
engine = self.engine_combo.currentText()
|
||
if engine == "PyInstaller":
|
||
self.engine_desc_lbl.setText(_("PyInstaller — Bundles Python interpreter and bytecode. Fast build speed, zero configuration (no C compiler needed), and excellent compatibility."))
|
||
else:
|
||
self.engine_desc_lbl.setText(_("Nuitka — Compiles source code into native C/C++ binary. Produces smaller package size, faster execution, and deep anti-decompilation protection (requires C compiler)."))
|
||
|
||
if getattr(self, 'upx_check', None) is not None and getattr(self, 'upx_path_container', None) is not None:
|
||
self.upx_check.setVisible(True)
|
||
self.upx_path_container.setVisible(self.upx_check.isChecked())
|
||
|
||
def on_python_path_changed(self, text=""):
|
||
raw_text = text or self.python_path_combo.currentText().strip()
|
||
ver_match = re.search(r'Python\s+(\d+\.\d+)', raw_text, re.I)
|
||
|
||
ver_str = ver_match.group(1) if ver_match else ""
|
||
if not ver_str:
|
||
m_path = re.search(r'python(\d)(\d+)', raw_text, re.I)
|
||
if m_path: ver_str = f"{m_path.group(1)}.{m_path.group(2)}"
|
||
|
||
self.python_desc_lbl.setStyleSheet("QLabel { background-color: #f8fafc; color: #334155; border: 1px solid #e2e8f0; border-radius: 6px; padding: 10px 14px; font-size: 12px; line-height: 1.6; }")
|
||
|
||
if ver_str:
|
||
try:
|
||
parts = [int(x) for x in ver_str.split('.')]
|
||
major, minor = parts[0], parts[1]
|
||
|
||
html = f'<div style="margin-bottom: 6px;"><b>Python {ver_str} { _("OS Support Matrix:") }</b></div>'
|
||
|
||
tag_style = "background-color:#f1f5f9; color:#475569; padding:2px 8px; border-radius:4px; font-weight:bold; font-size:10px; margin-right: 8px;"
|
||
|
||
if sys.platform == "win32":
|
||
if (major, minor) <= (3, 8):
|
||
html += '<span style="color:#16a34a; font-weight:bold;">✔ Windows 7 / 8 / 8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10 / 11</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows Server 2008+</span><br>'
|
||
html += f'<div style="margin-top: 6px;"><span style="{tag_style}">x86 / AMD64</span><span style="{tag_style}">{_("Full backward compatibility")}</span></div>'
|
||
elif (major, minor) <= (3, 10):
|
||
html += '<span style="color:#dc2626; font-weight:bold;">✖ Windows 7</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10 / 11</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows Server 2012+</span><br>'
|
||
html += f'<div style="margin-top: 6px;"><span style="{tag_style}">AMD64 / ARM64</span><span style="{tag_style}">{_("No Windows 7 Support")}</span></div>'
|
||
else:
|
||
html += '<span style="color:#dc2626; font-weight:bold;">✖ Windows 7 / 8 / 8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10 / 11</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows Server 2016+</span><br>'
|
||
html += f'<div style="margin-top: 6px;"><span style="{tag_style}">{_("* Nuitka Auto-manages C Compiler")}</span></div>'
|
||
|
||
elif sys.platform == "darwin":
|
||
if (major, minor) <= (3, 8):
|
||
html += '<span style="color:#16a34a; font-weight:bold;">✔ OS X 10.9 (Mavericks) +</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.15 (Catalina)</span><br>'
|
||
html += f'<div style="margin-top: 6px;"><span style="{tag_style}">x86_64 Intel Only</span><span style="{tag_style}">{_("Legacy macOS Compatible")}</span></div>'
|
||
elif (major, minor) <= (3, 10):
|
||
html += '<span style="color:#16a34a; font-weight:bold;">✔ macOS 10.9 (Mavericks) +</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 11 (Big Sur) +</span><br>'
|
||
html += f'<div style="margin-top: 6px;"><span style="{tag_style}">Universal2 Binary</span><span style="{tag_style}">{_("Apple Silicon (M1/M2) & Intel")}</span></div>'
|
||
else:
|
||
html += '<span style="color:#16a34a; font-weight:bold;">✔ macOS 10.13 (High Sierra) +</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 13 / 14 / 15+</span><br>'
|
||
html += f'<div style="margin-top: 6px;"><span style="{tag_style}">Universal2 Binary</span><span style="{tag_style}">{_("Apple Silicon (M-Series) & Intel")}</span></div>'
|
||
|
||
else:
|
||
if (major, minor) <= (3, 9):
|
||
html += '<span style="color:#16a34a; font-weight:bold;">✔ Ubuntu 16.04+</span> <span style="color:#16a34a; font-weight:bold;">✔ CentOS / RHEL 7+</span> <span style="color:#16a34a; font-weight:bold;">✔ Debian 9+</span><br>'
|
||
html += f'<div style="margin-top: 6px;"><span style="{tag_style}">x86_64 / aarch64</span><span style="{tag_style}">glibc >= 2.17</span></div>'
|
||
else:
|
||
html += '<span style="color:#16a34a; font-weight:bold;">✔ Ubuntu 20.04+</span> <span style="color:#16a34a; font-weight:bold;">✔ Fedora 32+</span> <span style="color:#16a34a; font-weight:bold;">✔ Debian 11+</span> <span style="color:#16a34a; font-weight:bold;">✔ RHEL 8+</span><br>'
|
||
html += f'<div style="margin-top: 6px;"><span style="{tag_style}">x86_64 / aarch64</span><span style="{tag_style}">glibc >= 2.28</span></div>'
|
||
|
||
self.python_desc_lbl.setText(html)
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
self.python_desc_lbl.setText(_('<div style="margin-bottom: 5px;"><b>Interpreter</b></div><span style="color:#6b7280;">Auto-detecting system environment and OS support matrix...</span>'))
|
||
|
||
def on_upx_toggled(self, checked):
|
||
if getattr(self, 'upx_path_container', None) is not None:
|
||
self.upx_path_container.setVisible(checked)
|
||
|
||
def on_out_mode_changed(self, index):
|
||
show_custom = (index == 1)
|
||
if getattr(self, 'out_dir_container', None) is not None:
|
||
self.out_dir_container.setVisible(show_custom)
|
||
|
||
def select_out_dir(self):
|
||
d = QFileDialog.getExistingDirectory(self, _("Target Directory:"), options=QFileDialog.Option.DontUseNativeDialog)
|
||
if d: self.out_dir_edit.setText(Path(d).resolve().as_posix())
|
||
|
||
def select_upx_path(self):
|
||
d = QFileDialog.getExistingDirectory(self, _("UPX Path:"), options=QFileDialog.Option.DontUseNativeDialog)
|
||
if d: self.upx_path_edit.setText(Path(d).resolve().as_posix())
|
||
|
||
def select_reqs_file(self):
|
||
f, _filter = QFileDialog.getOpenFileName(self, _("Requirements File:"), "", "Requirements Files (*.txt);;All Files (*)", options=QFileDialog.Option.DontUseNativeDialog)
|
||
if f: self.reqs_file_edit.setText(Path(f).resolve().as_posix())
|
||
|
||
def select_shared_venv_dir(self):
|
||
d = QFileDialog.getExistingDirectory(self, _("Select shared directory..."), options=QFileDialog.Option.DontUseNativeDialog)
|
||
if d:
|
||
self.shared_venv_dir_edit.setText(Path(d).resolve().as_posix())
|
||
|
||
def _on_shared_venv_radio_clicked(self):
|
||
if not self.shared_venv_dir_edit.text().strip():
|
||
self.select_shared_venv_dir()
|
||
if not self.shared_venv_dir_edit.text().strip():
|
||
self.rb_venv_isolated.setChecked(True)
|
||
|
||
def clean_local_venvs(self):
|
||
script_path = getattr(self.parent_win, 'script_path', '')
|
||
target_dir = Path(script_path).parent if (script_path and Path(script_path).exists()) else Path.cwd()
|
||
|
||
self.btn_clean_venvs.setEnabled(False)
|
||
self.btn_clean_venvs.setText(_("Scanning..."))
|
||
|
||
self.venv_worker = VenvCleanerTaskThread("scan", target_dir=target_dir)
|
||
self.venv_worker.scan_done.connect(self._on_venv_scan_done)
|
||
self.venv_worker.start()
|
||
|
||
def _on_venv_scan_done(self, venvs_to_remove, freed_bytes):
|
||
self.btn_clean_venvs.setEnabled(True)
|
||
self.btn_clean_venvs.setText(_("Clear Local Venvs"))
|
||
|
||
if not venvs_to_remove:
|
||
if hasattr(self.parent_win, 'show_notification'):
|
||
self.parent_win.show_notification(_("No local virtual environments found to clean."))
|
||
return
|
||
|
||
count = len(venvs_to_remove)
|
||
freed_mb = freed_bytes / (1024 * 1024)
|
||
paths_html = "<br>".join([f"• {v.name}" for v in venvs_to_remove])
|
||
|
||
msg_box = QMessageBox(self)
|
||
msg_box.setWindowTitle(_("Clear Virtual Environments"))
|
||
msg_box.setText(_(
|
||
"<b>Found {count} virtual environment(s) for the current project:</b><br>"
|
||
"<span style='color:#64748b; font-size:11px;'>{paths}</span><br><br>"
|
||
"Are you sure you want to delete them to free up disk space?",
|
||
count=count, paths=paths_html
|
||
))
|
||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||
|
||
btn_confirm = msg_box.addButton(_("Delete All"), QMessageBox.ButtonRole.AcceptRole)
|
||
btn_cancel = msg_box.addButton(_("Cancel"), QMessageBox.ButtonRole.RejectRole)
|
||
|
||
msg_box.setStyleSheet("""
|
||
QMessageBox { background-color: #ffffff; }
|
||
QLabel { color: #111827; font-size: 13px; line-height: 1.4; }
|
||
QPushButton {
|
||
background-color: #f1f5f9; color: #475569; border: 1px solid #cbd5e1;
|
||
border-radius: 6px; font-size: 12px; font-weight: bold;
|
||
padding: 6px 16px; min-width: 70px;
|
||
}
|
||
QPushButton:hover { background-color: #e2e8f0; }
|
||
""")
|
||
btn_confirm.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #D93025; color: #ffffff; border: none;
|
||
border-radius: 6px; font-size: 12px; font-weight: bold;
|
||
padding: 6px 16px; min-width: 70px;
|
||
}
|
||
QPushButton:hover { background-color: #C5221F; }
|
||
""")
|
||
|
||
msg_box.exec()
|
||
|
||
if msg_box.clickedButton() == btn_confirm:
|
||
self.btn_clean_venvs.setEnabled(False)
|
||
self.btn_clean_venvs.setText(_("Deleting..."))
|
||
|
||
self.venv_worker = VenvCleanerTaskThread("delete", venvs_to_delete=venvs_to_remove)
|
||
self.venv_worker.delete_done.connect(self._on_venv_delete_done)
|
||
self.venv_worker.start()
|
||
|
||
def _on_venv_delete_done(self, success_count, freed_mb):
|
||
self.btn_clean_venvs.setEnabled(True)
|
||
self.btn_clean_venvs.setText(_("Clear Local Venvs"))
|
||
|
||
msg = _("Successfully cleaned {count} virtual environment(s), freed {size:.1f} MB disk space.",
|
||
count=success_count, size=freed_mb)
|
||
if hasattr(self.parent_win, 'show_notification'):
|
||
self.parent_win.show_notification(msg, 6000)
|
||
|
||
def select_python_path(self):
|
||
exe_filter = "Executable (*.exe);;All Files (*)" if os.name == 'nt' else "All Files (*)"
|
||
f, _filter = QFileDialog.getOpenFileName(self, _("Interpreter:"), "", exe_filter, options=QFileDialog.Option.DontUseNativeDialog)
|
||
if f: self.python_path_combo.setCurrentText(Path(f).resolve().as_posix())
|
||
|
||
def on_download_python_clicked(self):
|
||
if hasattr(self, 'parent_win') and hasattr(self.parent_win, "_trigger_python_download_dialog"):
|
||
self.parent_win._trigger_python_download_dialog(is_missing_mode=False)
|
||
|
||
def update_icon_preview(self, path):
|
||
if path and Path(path).exists():
|
||
pixmap = QPixmap(path)
|
||
if not pixmap.isNull():
|
||
self.icon_preview.setPixmap(pixmap.scaled(28, 28, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
|
||
return
|
||
self.icon_preview.clear()
|
||
|
||
def select_icon(self):
|
||
p, _filter = QFileDialog.getOpenFileName(self, _("App Icon:"), "", "Icon Files (*.ico *.svg *.png *.icns)", options=QFileDialog.Option.DontUseNativeDialog)
|
||
if p: self.icon_edit.setText(Path(p).resolve().as_posix())
|
||
|
||
def auto_scan_hidden(self):
|
||
script_path = self.parent_win.script_path
|
||
if not hasattr(self.parent_win, "show_notification"): return
|
||
|
||
if not script_path:
|
||
return self.parent_win.show_error_log(_("[ERROR] Please load a valid Python source file first!"))
|
||
if is_cloud_locked(script_path):
|
||
return self.parent_win.show_error_log(_("[ERROR] Target file is locked or encrypted by cloud drive. Please decrypt and try again."))
|
||
|
||
try:
|
||
# 联动界面勾选的“扫描整个文件夹”状态
|
||
scan_dir = self.pipreqs_dir_check.isChecked()
|
||
target = Path(script_path).parent if scan_dir else Path(script_path)
|
||
hidden = extract_project_imports_via_ast(target, scan_dir=scan_dir)
|
||
hidden = [m for m in hidden if m not in STD_LIBS]
|
||
self.hidden_edit.setText(','.join(sorted(hidden)))
|
||
self.parent_win.show_notification(_("AST scan completed, found {count} dependencies.", count=len(hidden)))
|
||
except Exception as e:
|
||
self.parent_win.show_error_log(_("[ERROR] Exception occurred during AST parsing: {error}", error=str(e)))
|
||
|
||
def on_resources_dropped(self, paths):
|
||
for p_str in paths:
|
||
p = Path(p_str).resolve()
|
||
if p.is_file():
|
||
self._add_resource_item('file', p.as_posix(), ".")
|
||
elif p.is_dir():
|
||
self._add_resource_item('dir', p.as_posix(), p.name)
|
||
|
||
def add_resource_files(self):
|
||
files, _filter = QFileDialog.getOpenFileNames(self, _("Add File"), "", "All Files (*)", options=QFileDialog.Option.DontUseNativeDialog)
|
||
for f in files:
|
||
src = Path(f).resolve().as_posix()
|
||
self._add_resource_item('file', src, ".")
|
||
|
||
def add_resource_dir(self):
|
||
folder = QFileDialog.getExistingDirectory(self, _("Add Dir"), options=QFileDialog.Option.DontUseNativeDialog)
|
||
if folder:
|
||
src = Path(folder).resolve().as_posix()
|
||
dst = Path(folder).name
|
||
self._add_resource_item('dir', src, dst)
|
||
|
||
def _add_resource_item(self, r_type, src, dst):
|
||
tag = _("File") if r_type == 'file' else _("Directory")
|
||
display_text = f"[{tag}] {src} -> {dst}"
|
||
item = QListWidgetItem(display_text)
|
||
item.setData(Qt.ItemDataRole.UserRole, (r_type, src, dst))
|
||
self.add_data_list.addItem(item)
|
||
|
||
def _show_lineedit_menu(self, line_edit, pos):
|
||
menu = QMenu(line_edit)
|
||
|
||
act_undo = menu.addAction(_("Undo"))
|
||
act_undo.triggered.connect(line_edit.undo)
|
||
act_undo.setEnabled(line_edit.isUndoAvailable())
|
||
|
||
menu.addSeparator()
|
||
|
||
act_cut = menu.addAction(_("Cut"))
|
||
act_cut.triggered.connect(line_edit.cut)
|
||
act_cut.setEnabled(line_edit.hasSelectedText() and not line_edit.isReadOnly())
|
||
|
||
act_copy = menu.addAction(_("Copy"))
|
||
act_copy.triggered.connect(line_edit.copy)
|
||
act_copy.setEnabled(line_edit.hasSelectedText())
|
||
|
||
act_paste = menu.addAction(_("Paste"))
|
||
act_paste.triggered.connect(line_edit.paste)
|
||
act_paste.setEnabled(not line_edit.isReadOnly())
|
||
|
||
menu.addSeparator()
|
||
|
||
act_select_all = menu.addAction(_("Select All"))
|
||
act_select_all.triggered.connect(line_edit.selectAll)
|
||
|
||
menu.exec(line_edit.mapToGlobal(pos))
|
||
|
||
def edit_resource(self, item=None):
|
||
if not item: item = self.add_data_list.currentItem()
|
||
if not item: return
|
||
r_type, src, dst = item.data(Qt.ItemDataRole.UserRole)
|
||
|
||
dlg = CustomInputDialog(self, _("Edit Path"), _("Target relative path:"), text=dst)
|
||
if dlg.exec() == QDialog.DialogCode.Accepted:
|
||
new_dst = dlg.get_text().strip().replace('\\', '/')
|
||
if not new_dst: new_dst = "."
|
||
item.setData(Qt.ItemDataRole.UserRole, (r_type, src, new_dst))
|
||
tag = _("File") if r_type == 'file' else _("Directory")
|
||
item.setText(f"[{tag}] {src} -> {new_dst}")
|
||
|
||
def del_resource(self):
|
||
for item in self.add_data_list.selectedItems():
|
||
self.add_data_list.takeItem(self.add_data_list.row(item))
|
||
|
||
def clear_resource(self):
|
||
self.add_data_list.clear()
|
||
|
||
def reset_global_config(self):
|
||
msg_box = QMessageBox(self)
|
||
msg_box.setWindowTitle(_("Reset to Default Config"))
|
||
msg_box.setText(_(
|
||
"<b>Are you sure you want to reset all preferences?</b><br>"
|
||
"<span style='color:#64748b; font-size:12px;'>"
|
||
"All settings will be restored to default state."
|
||
"</span>"
|
||
))
|
||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||
|
||
btn_confirm = msg_box.addButton(_("Reset"), QMessageBox.ButtonRole.AcceptRole)
|
||
btn_cancel = msg_box.addButton(_("Cancel"), QMessageBox.ButtonRole.RejectRole)
|
||
|
||
msg_box.setStyleSheet("""
|
||
QMessageBox { background-color: #ffffff; }
|
||
QLabel { color: #111827; font-size: 13px; line-height: 1.4; }
|
||
QPushButton {
|
||
background-color: #f1f5f9; color: #475569; border: 1px solid #cbd5e1;
|
||
border-radius: 6px; font-size: 12px; font-weight: bold;
|
||
padding: 6px 16px; min-width: 70px;
|
||
}
|
||
QPushButton:hover { background-color: #e2e8f0; }
|
||
""")
|
||
btn_confirm.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #D93025; color: #ffffff; border: none;
|
||
border-radius: 6px; font-size: 12px; font-weight: bold;
|
||
padding: 6px 16px; min-width: 70px;
|
||
}
|
||
QPushButton:hover { background-color: #C5221F; }
|
||
QPushButton:pressed { background-color: #A50E0E; }
|
||
""")
|
||
|
||
msg_box.exec()
|
||
|
||
if msg_box.clickedButton() == btn_confirm:
|
||
if os.path.exists(CONFIG_FILE):
|
||
try:
|
||
os.remove(CONFIG_FILE)
|
||
except Exception:
|
||
pass
|
||
|
||
load_config()
|
||
self.load_from_config()
|
||
|
||
if hasattr(self.parent_win, "show_notification"):
|
||
self.parent_win.show_notification(_("Global configuration has been reset."))
|
||
|
||
class ScriptAnalysisThread(QThread):
|
||
analysis_done = Signal(str, str, str, str, set)
|
||
|
||
def __init__(self, path):
|
||
super().__init__()
|
||
self.path = path
|
||
|
||
def run(self):
|
||
if self.isInterruptionRequested(): return
|
||
|
||
app_name = Path(self.path).stem
|
||
version = ""
|
||
author = "Independent Developer"
|
||
desc = "Desktop Application"
|
||
script_imports = set()
|
||
|
||
try:
|
||
content = safe_read_text(Path(self.path))[:10240]
|
||
|
||
v_match = re.search(r'^(?:__version__|VERSION|version)\s*=\s*[\'"]([^\'"]+)[\'"]', content, re.M | re.I)
|
||
|
||
if v_match: version = v_match.group(1)
|
||
|
||
c_match = re.search(r'^(?:__company__|COMPANY)\s*=\s*[\'"]([^\'"]+)[\'"]', content, re.M | re.I)
|
||
if c_match:
|
||
author = c_match.group(1)
|
||
else:
|
||
a_match = re.search(r'^(?:__author__|AUTHOR)\s*=\s*[\'"]([^\'"]+)[\'"]', content, re.M | re.I)
|
||
if a_match: author = a_match.group(1)
|
||
|
||
n_match = re.search(r'^(?:__title__|__app_name__|APP_NAME)\s*=\s*[\'"]([^\'"]+)[\'"]', content, re.M | re.I)
|
||
if n_match: app_name = n_match.group(1)
|
||
|
||
d_match = re.search(r'^(?:__description__|DESCRIPTION)\s*=\s*[\'"]([^\'"]+)[\'"]', content, re.M | re.I)
|
||
if d_match: desc = d_match.group(1)
|
||
except: pass
|
||
|
||
if self.isInterruptionRequested(): return
|
||
|
||
try:
|
||
script_imports = extract_project_imports_via_ast(Path(self.path), False)
|
||
except: pass
|
||
|
||
if self.isInterruptionRequested(): return
|
||
|
||
self.analysis_done.emit(app_name, version, author, desc, script_imports)
|
||
|
||
class PackingThread(QThread):
|
||
progress = Signal(str)
|
||
build_finished = Signal(bool, str, list)
|
||
|
||
def __init__(self, params):
|
||
super().__init__()
|
||
self.params = params
|
||
self.process = None
|
||
self._is_cancelled = False
|
||
self.venv_dir = None
|
||
self.temp_workpath = None
|
||
self.temp_out_dir = None
|
||
self.temp_dist_dir = None
|
||
self.all_raw_logs = []
|
||
|
||
def cancel(self):
|
||
self._is_cancelled = True
|
||
if not self.process:
|
||
return
|
||
|
||
try:
|
||
if os.name == "nt":
|
||
subprocess.run(
|
||
["taskkill", "/F", "/T", "/PID", str(self.process.pid)],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
timeout=3,
|
||
creationflags=subprocess.CREATE_NO_WINDOW
|
||
)
|
||
else:
|
||
import signal
|
||
try:
|
||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||
except ProcessLookupError:
|
||
pass
|
||
|
||
try:
|
||
self.process.wait(timeout=PROCESS_KILL_TIMEOUT)
|
||
except subprocess.TimeoutExpired:
|
||
try:
|
||
self.process.kill()
|
||
self.process.wait(timeout=2)
|
||
except Exception as e:
|
||
logger.warning(f"Force kill failed: {e}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Process cancel failed: {e}\n{traceback.format_exc()}")
|
||
finally:
|
||
self._is_cancelled = True
|
||
|
||
def run_cmd(self, cmd, cwd=None, timeout=None, silent_error=False, extra_env=None):
|
||
if self._is_cancelled: return False
|
||
|
||
timer = None
|
||
is_timeout = [False]
|
||
|
||
clean_env = os.environ.copy()
|
||
clean_env.pop("PYTHONHOME", None)
|
||
|
||
for c_var in ("VIRTUAL_ENV", "CONDA_PREFIX", "CONDA_DEFAULT_ENV", "CONDA_PYTHON_EXE", "CONDA_PROMPT_MODIFIER"):
|
||
clean_env.pop(c_var, None)
|
||
|
||
if not (extra_env and "PYTHONPATH" in extra_env):
|
||
if not ("PYTHONPATH" in clean_env and "_qpypack_temp_" in clean_env["PYTHONPATH"]):
|
||
clean_env.pop("PYTHONPATH", None)
|
||
|
||
clean_env["PYTHONUTF8"] = "1"
|
||
clean_env["PYTHONIOENCODING"] = "utf-8"
|
||
clean_env["LANG"] = "en_US.UTF-8"
|
||
clean_env["LC_ALL"] = "en_US.UTF-8"
|
||
clean_env["PYTHONUNBUFFERED"] = "1"
|
||
clean_env["PLAYWRIGHT_BROWSERS_PATH"] = "0"
|
||
clean_env["NUITKA_ACCEPT_DOWNLOADS"] = "yes"
|
||
|
||
if extra_env:
|
||
clean_env.update(extra_env)
|
||
|
||
try:
|
||
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.STDOUT, "cwd": cwd,
|
||
"text": True, "encoding": "utf-8", "errors": "replace", "env": clean_env}
|
||
if os.name == 'nt':
|
||
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
else:
|
||
kwargs["start_new_session"] = True
|
||
|
||
self.process = subprocess.Popen(cmd, **kwargs)
|
||
|
||
if timeout:
|
||
def kill_proc():
|
||
is_timeout[0] = True
|
||
try:
|
||
if os.name == "nt":
|
||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(self.process.pid)],
|
||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=subprocess.CREATE_NO_WINDOW)
|
||
else:
|
||
import signal
|
||
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
||
except: pass
|
||
timer = threading.Timer(timeout, kill_proc)
|
||
timer.start()
|
||
|
||
buffer = []
|
||
last_emit = time.time()
|
||
|
||
for line in self.process.stdout:
|
||
if self._is_cancelled:
|
||
try:
|
||
if self.process:
|
||
self.process.terminate()
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
stripped = line.rstrip('\r\n')
|
||
self.all_raw_logs.append(stripped)
|
||
|
||
if silent_error:
|
||
continue
|
||
|
||
buffer.append(stripped)
|
||
if len(buffer) >= 15 or (time.time() - last_emit) > 0.1:
|
||
self.progress.emit('\n'.join(buffer))
|
||
buffer.clear()
|
||
last_emit = time.time()
|
||
|
||
if buffer and not silent_error:
|
||
self.progress.emit('\n'.join(buffer))
|
||
self.process.wait()
|
||
|
||
if is_timeout[0]:
|
||
self.progress.emit(_("[WARN] Command timeout (>{timeout}s)", timeout=timeout))
|
||
return False
|
||
|
||
return self.process.returncode == 0
|
||
except FileNotFoundError as e:
|
||
error_msg = _("[ERROR] Process error: command or binary missing ({error})", error=str(e))
|
||
self.progress.emit(error_msg)
|
||
logger.error(f"Command not found: {cmd[0] if cmd else 'unknown'}, Error: {e}")
|
||
return False
|
||
except subprocess.TimeoutExpired as e:
|
||
self.progress.emit(_("[WARN] Command timeout (>{timeout}s)", timeout=timeout))
|
||
logger.warning(f"Command timeout: {' '.join(cmd[:3])}, Timeout: {timeout}s")
|
||
return False
|
||
except Exception as e:
|
||
error_msg = _("[ERROR] System execution exception: {error}", error=str(e))
|
||
self.progress.emit(error_msg)
|
||
logger.error(f"Unexpected error in run_cmd: {e}\n{traceback.format_exc()}")
|
||
return False
|
||
finally:
|
||
if timer: timer.cancel()
|
||
if self.process:
|
||
if self.process.stdout:
|
||
try:
|
||
self.process.stdout.close()
|
||
except Exception as e:
|
||
logger.debug(f"Failed to close stdout: {e}")
|
||
try:
|
||
self.process.wait(timeout=5)
|
||
except Exception as e:
|
||
logger.debug(f"Process wait failed: {e}")
|
||
|
||
def run_pip_install(self, python_exe, pkgs_or_args):
|
||
primary_idx = self.params.get('pip_index_url', '').strip()
|
||
backup_idx = self.params.get('pip_index_backup', '').strip()
|
||
|
||
pip_args = [
|
||
"--default-timeout=120",
|
||
"--disable-pip-version-check",
|
||
"--prefer-binary"
|
||
]
|
||
if primary_idx: pip_args.extend(["-i", primary_idx])
|
||
if backup_idx and backup_idx != primary_idx: pip_args.extend(["--extra-index-url", backup_idx])
|
||
|
||
cmd = [python_exe, "-m", "pip", "install"] + pkgs_or_args + pip_args
|
||
success = self.run_cmd(cmd)
|
||
|
||
if not success and not self.params.get('use_venv') and not self._is_cancelled:
|
||
self.progress.emit(_("[WARN] System Python write permission restricted. Retrying with '--user' mode..."))
|
||
user_cmd = [python_exe, "-m", "pip", "install", "--user"] + pkgs_or_args + pip_args
|
||
success = self.run_cmd(user_cmd)
|
||
|
||
if not success and backup_idx and backup_idx != primary_idx:
|
||
self.progress.emit(_("[INFO] Switching to backup PyPI source for retrieval: {url}", url=backup_idx))
|
||
fallback_cmd = [python_exe, "-m", "pip", "install"] + pkgs_or_args + ["-i", backup_idx, "--disable-pip-version-check", "--prefer-binary"]
|
||
if not self.params.get('use_venv'):
|
||
fallback_cmd.insert(4, "--user")
|
||
success = self.run_cmd(fallback_cmd)
|
||
|
||
return success
|
||
|
||
def sanitize_script(self, orig_path: Path):
|
||
if is_cloud_locked(orig_path):
|
||
return None, False, _("[ERROR] Target file is locked or encrypted by cloud drive. Please decrypt and try again.")
|
||
|
||
if not self.params['noconsole']:
|
||
try:
|
||
code = safe_read_text(orig_path)
|
||
|
||
pause_prompt_str = _("\\nProgram execution completed, press Enter to exit...").replace('\\n', '\n')
|
||
|
||
pause_code = (
|
||
"# --- QPyPack Auto-injected Console Pause ---\n"
|
||
"import atexit\n"
|
||
"def _qpypack_pause():\n"
|
||
" try:\n"
|
||
" import sys\n"
|
||
" if sys.platform == 'win32':\n"
|
||
" import ctypes\n"
|
||
" if ctypes.windll.kernel32.GetConsoleProcessList((ctypes.c_uint * 10)(), 10) <= 2:\n"
|
||
f" input({repr(pause_prompt_str)})\n"
|
||
" except:\n"
|
||
" pass\n"
|
||
"atexit.register(_qpypack_pause)\n"
|
||
"# -------------------------------------------\n\n"
|
||
)
|
||
|
||
temp_file = orig_path.parent / f"_qpypack_temp_{orig_path.name}"
|
||
try:
|
||
temp_file.write_text(pause_code + code, encoding='utf-8')
|
||
except PermissionError:
|
||
temp_file = Path(tempfile.gettempdir()) / f"_qpypack_temp_{orig_path.name}"
|
||
temp_file.write_text(pause_code + code, encoding='utf-8')
|
||
os.environ["PYTHONPATH"] = orig_path.parent.as_posix() + os.pathsep + os.environ.get("PYTHONPATH", "")
|
||
|
||
return temp_file, True, ""
|
||
except Exception as e:
|
||
self.progress.emit(_("[WARN] Pause code injection exception: {error}", error=str(e)))
|
||
|
||
return orig_path, False, ""
|
||
|
||
def detect_python_syntax_errors(self):
|
||
script_path = self.params['script_path']
|
||
script_name = Path(script_path).name
|
||
log_text = "\n".join(self.all_raw_logs)
|
||
|
||
file_line_pat = re.compile(r'File "([^"]+)", line (\d+)', re.I)
|
||
err_type_pat = re.compile(r'^(IndentationError|SyntaxError|TabError):\s*(.*)', re.M)
|
||
|
||
err_matches = list(err_type_pat.finditer(log_text))
|
||
if err_matches:
|
||
last_err = err_matches[-1]
|
||
err_type = last_err.group(1)
|
||
err_desc = last_err.group(2)
|
||
|
||
err_pos = last_err.start()
|
||
line_no = _("Unknown")
|
||
file_name = script_name
|
||
|
||
file_line_matches = list(file_line_pat.finditer(log_text))
|
||
for m in reversed(file_line_matches):
|
||
if m.end() < err_pos:
|
||
matched_filepath = m.group(1)
|
||
if matched_filepath.endswith(('.py', '.pyw')):
|
||
line_no = m.group(2)
|
||
file_name = Path(matched_filepath).name
|
||
break
|
||
|
||
return {
|
||
"is_code_error": True,
|
||
"type": err_type,
|
||
"desc": err_desc,
|
||
"line": line_no,
|
||
"file": file_name
|
||
}
|
||
return {"is_code_error": False}
|
||
|
||
def auto_sign_executable(self, target_path: Path) -> bool:
|
||
is_macos = (sys.platform == 'darwin')
|
||
enable_sign_setting = self.params.get('enable_sign', False)
|
||
|
||
if not is_macos and not enable_sign_setting:
|
||
return True
|
||
|
||
self.progress.emit(_("[INFO] Applying smart digital signature..."))
|
||
|
||
if os.name == 'nt':
|
||
if target_path.is_file():
|
||
for _wait in range(6):
|
||
try:
|
||
with open(target_path, 'r+b'):
|
||
break
|
||
except (IOError, OSError):
|
||
time.sleep(0.5)
|
||
|
||
cert_path = self.params.get('cert_path', '').strip()
|
||
cert_pass = self.params.get('cert_pass', '').strip()
|
||
|
||
targets = []
|
||
if target_path.is_dir():
|
||
targets.extend(list(target_path.rglob("*.exe")))
|
||
targets.extend(list(target_path.rglob("*.dll")))
|
||
targets.extend(list(target_path.rglob("*.pyd")))
|
||
else:
|
||
targets.append(target_path)
|
||
|
||
if not targets:
|
||
return True
|
||
|
||
tsa_servers = [
|
||
"http://timestamp.digicert.com",
|
||
"http://ts.ssl.com",
|
||
"http://timestamp.sectigo.com",
|
||
"http://timestamp.acs.microsoft.com",
|
||
"http://timestamp.comodoca.com",
|
||
]
|
||
|
||
signtool_exe = self._find_signtool()
|
||
success_all = True
|
||
|
||
for target_file in targets:
|
||
target_str = target_file.resolve().as_posix()
|
||
signed_with_ts = False
|
||
|
||
if signtool_exe:
|
||
signed_with_ts = self._sign_with_signtool(
|
||
signtool_exe, target_str, cert_path, cert_pass, tsa_servers
|
||
)
|
||
if signed_with_ts and target_file == targets[0]:
|
||
self.progress.emit(_("[INFO] Applied digital signature with timestamp ({tsa})...", tsa="RFC3161/signtool"))
|
||
|
||
if not signed_with_ts:
|
||
signed_with_ts = self._sign_with_powershell(
|
||
target_str, cert_path, cert_pass, tsa_servers, is_first=(target_file == targets[0])
|
||
)
|
||
|
||
if not signed_with_ts:
|
||
success_all = False
|
||
|
||
if success_all:
|
||
self.progress.emit(_("[SUCCESS] Digital signature applied successfully!"))
|
||
return True
|
||
else:
|
||
self.progress.emit(_("[WARN] Smart signing failed, but build output is retained."))
|
||
return False
|
||
|
||
elif is_macos:
|
||
target_posix = target_path.resolve().as_posix()
|
||
cmd = ["codesign", "--force", "--deep", "--options", "runtime", "--sign", "-", target_posix]
|
||
|
||
ok = self.run_cmd(cmd, silent_error=True)
|
||
if not ok:
|
||
fallback_cmd = ["codesign", "--force", "--deep", "--sign", "-", target_posix]
|
||
ok = self.run_cmd(fallback_cmd, silent_error=True)
|
||
|
||
if ok:
|
||
self.progress.emit(_("[SUCCESS] Digital signature applied successfully!"))
|
||
return True
|
||
else:
|
||
self.progress.emit(_("[WARN] Smart signing failed, but build output is retained."))
|
||
return False
|
||
|
||
return True
|
||
|
||
def _find_signtool(self) -> str:
|
||
signtool = shutil.which("signtool") or shutil.which("signtool.exe")
|
||
if signtool:
|
||
return signtool
|
||
|
||
sdk_base = Path(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")) / "Windows Kits" / "10" / "bin"
|
||
if sdk_base.exists():
|
||
candidates = sorted(sdk_base.glob("10.*/x64/signtool.exe"), reverse=True)
|
||
if candidates:
|
||
return candidates[0].as_posix()
|
||
|
||
return ""
|
||
|
||
def _sign_with_signtool(self, signtool_exe, target_path, cert_path, cert_pass, tsa_servers) -> bool:
|
||
thumbprint = None
|
||
|
||
if not (cert_path and Path(cert_path).exists()):
|
||
company_name = self.params.get('ver_comp', '').strip() or 'QPyPack App'
|
||
safe_company = company_name.replace("'", "''").replace('"', '""')
|
||
ps_create = (
|
||
f'$subject = "CN={safe_company}"; '
|
||
'$cert = Get-ChildItem Cert:\\CurrentUser\\My -CodeSigningCert | Where-Object { $_.Subject -eq $subject } | Select-Object -First 1; '
|
||
'if (-not $cert) { $cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject $subject -CertStoreLocation Cert:\\CurrentUser\\My }; '
|
||
'Write-Output $cert.Thumbprint'
|
||
)
|
||
try:
|
||
kw = {"capture_output": True, "text": True, "timeout": 10, "errors": "ignore"}
|
||
if os.name == 'nt':
|
||
kw["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
res = subprocess.run(
|
||
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_create],
|
||
**kw
|
||
)
|
||
thumbprint = res.stdout.strip().split('\n')[-1].strip() if res.returncode == 0 else ""
|
||
if not thumbprint:
|
||
return False
|
||
except Exception:
|
||
return False
|
||
|
||
for tsa in tsa_servers:
|
||
if self._is_cancelled:
|
||
return False
|
||
|
||
if cert_path and Path(cert_path).exists():
|
||
cmd = [signtool_exe, "sign", "/f", cert_path, "/fd", "sha256",
|
||
"/tr", tsa, "/td", "sha256"]
|
||
if cert_pass:
|
||
cmd.extend(["/p", cert_pass])
|
||
cmd.append(target_path)
|
||
else:
|
||
cmd = [signtool_exe, "sign", "/sha1", thumbprint, "/fd", "sha256",
|
||
"/tr", tsa, "/td", "sha256", target_path]
|
||
|
||
if self.run_cmd(cmd, silent_error=True, timeout=30):
|
||
return True
|
||
|
||
return False
|
||
|
||
def _sign_with_powershell(self, target_path, cert_path, cert_pass, tsa_servers, is_first=False) -> bool:
|
||
company_name = self.params.get('ver_comp', '').strip()
|
||
if not company_name:
|
||
app_title = self.params.get('app_name', 'App').strip() or 'App'
|
||
company_name = f"{app_title} Developer"
|
||
|
||
safe_company = company_name.replace("'", "''").replace('"', '""')
|
||
|
||
if cert_path and Path(cert_path).exists():
|
||
safe_pwd = (cert_pass or '').replace("'", "''")
|
||
pass_part = f' -Password (ConvertTo-SecureString -String \'{safe_pwd}\' -Force -AsPlainText)' if cert_pass else ''
|
||
base_cert_script = f'$cert = Get-PfxCertificate -FilePath "{cert_path}"{pass_part}; '
|
||
else:
|
||
base_cert_script = (
|
||
f'$subject = "CN={safe_company}"; '
|
||
'$cert = Get-ChildItem Cert:\\CurrentUser\\My -CodeSigningCert | Where-Object { $_.Subject -eq $subject } | Select-Object -First 1; '
|
||
'if (-not $cert) { '
|
||
'$cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject $subject -CertStoreLocation Cert:\\CurrentUser\\My '
|
||
'}; '
|
||
)
|
||
|
||
for tsa in tsa_servers:
|
||
if self._is_cancelled:
|
||
return False
|
||
|
||
ps_cmd = (
|
||
f'{base_cert_script}'
|
||
f'$sig = Set-AuthenticodeSignature -FilePath "{target_path}" -Certificate $cert '
|
||
f'-TimestampServer "{tsa}" -HashAlgorithm SHA256; '
|
||
f'if ($sig.Status -ne "Valid" -and $sig.Status -ne "UnknownError") {{ exit 1 }}; '
|
||
f'if ($null -eq $sig.TimeStamperCertificate) {{ exit 1 }}'
|
||
)
|
||
if self.run_cmd(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd],
|
||
silent_error=True, timeout=30):
|
||
if is_first:
|
||
self.progress.emit(_("[INFO] Applied digital signature with timestamp ({tsa})...", tsa=tsa))
|
||
return True
|
||
|
||
self.progress.emit(_("[WARN] Timestamp server connection timed out, performing local fast signing..."))
|
||
|
||
ps_cmd_no_ts = (
|
||
f'{base_cert_script}'
|
||
f'$sig = Set-AuthenticodeSignature -FilePath "{target_path}" -Certificate $cert -HashAlgorithm SHA256; '
|
||
f'if ($sig.Status -ne "Valid" -and $sig.Status -ne "UnknownError") {{ exit 1 }}'
|
||
)
|
||
return self.run_cmd(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd_no_ts],
|
||
silent_error=True, timeout=15)
|
||
|
||
def run(self):
|
||
engine = self.params['engine']
|
||
app_name = self.params.get('app_name', 'app').strip() or 'app'
|
||
pip_idx = self.params.get('pip_index_url', '').strip()
|
||
pip_backup = self.params.get('pip_index_backup', '').strip()
|
||
is_temp = False
|
||
build_script_path = None
|
||
ext = ".exe" if os.name == "nt" else ""
|
||
failed_packages = []
|
||
|
||
script_dir = Path(self.params['script_path']).parent if self.params.get('script_path') else Path.cwd()
|
||
|
||
sandbox_mode = int(self.params.get('temp_sandbox_mode', 0) or 0)
|
||
if sandbox_mode == 0:
|
||
custom_temp_base = (script_dir / ".qpypack_build").resolve()
|
||
custom_temp_base.mkdir(parents=True, exist_ok=True)
|
||
temp_dir_arg = custom_temp_base.as_posix()
|
||
else:
|
||
custom_temp_base = Path(tempfile.gettempdir()).resolve()
|
||
temp_dir_arg = None
|
||
|
||
try:
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
try:
|
||
import platform
|
||
os_info = f"{platform.system()} {platform.release()} ({platform.machine()})"
|
||
py_arch = "64-bit" if sys.maxsize > 2**32 else "32-bit"
|
||
diag_msg = (
|
||
"[DETAILED_ONLY]\n"
|
||
"=== QPyPack Build Diagnostics ===\n"
|
||
f"QPyPack Ver : {__version__}\n"
|
||
f"OS Platform : {os_info}\n"
|
||
f"Host Python : {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} ({py_arch}) - {sys.executable}\n"
|
||
f"Target Py : {self.params.get('python_exe')}\n"
|
||
f"Build Engine: {engine} (OneFile: {self.params['onefile']}, NoConsole: {self.params['noconsole']}, LiteMode: {self.params.get('lite_mode')})\n"
|
||
f"Workspace : {script_dir.as_posix()}\n"
|
||
"================================="
|
||
)
|
||
self.progress.emit(diag_msg)
|
||
except Exception:
|
||
pass
|
||
|
||
self.progress.emit(_("[INFO] Analyzing source code and project dependencies..."))
|
||
script_path = Path(self.params['script_path']).resolve()
|
||
script_dir = script_path.parent
|
||
system_python_exe = self.params.get('python_exe')
|
||
|
||
# 使用选定的目标 Python 进行语法预检,避免高低版本语法特性误杀
|
||
if system_python_exe and os.path.exists(system_python_exe):
|
||
try:
|
||
kw_chk = {"capture_output": True, "text": True, "timeout": 5, "errors": "ignore"}
|
||
if os.name == 'nt': kw_chk["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
chk_syntax = subprocess.run([system_python_exe, "-m", "py_compile", script_path.as_posix()], **kw_chk)
|
||
if chk_syntax.returncode != 0:
|
||
err_desc = chk_syntax.stderr.strip() or _("Invalid syntax")
|
||
msg = _(
|
||
"[Syntax Error] Source code syntax parsing failed, build aborted:\n"
|
||
" - File: {file}\n"
|
||
" - Detail: {desc}\n\n"
|
||
"Tip: This is usually NOT a fault of the packaging tool.\n"
|
||
"Please ensure the [Build Python Version] you selected matches the version you used to [Write/Test the Code].\n"
|
||
"Using newer syntax (e.g., walrus operator :=, type unions |, match-case) in an older Python environment will trigger this error.\n"
|
||
"We recommend going to [Build Settings] -> [Engine] to switch to the correct Python version.",
|
||
file=script_path.name, desc=err_desc
|
||
)
|
||
return self.build_finished.emit(False, msg, [])
|
||
except Exception:
|
||
pass
|
||
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
self.progress.emit(_("[INFO] Performing pre-flight environment checks..."))
|
||
|
||
health_issues = []
|
||
|
||
free_disk = get_free_disk_gb(script_dir.as_posix())
|
||
if free_disk < DISK_SPACE_MIN_GB:
|
||
health_issues.append(_("Disk space critically low: {free:.1f}GB < {min:.1f}GB", free=free_disk, min=DISK_SPACE_MIN_GB))
|
||
elif free_disk < DISK_SPACE_LOW_GB:
|
||
self.progress.emit(_("[WARN] Low disk space detected: {free:.1f} GB available", free=free_disk))
|
||
|
||
free_ram = get_free_ram_gb()
|
||
if free_ram < 1.0:
|
||
health_issues.append(_("RAM critically low: {free:.1f}GB < 1GB", free=free_ram))
|
||
|
||
if not system_python_exe or not os.path.exists(system_python_exe):
|
||
health_issues.append(_("Python interpreter not found: {path}", path=system_python_exe or "None"))
|
||
|
||
if health_issues:
|
||
error_msg = _("[ERROR] Pre-flight check failed:") + "\n" + "\n".join(f" • {issue}" for issue in health_issues)
|
||
return self.build_finished.emit(False, error_msg, [])
|
||
|
||
out_mode = int(self.params.get('out_mode', 0) or 0)
|
||
custom_out = (self.params.get('custom_out_dir') or '').strip()
|
||
target_out_dir = Path(custom_out) if (out_mode == 1 and custom_out) else script_dir
|
||
try:
|
||
target_out_dir.mkdir(parents=True, exist_ok=True)
|
||
test_file = target_out_dir / ".qpypack_write_test"
|
||
test_file.write_text("test")
|
||
test_file.unlink()
|
||
except Exception as e:
|
||
return self.build_finished.emit(False, _("[ERROR] Output directory is missing write permissions: {error}", error=str(e)), [])
|
||
|
||
if self.params.get('use_reqs'):
|
||
custom_reqs = (self.params.get('reqs_file') or '').strip()
|
||
if custom_reqs and not Path(custom_reqs).exists():
|
||
return self.build_finished.emit(False, _("[ERROR] Requirements file not found: {path}", path=custom_reqs), [])
|
||
|
||
for r_type, src, dst in (self.params.get('add_data_list') or []):
|
||
if not Path(src).exists():
|
||
return self.build_finished.emit(False, _("[ERROR] Additional resource file/directory not found: {path}", path=src), [])
|
||
|
||
if is_cloud_sync_path(script_dir):
|
||
self.progress.emit(_("[WARN] Target project is in a Cloud Sync directory (e.g. OneDrive/Dropbox). Cloud sync may temporarily lock build files."))
|
||
|
||
build_script_path, is_temp, err_msg = self.sanitize_script(script_path)
|
||
if not build_script_path and err_msg:
|
||
return self.build_finished.emit(False, _("[ERROR] I/O Exception: {err_msg}", err_msg=err_msg), [])
|
||
|
||
script_posix = build_script_path.as_posix()
|
||
script_imports = set()
|
||
try:
|
||
script_imports = extract_project_imports_via_ast(Path(script_posix), False)
|
||
except Exception as e:
|
||
self.progress.emit(_("[WARN] AST Analysis Exception: {error}", error=str(e)))
|
||
|
||
known_mappings = self.params.get('mappings', DEFAULT_MAPPINGS.copy())
|
||
known_mappings_lower = {k.lower(): v for k, v in known_mappings.items()}
|
||
|
||
def get_canonical_pypi_name(raw_name):
|
||
clean = raw_name.strip().lower().replace('_', '-')
|
||
return known_mappings_lower.get(clean, clean).lower()
|
||
|
||
target_std_libs = set(STD_LIBS)
|
||
try:
|
||
cmd_std = [system_python_exe, "-c", "import sys; print(','.join(getattr(sys, 'stdlib_module_names', [])))"]
|
||
kw = {"capture_output": True, "text": True, "timeout": 3, "errors": "ignore"}
|
||
if os.name == 'nt': kw["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
res = subprocess.run(cmd_std, **kw)
|
||
if res.returncode == 0 and res.stdout.strip():
|
||
target_std_libs.update(res.stdout.strip().split(','))
|
||
except Exception:
|
||
pass
|
||
|
||
def parse_req_line(line):
|
||
line = line.strip()
|
||
if not line or line.startswith('#') or line.startswith('-'):
|
||
return None, None
|
||
m = re.match(r'^([a-zA-Z0-9_\-\.]+)(.*)$', line)
|
||
if m:
|
||
pkg_raw = m.group(1)
|
||
canon_name = get_canonical_pypi_name(pkg_raw)
|
||
if canon_name.lower() in target_std_libs:
|
||
return None, None
|
||
if canon_name.lower() != pkg_raw.lower():
|
||
full_spec = canon_name
|
||
else:
|
||
full_spec = line
|
||
return canon_name, full_spec
|
||
return None, None
|
||
|
||
final_dependencies = {}
|
||
reqs_declared_pkgs = set()
|
||
auto_detected_pkgs = set()
|
||
auto_added_supplements = set()
|
||
|
||
if self.params.get('use_reqs'):
|
||
custom_reqs = self.params.get('reqs_file', '').strip()
|
||
req_file = Path(custom_reqs) if (custom_reqs and Path(custom_reqs).exists()) else (script_dir / "requirements.txt")
|
||
if req_file.exists():
|
||
try:
|
||
if is_cloud_locked(req_file): raise ValueError("Requirements file is locked")
|
||
req_content = safe_read_text(req_file)
|
||
|
||
for line in req_content.splitlines():
|
||
canon_name, full_spec = parse_req_line(line)
|
||
|
||
if canon_name and canon_name.lower() not in target_std_libs:
|
||
final_dependencies[canon_name] = full_spec
|
||
reqs_declared_pkgs.add(canon_name)
|
||
except Exception as e:
|
||
self.progress.emit(_("[WARN] Read requirements.txt warning: {error}", error=str(e)))
|
||
|
||
local_modules = {p.stem.lower() for p in script_dir.iterdir()
|
||
if (p.is_file() and p.suffix.lower() in ('.py', '.pyw', '.pyd', '.so')) or (p.is_dir() and (p / '__init__.py').exists())}
|
||
|
||
for m in script_imports:
|
||
if m.lower() in target_std_libs or m.lower() in local_modules: continue
|
||
canon_name = get_canonical_pypi_name(m)
|
||
if canon_name.lower() in target_std_libs: continue
|
||
auto_detected_pkgs.add(canon_name)
|
||
if canon_name not in final_dependencies:
|
||
final_dependencies[canon_name] = canon_name
|
||
auto_added_supplements.add(canon_name)
|
||
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
self.progress.emit(_("[INFO] Initializing isolated build environment..."))
|
||
self.progress.emit(_("[INFO] Python interpreter path: {path}", path=system_python_exe))
|
||
|
||
if self.params['use_venv']:
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
import hashlib
|
||
|
||
target_py_ver = "3.x"
|
||
target_py_arch = "x64" if sys.maxsize > 2**32 else "x86"
|
||
try:
|
||
kw_check = {"capture_output": True, "text": True, "timeout": 4, "errors": "ignore"}
|
||
if os.name == 'nt': kw_check["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
|
||
code_info = "import sys, platform; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}|{platform.architecture()[0]}')"
|
||
res_info = subprocess.run([system_python_exe, "-c", code_info], **kw_check)
|
||
if res_info.returncode == 0 and "|" in res_info.stdout:
|
||
v_part, a_part = res_info.stdout.strip().split("|")
|
||
target_py_ver = v_part
|
||
target_py_arch = "x64" if "64" in a_part else "x86"
|
||
except Exception as e:
|
||
self.progress.emit(_("[WARN] Python environment info detection fallback: {error}", error=str(e)))
|
||
|
||
raw_stem = build_script_path.stem
|
||
clean_stem = re.sub(r'[^a-zA-Z0-9]', '', raw_stem) or "script"
|
||
short_stem = clean_stem[:12]
|
||
path_hash = hashlib.md5(script_path.resolve().as_posix().encode('utf-8')).hexdigest()[:6]
|
||
|
||
safe_venv_name = f".qpypack_venv_{short_stem}_{path_hash}_py{target_py_ver}_{target_py_arch}"
|
||
|
||
if self.params.get('keep_venv'):
|
||
if self.params.get('venv_mode') == 'shared':
|
||
shared_dir = self.params.get('shared_venv_dir', '').strip()
|
||
if shared_dir and Path(shared_dir).exists():
|
||
try:
|
||
test_file = Path(shared_dir) / ".qpypack_write_test"
|
||
test_file.touch()
|
||
test_file.unlink()
|
||
shared_venv_name = f".qpypack_shared_venv_py{target_py_ver}_{target_py_arch}"
|
||
self.venv_dir = (Path(shared_dir) / shared_venv_name).resolve()
|
||
except Exception:
|
||
self.progress.emit(_("[WARN] Shared directory lacks write permissions, falling back to isolated mode."))
|
||
self.venv_dir = (script_dir / safe_venv_name).resolve()
|
||
else:
|
||
self.progress.emit(_("[WARN] Shared directory invalid, falling back to isolated environment mode."))
|
||
self.venv_dir = (script_dir / safe_venv_name).resolve()
|
||
else:
|
||
self.venv_dir = (script_dir / safe_venv_name).resolve()
|
||
else:
|
||
self.venv_dir = Path(tempfile.mkdtemp(prefix=f"qpypack_env_{short_stem}_", dir=temp_dir_arg)).resolve()
|
||
|
||
python_exe_in_venv = (self.venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).as_posix()
|
||
|
||
is_venv_valid = False
|
||
if self.params.get('keep_venv') and self.venv_dir.exists() and Path(python_exe_in_venv).exists():
|
||
try:
|
||
kw_val = {"capture_output": True, "text": True, "timeout": 5, "errors": "ignore"}
|
||
if os.name == 'nt': kw_val["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
|
||
code_val = "import sys, pip; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')"
|
||
check_proc = subprocess.run([python_exe_in_venv, "-c", code_val], **kw_val)
|
||
|
||
if check_proc.returncode == 0 and check_proc.stdout.strip() == target_py_ver:
|
||
is_venv_valid = True
|
||
except Exception:
|
||
is_venv_valid = False
|
||
|
||
if is_venv_valid:
|
||
self.progress.emit(_("[INFO] Reusing existing virtual environment: {path}", path=self.venv_dir.name))
|
||
python_exe = python_exe_in_venv
|
||
else:
|
||
if self.venv_dir.exists():
|
||
try:
|
||
robust_rmtree(self.venv_dir)
|
||
except Exception as e:
|
||
if self.params.get('keep_venv'):
|
||
self.progress.emit(_("[WARN] Failed to delete invalid venv, attempting to rename: {error}", error=str(e)))
|
||
try:
|
||
backup_name = f"{self.venv_dir.name}_backup_{int(time.time())}"
|
||
backup_path = self.venv_dir.parent / backup_name
|
||
self.venv_dir.rename(backup_path)
|
||
self.progress.emit(_("[INFO] Old venv renamed to: {name}", name=backup_name))
|
||
except Exception as rename_err:
|
||
error_msg = _(
|
||
"[ERROR] Unable to clean up invalid virtual environment:\n"
|
||
" Path: {path}\n"
|
||
" Error: {error}\n\n"
|
||
"Please manually delete the directory or uncheck 'Keep Local Venv' and retry.",
|
||
path=self.venv_dir.as_posix(),
|
||
error=str(rename_err)
|
||
)
|
||
return self.build_finished.emit(False, error_msg, [])
|
||
else:
|
||
self.progress.emit(_("[WARN] Failed to purge invalid environment, falling back to temp sandbox: {error}", error=str(e)))
|
||
self.venv_dir = Path(tempfile.mkdtemp(prefix=f"qpypack_env_{short_stem}_", dir=temp_dir_arg)).resolve()
|
||
python_exe_in_venv = (self.venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).as_posix()
|
||
|
||
self.progress.emit(_("[INFO] Creating virtual environment ({name})...", name=self.venv_dir.name))
|
||
if not self.run_cmd([system_python_exe, "-m", "venv", self.venv_dir.as_posix()]):
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
return self.build_finished.emit(False, _("[ERROR] Failed to create virtual environment. Current Python environment might be missing necessary modules or have restricted permissions."), [])
|
||
|
||
if self.params.get('keep_venv') and self.venv_dir.exists():
|
||
try:
|
||
(self.venv_dir / ".gitignore").write_text("*\n", encoding="utf-8")
|
||
except Exception: pass
|
||
|
||
python_exe = python_exe_in_venv
|
||
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
self.progress.emit(_("[INFO] Synchronizing and upgrading pip package manager..."))
|
||
self.run_pip_install(python_exe, ["--upgrade", "pip", "-q"])
|
||
else:
|
||
python_exe = system_python_exe
|
||
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
self.progress.emit(_("[INFO] Scanning project source code via AST engine..."))
|
||
|
||
scan_dir_mode = self.params.get('use_pipreqs_dir', False)
|
||
if scan_dir_mode:
|
||
ast_discovered_imports = extract_project_imports_via_ast(script_dir, scan_dir=True)
|
||
else:
|
||
ast_discovered_imports = script_imports
|
||
|
||
env_pkg_map = query_target_env_packages(system_python_exe)
|
||
|
||
for m in ast_discovered_imports:
|
||
if m.lower() in target_std_libs or m.lower() in local_modules:
|
||
continue
|
||
canon_name = (env_pkg_map.get(m) or get_canonical_pypi_name(m)).lower()
|
||
if canon_name not in target_std_libs:
|
||
auto_detected_pkgs.add(canon_name)
|
||
if canon_name not in final_dependencies:
|
||
final_dependencies[canon_name] = canon_name
|
||
auto_added_supplements.add(canon_name)
|
||
|
||
|
||
engine_pkg = "pyinstaller" if engine == "PyInstaller" else "nuitka"
|
||
if engine == "PyInstaller" and self.params.get('pyi_version'):
|
||
engine_pkg = f"pyinstaller=={self.params['pyi_version']}"
|
||
elif engine == "Nuitka" and self.params.get('nuitka_version'):
|
||
engine_pkg = f"nuitka=={self.params['nuitka_version']}"
|
||
|
||
engine_pkgs = [engine_pkg]
|
||
if engine == "PyInstaller":
|
||
engine_pkgs.append("pillow")
|
||
engine_pkgs.append("pyinstaller-hooks-contrib")
|
||
elif engine == "Nuitka":
|
||
engine_pkgs.append("zstandard")
|
||
|
||
self.progress.emit(_("[INFO] Resolving project dependencies..."))
|
||
if reqs_declared_pkgs:
|
||
self.progress.emit(" • " + _("Declared in requirements.txt ({count}): {pkgs}", count=len(reqs_declared_pkgs), pkgs=', '.join(sorted(reqs_declared_pkgs))))
|
||
else:
|
||
self.progress.emit(" • " + _("Declared in requirements.txt: None"))
|
||
|
||
self.progress.emit(" • " + _("Discovered via scanner ({count}): {pkgs}", count=len(auto_detected_pkgs), pkgs=', '.join(sorted(auto_detected_pkgs))))
|
||
|
||
if auto_added_supplements:
|
||
self.progress.emit(" • " + _("Auto-patched missing ({count}): {pkgs}", count=len(auto_added_supplements), pkgs=', '.join(sorted(auto_added_supplements))))
|
||
else:
|
||
self.progress.emit(" • " + _("Manifest complete (No missing packages)"))
|
||
|
||
self.progress.emit(" • " + _("Build engine packages ({count}): {pkgs}", count=len(engine_pkgs), pkgs=', '.join(sorted(engine_pkgs))))
|
||
|
||
final_install_dict = {}
|
||
for spec in engine_pkgs + list(final_dependencies.values()):
|
||
m = re.match(r'^([a-zA-Z0-9_\-\.]+)(.*)$', spec.strip())
|
||
if m:
|
||
base_name = m.group(1).lower()
|
||
has_ver = bool(m.group(2).strip())
|
||
if base_name in final_install_dict:
|
||
existing_has_ver = bool(re.match(r'^[a-zA-Z0-9_\-\.]+[=><!~]', final_install_dict[base_name]))
|
||
if existing_has_ver and not has_ver:
|
||
continue
|
||
final_install_dict[base_name] = spec
|
||
|
||
dedup_install_list = [pkg for pkg in list(final_install_dict.values()) if re.split(r'[=><!~]', pkg)[0].strip().lower() not in target_std_libs]
|
||
|
||
enable_shield = self.params.get('enable_backport_shield', True)
|
||
user_backport_rules = self.params.get('backport_rules', {})
|
||
|
||
target_py_ver = (3, 8)
|
||
try:
|
||
kw_v = {"capture_output": True, "text": True, "timeout": 3, "errors": "ignore"}
|
||
if os.name == 'nt': kw_v["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
res_v = subprocess.run([system_python_exe, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"], **kw_v)
|
||
if res_v.returncode == 0 and res_v.stdout.strip():
|
||
pv = res_v.stdout.strip().split('.')
|
||
target_py_ver = (int(pv[0]), int(pv[1]))
|
||
except Exception: pass
|
||
|
||
active_exclusions = set()
|
||
if enable_shield:
|
||
for pkg_name, min_ver_tuple in user_backport_rules.items():
|
||
if target_py_ver >= min_ver_tuple:
|
||
active_exclusions.add(pkg_name.lower())
|
||
|
||
sanitized_dependencies = {}
|
||
for canon_name, full_spec in final_dependencies.items():
|
||
clean_k = canon_name.lower().strip()
|
||
if clean_k in active_exclusions:
|
||
self.progress.emit(_("[INFO] Auto-shielded obsolete backport module: '{pkg}' (Built-in in Python {ver})",
|
||
pkg=canon_name, ver=f"{target_py_ver[0]}.{target_py_ver[1]}"))
|
||
continue
|
||
sanitized_dependencies[canon_name] = full_spec
|
||
|
||
final_dependencies = sanitized_dependencies
|
||
|
||
temp_unified_reqs = custom_temp_base / f"qpypack_atomic_reqs_{int(time.time())}.txt"
|
||
temp_unified_reqs.write_text('\n'.join(dedup_install_list), encoding='utf-8')
|
||
|
||
|
||
total_pkgs = len(dedup_install_list)
|
||
pkg_names_str = ', '.join(sorted(dedup_install_list))
|
||
|
||
self.progress.emit(_("[INFO] Installing build environment and project dependencies ({count} packages): {pkgs}", count=total_pkgs, pkgs=pkg_names_str))
|
||
|
||
if self._is_cancelled:
|
||
temp_unified_reqs.unlink(missing_ok=True)
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
if not self.run_pip_install(python_exe, ["-q", "-r", temp_unified_reqs.as_posix()]):
|
||
if self._is_cancelled:
|
||
temp_unified_reqs.unlink(missing_ok=True)
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
self.progress.emit(_("[WARN] Specified versions failed to install. Stripping version constraints for automatic compatibility match..."))
|
||
|
||
flex_install_list = [re.split(r'[=><!~]', pkg)[0].strip() for pkg in dedup_install_list]
|
||
|
||
installed_any = False
|
||
for pkg in flex_install_list:
|
||
if self._is_cancelled:
|
||
temp_unified_reqs.unlink(missing_ok=True)
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
if pkg.lower() in target_std_libs:
|
||
continue
|
||
|
||
if self.run_pip_install(python_exe, ["-q", pkg]):
|
||
installed_any = True
|
||
else:
|
||
if self._is_cancelled:
|
||
temp_unified_reqs.unlink(missing_ok=True)
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
self.progress.emit(_("[WARN] Package '{pkg}' failed to install, skipping...", pkg=pkg))
|
||
failed_packages.append(pkg)
|
||
|
||
if installed_any:
|
||
self.progress.emit(_("[INFO] Successfully installed compatible versions."))
|
||
else:
|
||
self.progress.emit(_("[WARN] Some dependencies failed to install, build will proceed with risk..."))
|
||
|
||
temp_unified_reqs.unlink(missing_ok=True)
|
||
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
has_playwright_pkg = any(re.match(r'^playwright([=><!~]|$)', pkg.lower()) for pkg in dedup_install_list)
|
||
if has_playwright_pkg:
|
||
self.progress.emit(_("[INFO] Playwright detected. Installing built-in browsers (PLAYWRIGHT_BROWSERS_PATH=0)..."))
|
||
|
||
pw_mirrors = [
|
||
"https://npmmirror.com/mirrors/playwright/",
|
||
"https://cdn.npmmirror.com/binaries/playwright/",
|
||
"https://mirrors.huaweicloud.com/playwright/",
|
||
""
|
||
] if (I18N.current_lang == 'zh_CN' or any(d in pip_idx for d in ['tsinghua', 'aliyun', 'tencent', 'huawei', 'ustc'])) else [""]
|
||
|
||
success_pw = False
|
||
for mirror in pw_mirrors:
|
||
if self._is_cancelled: break
|
||
|
||
pw_env = {}
|
||
if mirror:
|
||
pw_env["PLAYWRIGHT_DOWNLOAD_HOST"] = mirror
|
||
self.progress.emit(_("[INFO] Downloading Playwright browsers via mirror: {url}", url=mirror))
|
||
else:
|
||
self.progress.emit(_("[INFO] Retrying Playwright browser download via official CDN..."))
|
||
|
||
if self.run_cmd([python_exe, "-m", "playwright", "install"], timeout=300, silent_error=True, extra_env=pw_env):
|
||
success_pw = True
|
||
break
|
||
|
||
if not success_pw and not self._is_cancelled:
|
||
self.progress.emit(_("[WARN] Playwright browser installation failed across all sources, build will proceed with risk..."))
|
||
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
self.progress.emit(_("[INFO] Starting {engine} engine to compile binary files...", engine=engine))
|
||
|
||
if engine == "PyInstaller" and os.name == "nt":
|
||
if 'multiprocessing' in {m.lower() for m in script_imports}:
|
||
self.progress.emit(_("[WARN] 'multiprocessing' module detected. Ensure 'multiprocessing.freeze_support()' is called under 'if __name__ == \"__main__\":' to prevent infinite process loops (fork bombs)."))
|
||
|
||
if engine == "PyInstaller" and self.params['onefile']:
|
||
try:
|
||
code_text = script_path.read_text(encoding='utf-8', errors='ignore')
|
||
if '__file__' in code_text and 'sys._MEIPASS' not in code_text:
|
||
self.progress.emit(_("[WARN] Usage of '__file__' detected. In PyInstaller One-File mode, use 'sys._MEIPASS' to reliably locate bundled resource files!"))
|
||
except: pass
|
||
|
||
cmd = []
|
||
icon_path = Path(self.params['icon']).resolve().as_posix() if self.params.get('icon') else None
|
||
|
||
if engine == "PyInstaller":
|
||
self.temp_workpath = Path(tempfile.mkdtemp(prefix="qpypack_build_", dir=temp_dir_arg)).resolve()
|
||
self.temp_dist_dir = Path(tempfile.mkdtemp(prefix="qpypack_dist_", dir=temp_dir_arg)).resolve()
|
||
cmd = [
|
||
python_exe, "-m", "PyInstaller", "--clean", "--noconfirm",
|
||
f"--distpath={self.temp_dist_dir.as_posix()}",
|
||
f"--workpath={self.temp_workpath.as_posix()}",
|
||
f"--name={app_name}"
|
||
]
|
||
|
||
if self.params['onefile']:
|
||
cmd.append("--onefile")
|
||
else:
|
||
cmd.append("--onedir")
|
||
contents_dir = (self.params.get('contents_dir') or '_internal').strip()
|
||
if contents_dir:
|
||
cmd.append(f"--contents-directory={contents_dir}")
|
||
|
||
if self.params['noconsole']:
|
||
cmd.append("--noconsole")
|
||
else:
|
||
cmd.append("--console")
|
||
|
||
if icon_path:
|
||
cmd.extend(["--icon", icon_path])
|
||
cmd.extend(["--add-data", f"{icon_path}{os.pathsep}."])
|
||
|
||
if self.params.get('version_file') and os.name == "nt":
|
||
cmd.extend(["--version-file", self.params['version_file']])
|
||
elif sys.platform == "darwin":
|
||
raw_comp = re.sub(r'[^a-zA-Z0-9]', '', self.params.get('ver_comp', 'mycompany')).lower() or 'anonymous'
|
||
raw_app = re.sub(r'[^a-zA-Z0-9]', '', app_name).lower() or 'app'
|
||
bundle_id = f"com.{raw_comp}.{raw_app}"
|
||
cmd.extend(["--osx-bundle-identifier", bundle_id])
|
||
|
||
if self.params.get('upx'):
|
||
upx_dir_custom = (self.params.get('upx_path') or '').strip()
|
||
if upx_dir_custom and Path(upx_dir_custom).exists():
|
||
cmd.append(f"--upx-dir={upx_dir_custom}")
|
||
else:
|
||
upx_dir_default = (Path.cwd() / "upx").resolve()
|
||
if upx_dir_default.exists(): cmd.append(f"--upx-dir={upx_dir_default.as_posix()}")
|
||
if os.name == "nt":
|
||
cmd.extend([
|
||
"--upx-exclude=python3.dll",
|
||
"--upx-exclude=vcruntime140.dll",
|
||
"--upx-exclude=vcruntime140_1.dll",
|
||
"--upx-exclude=ucrtbase.dll",
|
||
"--upx-exclude=msvcp140.dll",
|
||
"--upx-exclude=msvcp140_1.dll",
|
||
"--upx-exclude=Qt6Core.dll",
|
||
"--upx-exclude=Qt6Gui.dll",
|
||
"--upx-exclude=Qt6Widgets.dll",
|
||
"--upx-exclude=qwindows.dll"
|
||
])
|
||
try:
|
||
kw = {"capture_output": True, "text": True, "timeout": 3, "errors": "ignore"}
|
||
if os.name == 'nt': kw["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
res = subprocess.run([python_exe, "-c",
|
||
"import sys; print(f'python{sys.version_info.major}{sys.version_info.minor}.dll')"], **kw)
|
||
if res.returncode == 0 and res.stdout.strip():
|
||
cmd.append(f"--upx-exclude={res.stdout.strip()}")
|
||
except:
|
||
for v in ("38", "39", "310", "311", "312", "313", "314"):
|
||
cmd.append(f"--upx-exclude=python{v}.dll")
|
||
else:
|
||
cmd.append("--noupx")
|
||
|
||
for imp in (self.params.get('hidden_imports') or '').split(','):
|
||
if imp.strip(): cmd.extend(["--hidden-import", imp.strip()])
|
||
|
||
for r_type, src, dst in (self.params.get('add_data_list') or []):
|
||
cmd.extend(["--add-data", f"{src}{os.pathsep}{dst}"])
|
||
|
||
for excl in (self.params.get('exclude_modules') or '').split(','):
|
||
if excl.strip(): cmd.extend(["--exclude-module", excl.strip()])
|
||
|
||
imports_lower = {m.lower() for m in script_imports}
|
||
hidden_list = [i.strip().lower() for i in (self.params.get('hidden_imports') or '').split(',') if i.strip()]
|
||
all_imports_lower = imports_lower | set(hidden_list)
|
||
|
||
if 'ttkbootstrap' in all_imports_lower: cmd.extend(["--collect-all", "ttkbootstrap"])
|
||
if 'customtkinter' in all_imports_lower: cmd.extend(["--collect-all", "customtkinter"])
|
||
if 'playwright' in all_imports_lower or has_playwright_pkg: cmd.extend(["--collect-all", "playwright"])
|
||
if 'moviepy' in all_imports_lower: cmd.extend(["--collect-data", "moviepy"])
|
||
if 'gradio' in all_imports_lower: cmd.extend(["--collect-all", "gradio"])
|
||
if 'pydantic' in all_imports_lower: cmd.extend(["--collect-submodules", "pydantic"])
|
||
if 'matplotlib' in all_imports_lower or 'seaborn' in all_imports_lower:
|
||
cmd.extend(["--collect-data", "matplotlib"])
|
||
|
||
if any(lib in all_imports_lower for lib in ('requests', 'httpx', 'urllib3', 'aiohttp')):
|
||
cmd.extend(["--collect-data", "certifi"])
|
||
self.progress.emit(_("[WARN] Detected 'requests' or 'httpx'. Auto-bundling 'certifi' certificates to prevent SSL errors."))
|
||
|
||
for web_fw in ('fastapi', 'uvicorn', 'flask', 'streamlit', 'pywebio', 'dash'):
|
||
if web_fw in all_imports_lower:
|
||
cmd.extend(["--collect-all", web_fw])
|
||
|
||
elif engine == "Nuitka":
|
||
self.temp_out_dir = Path(tempfile.mkdtemp(prefix="qpypack_nuitka_", dir=temp_dir_arg)).resolve()
|
||
cmd = [
|
||
python_exe, "-m", "nuitka", "--remove-output", "--assume-yes-for-downloads",
|
||
f"--output-dir={self.temp_out_dir.as_posix()}",
|
||
f"--output-filename={app_name}{ext}"
|
||
]
|
||
|
||
if os.name == "nt":
|
||
# 1. 精确获取打包 Python 的版本与架构位数
|
||
py_ver_num = (3, 8)
|
||
is_64bit = True
|
||
try:
|
||
kw_v = {"capture_output": True, "text": True, "timeout": 2, "errors": "ignore"}
|
||
if os.name == 'nt': kw_v["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
check_ver = subprocess.run([python_exe, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}|{sys.maxsize > 2**32}')"], **kw_v)
|
||
ver_str = check_ver.stdout.strip()
|
||
if ver_str and "|" in ver_str:
|
||
v_part, arch_part = ver_str.split('|')
|
||
parts = v_part.split('.')
|
||
py_ver_num = (int(parts[0]), int(parts[1]))
|
||
is_64bit = (arch_part.lower() == 'true')
|
||
except Exception: pass
|
||
|
||
# 2. 精确检测 MSVC 以及是否安装了 Windows SDK
|
||
has_suitable_msvc = False
|
||
has_win_sdk = False
|
||
vswhere = Path(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")) / "Microsoft Visual Studio/Installer/vswhere.exe"
|
||
|
||
if vswhere.exists():
|
||
try:
|
||
# Python 3.11+ 必须 VS 2022 (v17.0+ / MSVC 14.3+);3.8-3.10 支持 VS 2017/2019/2022
|
||
min_vs_ver = "17.0" if py_ver_num >= (3, 11) else "15.0"
|
||
res_vc = subprocess.run([
|
||
vswhere.as_posix(), "-latest", "-version", min_vs_ver,
|
||
"-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64"
|
||
], capture_output=True, text=True, errors="ignore", creationflags=subprocess.CREATE_NO_WINDOW)
|
||
if res_vc.stdout.strip():
|
||
has_suitable_msvc = True
|
||
except Exception: pass
|
||
|
||
# 修复:直接扫描标准系统 SDK 路径(防止 vswhere 检索范围局限在 VS 目录导致误判)
|
||
sdk_paths = [
|
||
Path(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")) / "Windows Kits" / "10" / "Lib",
|
||
Path(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")) / "Windows Kits" / "11" / "Lib",
|
||
Path(os.environ.get("ProgramFiles", "C:\\Program Files")) / "Windows Kits" / "10" / "Lib",
|
||
Path(os.environ.get("ProgramFiles", "C:\\Program Files")) / "Windows Kits" / "11" / "Lib",
|
||
]
|
||
for p in sdk_paths:
|
||
if p.exists() and any(p.iterdir()):
|
||
has_win_sdk = True
|
||
break
|
||
|
||
# 3. 全版本编译器分流矩阵
|
||
# 规则 A: 拥有合规 MSVC 且包含 SDK -> 优先使用 MSVC
|
||
if has_suitable_msvc and has_win_sdk:
|
||
cmd.append("--msvc=latest")
|
||
self.progress.emit(_("[INFO] Found compatible MSVC + Windows SDK environment, using native compiler."))
|
||
|
||
# 规则 B: Python 3.13+ 并且是 64 位环境 -> 优先使用 Zig 编译器 (MinGW64 在 3.13+ 已废弃)
|
||
elif py_ver_num >= (3, 13) and is_64bit:
|
||
cmd.append("--zig")
|
||
self.progress.emit(_("[INFO] Python 3.13+ detected: Using Zig compiler (--zig) as native backend."))
|
||
|
||
# 规则 C: Python 3.8 ~ 3.12 -> 使用/自动下载 MinGW64 (winlibs)
|
||
elif py_ver_num <= (3, 12):
|
||
cmd.append("--mingw64")
|
||
if not has_suitable_msvc and py_ver_num >= (3, 11):
|
||
self.progress.emit(_("[INFO] MSVC 14.3+ not detected. Nuitka will use/download MinGW64 toolchain (Python {v}).", v=f"{py_ver_num[0]}.{py_ver_num[1]}"))
|
||
else:
|
||
self.progress.emit(_("[INFO] Using MinGW64 compiler."))
|
||
|
||
# 规则 D: Python 3.13+ 且为 32位系统,但缺失 VS2022
|
||
else:
|
||
cmd.append("--msvc=latest")
|
||
self.progress.emit(_("[WARN] Python 3.13+ (32-bit) requires Visual Studio 2022 with Windows SDK. Build will attempt with current environment."))
|
||
|
||
if free_disk < 3.0:
|
||
self.progress.emit(_("[WARN] Low disk space detected on build target drive (Available: {free:.1f} GB, >= 5.0 GB recommended). Build process may interrupt due to disk exhaustion.", free=free_disk))
|
||
|
||
cores = self.params.get('cpu_cores', os.cpu_count() or 2)
|
||
free_ram = get_free_ram_gb()
|
||
|
||
if free_ram < 2.0 and cores > 1:
|
||
safe_jobs = 1
|
||
self.progress.emit(_("[INFO] Evaluating system physical memory (Available: {ram:.1f} GB). Adaptive concurrency adjusted: {cores} -> {safe_jobs} ...", ram=free_ram, cores=cores, safe_jobs=safe_jobs))
|
||
cores = safe_jobs
|
||
cmd.append("--low-memory")
|
||
elif free_ram < 3.5 and cores > 2:
|
||
safe_jobs = max(2, min(cores, int(free_ram)))
|
||
self.progress.emit(_("[INFO] Evaluating system physical memory (Available: {ram:.1f} GB). Adaptive concurrency adjusted: {cores} -> {safe_jobs} ...", ram=free_ram, cores=cores, safe_jobs=safe_jobs))
|
||
cores = safe_jobs
|
||
cmd.append("--low-memory")
|
||
elif free_ram < 6.0 and cores > 4:
|
||
safe_jobs = max(3, min(cores, int(free_ram / 1.5)))
|
||
if safe_jobs < cores:
|
||
self.progress.emit(_("[INFO] Evaluating system physical memory (Available: {ram:.1f} GB). Adaptive concurrency adjusted: {cores} -> {safe_jobs} ...", ram=free_ram, cores=cores, safe_jobs=safe_jobs))
|
||
cores = safe_jobs
|
||
cmd.append(f"--jobs={cores}")
|
||
|
||
if self.params.get('upx'):
|
||
cmd.append("--enable-plugin=upx")
|
||
upx_dir_custom = (self.params.get('upx_path') or '').strip()
|
||
if upx_dir_custom and Path(upx_dir_custom).exists():
|
||
upx_exe = Path(upx_dir_custom) / ("upx.exe" if os.name == "nt" else "upx")
|
||
if upx_exe.exists():
|
||
cmd.append(f"--upx-binary={upx_exe.as_posix()}")
|
||
else:
|
||
cmd.append(f"--upx-binary={upx_dir_custom}")
|
||
|
||
if self.params['noconsole']:
|
||
if os.name == "nt":
|
||
cmd.append("--windows-console-mode=disable")
|
||
if sys.platform == "darwin":
|
||
cmd.append("--macos-create-app-bundle")
|
||
else:
|
||
if os.name == "nt":
|
||
cmd.append("--windows-console-mode=force")
|
||
|
||
if self.params['onefile']:
|
||
cmd.append("--onefile")
|
||
else:
|
||
cmd.append("--standalone")
|
||
|
||
if icon_path:
|
||
if os.name == "nt": cmd.append(f"--windows-icon-from-ico={icon_path}")
|
||
if sys.platform == "darwin": cmd.append(f"--macos-app-icon={icon_path}")
|
||
cmd.append(f"--include-data-files={Path(icon_path).resolve().as_posix()}={Path(icon_path).name}")
|
||
|
||
if os.name == "nt":
|
||
if self.params.get('ver_comp'): cmd.append(f"--company-name={self.params['ver_comp']}")
|
||
if self.params.get('ver_desc'): cmd.append(f"--file-description={self.params['ver_desc']}")
|
||
if self.params.get('app_name'): cmd.append(f"--product-name={self.params['app_name']}")
|
||
if self.params.get('ver_ver'):
|
||
v_str = self.params['ver_ver'].strip()
|
||
v_nums = re.findall(r'\d+', v_str)
|
||
v_clean = ".".join((v_nums + ['0', '0', '0', '0'])[:4])
|
||
cmd.append(f"--file-version={v_clean}")
|
||
cmd.append(f"--product-version={v_clean}")
|
||
elif sys.platform == "darwin":
|
||
if self.params.get('ver_comp'): cmd.append(f"--company-name={self.params['ver_comp']}")
|
||
if self.params.get('ver_ver'): cmd.append(f"--macos-app-version={self.params['ver_ver']}")
|
||
comp = self.params.get('ver_comp', 'mycompany').strip().lower().replace(" ", "")
|
||
bundle_id = f"com.{comp or 'anonymous'}.{app_name.lower().replace(' ', '')}"
|
||
cmd.append(f"--macos-signed-app-name={bundle_id}")
|
||
|
||
hidden_list = [i.strip().lower() for i in (self.params.get('hidden_imports') or '').split(',') if i.strip()]
|
||
imports_lower = {m.lower() for m in script_imports} | set(hidden_list)
|
||
|
||
if 'pyqt5' in imports_lower: cmd.append("--enable-plugin=pyqt5")
|
||
elif 'pyqt6' in imports_lower: cmd.append("--enable-plugin=pyqt6")
|
||
elif 'pyside2' in imports_lower: cmd.append("--enable-plugin=pyside2")
|
||
elif 'pyside6' in imports_lower: cmd.append("--enable-plugin=pyside6")
|
||
|
||
if 'matplotlib' in imports_lower: cmd.append("--enable-plugin=matplotlib")
|
||
if any(tk in imports_lower for tk in ('tkinter', 'pysimplegui', 'customtkinter', 'turtle', 'easygui', 'ttkbootstrap')):
|
||
cmd.append("--enable-plugin=tk-inter")
|
||
if 'multiprocessing' in imports_lower:
|
||
cmd.append("--enable-plugin=multiprocessing")
|
||
if os.name == "nt":
|
||
self.progress.emit(_("[WARN] 'multiprocessing' module detected. Ensure 'multiprocessing.freeze_support()' is called under 'if __name__ == \"__main__\":' to prevent infinite process loops (fork bombs)."))
|
||
is_lite = self.params.get('lite_mode')
|
||
|
||
if 'ttkbootstrap' in imports_lower:
|
||
if not is_lite: cmd.append("--include-package=ttkbootstrap")
|
||
cmd.append("--include-package-data=ttkbootstrap")
|
||
|
||
if 'playwright' in imports_lower or has_playwright_pkg:
|
||
if not is_lite: cmd.append("--include-package=playwright")
|
||
cmd.append("--include-package-data=playwright")
|
||
|
||
if any(lib in imports_lower for lib in ('requests', 'httpx', 'urllib3', 'aiohttp')):
|
||
cmd.append("--include-package-data=certifi")
|
||
self.progress.emit(_("[WARN] Detected 'requests' or 'httpx'. Auto-bundling 'certifi' certificates to prevent SSL errors."))
|
||
|
||
for web_fw in ('fastapi', 'uvicorn', 'flask', 'streamlit', 'pywebio', 'dash'):
|
||
if web_fw in imports_lower:
|
||
if not is_lite: cmd.append(f"--include-package={web_fw}")
|
||
cmd.append(f"--include-package-data={web_fw}")
|
||
|
||
if 'numpy' in imports_lower: cmd.append("--enable-plugin=numpy")
|
||
|
||
for imp in (self.params.get('hidden_imports') or '').split(','):
|
||
if imp.strip(): cmd.extend([f"--include-module={imp.strip()}"])
|
||
|
||
for r_type, src, dst in (self.params.get('add_data_list') or []):
|
||
src_path = Path(src).resolve().as_posix()
|
||
if r_type == 'dir':
|
||
cmd.append(f"--include-data-dir={src_path}={dst}")
|
||
else:
|
||
filename = Path(src).name
|
||
if dst == ".": nuitka_dst = filename
|
||
else: nuitka_dst = os.path.normpath(os.path.join(dst, filename)).replace('\\', '/')
|
||
cmd.append(f"--include-data-files={src_path}={nuitka_dst}")
|
||
|
||
for excl in (self.params.get('exclude_modules') or '').split(','):
|
||
if excl.strip(): cmd.append(f"--nofollow-import-to={excl.strip()}")
|
||
|
||
if self.params.get('lite_mode'):
|
||
self.progress.emit(_("[INFO] Lite mode enabled, applying bytecode optimization (-OO) and stripping dev modules..."))
|
||
if not self.params.get('use_venv'):
|
||
self.progress.emit(_("[WARN] Strongly recommend checking [Virtual Environment] to maximize lite mode effect."))
|
||
|
||
if engine == "PyInstaller":
|
||
cmd.append("--optimize=2")
|
||
safe_dev_excludes = ['unittest', 'doctest', 'pdb', 'pydoc', 'test', 'pytest', 'IPython', 'binder', 'tkinter.test']
|
||
for ex in safe_dev_excludes:
|
||
cmd.append(f"--exclude-module={ex}")
|
||
elif engine == "Nuitka":
|
||
cmd.append("--python-flag=-OO")
|
||
cmd.append("--enable-plugin=anti-bloat")
|
||
nuitka_dev_excludes = ['unittest', 'doctest', 'pdb', 'pydoc', 'test', 'pytest', 'IPython', 'binder', 'tkinter.test', 'pip', 'setuptools', 'wheel']
|
||
for ex in nuitka_dev_excludes:
|
||
cmd.append(f"--nofollow-import-to={ex}")
|
||
|
||
cmd.append(script_posix)
|
||
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
success = self.run_cmd(cmd, cwd=script_dir.as_posix(), timeout=3600)
|
||
|
||
if not success and not self._is_cancelled:
|
||
log_text = "\n".join(self.all_raw_logs)
|
||
if any(kw in log_text for kw in ["NoSpaceLeft", "No space left on device", "[Errno 28]"]):
|
||
self.progress.emit(_("[ERROR] Build aborted: Insufficient disk space (NoSpaceLeft / Errno 28). Please clean up drive space (at least 5 GB free space recommended) and try again."))
|
||
elif any(kw in log_text for kw in ["Allocation error", "not enough memory", "ZstdError", "out of memory"]):
|
||
self.progress.emit(_("[WARN] Memory allocation exception caught (ZstdError / OOM). Triggering memory protection fallback: Retrying in single-thread mode..."))
|
||
clean_cmd = [arg if not arg.startswith("--jobs=") else "--jobs=1" for arg in cmd]
|
||
if "--low-memory" not in clean_cmd:
|
||
clean_cmd.append("--low-memory")
|
||
self.all_raw_logs.clear()
|
||
success = self.run_cmd(clean_cmd, cwd=script_dir.as_posix())
|
||
|
||
if not success and icon_path and not self._is_cancelled:
|
||
log_text = "\n".join(self.all_raw_logs)
|
||
icon_err_keywords = ["Failed to add resources", "error code 22", "UpdateResource", "Resource modification failed", "Failed to add"]
|
||
if any(kw in log_text for kw in icon_err_keywords):
|
||
self.progress.emit(_("[WARN] Icon resource writing blocked (possibly locked by system/antivirus), triggering fallback protection..."))
|
||
self.progress.emit(_("[INFO] Stripping icon parameters and automatically rebuilding..."))
|
||
|
||
clean_cmd = []
|
||
skip_next = False
|
||
for arg in cmd:
|
||
if skip_next:
|
||
skip_next = False
|
||
continue
|
||
if arg == "--icon":
|
||
skip_next = True
|
||
continue
|
||
if arg.startswith(("--windows-icon-from-ico=", "--macos-app-icon=")):
|
||
continue
|
||
if icon_path in arg and "--include-data-files=" in arg:
|
||
continue
|
||
clean_cmd.append(arg)
|
||
|
||
self.all_raw_logs.clear()
|
||
success = self.run_cmd(clean_cmd, cwd=script_dir.as_posix())
|
||
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
|
||
src_out = None
|
||
if engine == "PyInstaller":
|
||
if sys.platform == "darwin" and self.params['noconsole']:
|
||
src_out = self.temp_dist_dir / f"{app_name}.app"
|
||
else:
|
||
src_out = self.temp_dist_dir / (f"{app_name}{ext}" if self.params['onefile'] else app_name)
|
||
elif engine == "Nuitka":
|
||
if self.params['onefile']:
|
||
if sys.platform == "darwin" and self.params['noconsole']:
|
||
src_out = self.temp_out_dir / f"{app_name}.app"
|
||
else:
|
||
src_out = self.temp_out_dir / f"{app_name}{ext}"
|
||
else:
|
||
if sys.platform == "darwin" and self.params['noconsole']:
|
||
src_out = self.temp_out_dir / f"{app_name}.app"
|
||
else:
|
||
dist_dirs = list(self.temp_out_dir.glob("*.dist"))
|
||
if dist_dirs: src_out = dist_dirs[0]
|
||
else: src_out = self.temp_out_dir / f"{app_name}.dist"
|
||
|
||
out_mode = int(self.params.get('out_mode', 0) or 0)
|
||
custom_out = (self.params.get('custom_out_dir') or '').strip()
|
||
if out_mode == 1 and custom_out:
|
||
try:
|
||
final_out_dir = Path(custom_out)
|
||
final_out_dir.mkdir(parents=True, exist_ok=True)
|
||
except: final_out_dir = script_dir
|
||
else:
|
||
final_out_dir = script_dir
|
||
|
||
if sys.platform == "darwin" and self.params['noconsole']:
|
||
final_out = final_out_dir / f"{app_name}.app"
|
||
elif self.params['onefile']:
|
||
final_out = final_out_dir / f"{app_name}{ext}"
|
||
else:
|
||
final_out = final_out_dir / app_name
|
||
|
||
if success and src_out and src_out.exists():
|
||
self.progress.emit(_("[INFO] Compilation completed, archiving built files..."))
|
||
try:
|
||
if src_out.resolve() != final_out.resolve():
|
||
# 若目标路径已存在旧文件/文件夹,先安全彻底删除
|
||
if final_out.exists():
|
||
if final_out.is_dir():
|
||
if not robust_rmtree(final_out):
|
||
raise PermissionError("Target folder locked and cannot be removed.")
|
||
else:
|
||
try:
|
||
final_out.unlink()
|
||
except PermissionError:
|
||
time.sleep(1)
|
||
final_out.unlink(missing_ok=True)
|
||
|
||
# 确保旧产物已彻底不存在后再移动,避免 onedir 模式下将目录移入自身产生嵌套
|
||
for _retry in range(15):
|
||
try:
|
||
shutil.move(src_out.as_posix(), final_out.as_posix())
|
||
break
|
||
except PermissionError:
|
||
time.sleep(0.5)
|
||
else:
|
||
raise PermissionError("File locked by Antivirus or other process.")
|
||
except PermissionError:
|
||
success = False
|
||
self.progress.emit(_("[ERROR] Target file is running or occupied. Please close the existing application and try again."))
|
||
except Exception as e:
|
||
success = False
|
||
self.progress.emit(_("[ERROR] Product transfer failed, file might be occupied by system process or lack permission: {error}", error=str(e)))
|
||
if src_out and src_out.exists():
|
||
self.progress.emit(f"[INFO] You can manually copy the compiled product from temporary folder:\n -> {src_out.as_posix()}")
|
||
else:
|
||
if not success and not self._is_cancelled:
|
||
self.progress.emit(_("[ERROR] Could not locate valid executable product in temporary build directory: {path}", path=str(src_out)))
|
||
|
||
if success and final_out.exists():
|
||
if self.params.get('enable_sign'):
|
||
self.auto_sign_executable(final_out)
|
||
|
||
self.progress.emit(_("[INFO] Validating output files and generating final product..."))
|
||
if self.params.get('auto_save_log') and self.all_raw_logs:
|
||
try:
|
||
log_file = final_out_dir / f"qpypack_build_{app_name}.log"
|
||
log_file.write_text('\n'.join(self.all_raw_logs), encoding='utf-8')
|
||
self.progress.emit(_("[INFO] Build log exported to: {path}", path=log_file.as_posix()))
|
||
except: pass
|
||
|
||
if self.params['onefile'] and os.name == 'nt':
|
||
self.progress.emit(_("[INFO] Tip: Executables without commercial signatures might be falsely flagged by Windows Defender/Antivirus."))
|
||
self.build_finished.emit(True, _("[SUCCESS] Compilation completed, output path: {path}", path=final_out.resolve().as_posix()), failed_packages)
|
||
else:
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
err_info = self.detect_python_syntax_errors()
|
||
if err_info["is_code_error"]:
|
||
msg = _("[Syntax Error] Source code contains syntax errors, compilation aborted:\n - File: {file}\n - Type: {type}\n - Line: Line {line}\n - Detail: {desc}\n\nTip: Please ensure the source code runs locally before packaging.",
|
||
file=err_info['file'], type=err_info['type'], line=err_info['line'], desc=err_info['desc'])
|
||
else:
|
||
msg = _(
|
||
"[FAILED] Build interrupted exceptionally!\n\n"
|
||
"Common Troubleshooting:\n"
|
||
"1. Environment Mismatch (Most Common): The selected Python version is incompatible with your source code. Please go to [Build Settings] to switch to the Python version you normally use for this code.\n"
|
||
"2. Missing Dependencies: Click 'Detailed Mode' above to check for ModuleNotFoundError.\n"
|
||
"3. Antivirus Block: Ensure your security software is not blocking the build process.\n\n"
|
||
"(Note: This is usually caused by environment/code discrepancies rather than the packaging engine itself. Please check the detailed log for exact reasons.)"
|
||
)
|
||
self.build_finished.emit(False, msg, failed_packages)
|
||
|
||
except Exception as e:
|
||
if self._is_cancelled:
|
||
return self.build_finished.emit(False, _("[INFO] Build Cancelled."), [])
|
||
err_trace = traceback.format_exc()
|
||
self.progress.emit(f"[DETAILED_ONLY]\n[FATAL ERROR TRACE]\n{err_trace}\n")
|
||
self.build_finished.emit(False, f"[ERROR] {str(e)}\n\n(See Detailed Log for complete trace)", failed_packages)
|
||
finally:
|
||
cleanup_errors = []
|
||
|
||
if "PYTHONPATH" in os.environ and "_qpypack_temp_" in os.environ.get("PYTHONPATH", ""):
|
||
try:
|
||
parts = os.environ["PYTHONPATH"].split(os.pathsep)
|
||
os.environ["PYTHONPATH"] = os.pathsep.join(p for p in parts if "_qpypack_temp_" not in p)
|
||
if not os.environ["PYTHONPATH"]:
|
||
os.environ.pop("PYTHONPATH", None)
|
||
except Exception as e:
|
||
logger.debug(f"Failed to clean PYTHONPATH: {e}")
|
||
|
||
if is_temp and build_script_path and build_script_path.exists():
|
||
try:
|
||
build_script_path.unlink()
|
||
pycache_dir = script_dir / "__pycache__"
|
||
if pycache_dir.exists():
|
||
if not robust_rmtree(pycache_dir):
|
||
cleanup_errors.append(f"__pycache__: {pycache_dir}")
|
||
except Exception as e:
|
||
logger.debug(f"Failed to remove temp script: {e}")
|
||
cleanup_errors.append(f"temp script: {build_script_path}")
|
||
|
||
if self.params.get('version_file'):
|
||
try:
|
||
p = Path(self.params['version_file'])
|
||
if p.exists(): p.unlink()
|
||
except Exception as e:
|
||
logger.debug(f"Failed to remove version file: {e}")
|
||
|
||
if self.params.get('temp_icon_file'):
|
||
try:
|
||
p = Path(self.params['temp_icon_file'])
|
||
if p.exists(): p.unlink()
|
||
except Exception as e:
|
||
logger.debug(f"Failed to remove temp icon: {e}")
|
||
|
||
if self.params['clean_all']:
|
||
self.progress.emit(_("[INFO] Freeing up space, cleaning temporary build environment..."))
|
||
cleanup_dirs = [
|
||
(self.temp_workpath, "workpath"),
|
||
(self.temp_out_dir, "output"),
|
||
(self.temp_dist_dir, "dist")
|
||
]
|
||
|
||
if not self.params.get('keep_venv'):
|
||
cleanup_dirs.append((self.venv_dir, "venv"))
|
||
|
||
for dir_path, dir_name in cleanup_dirs:
|
||
if dir_path and dir_path.exists():
|
||
if not robust_rmtree(dir_path):
|
||
cleanup_errors.append(f"{dir_name}: {dir_path}")
|
||
|
||
app_name = self.params.get('app_name', 'app')
|
||
for p in ["__pycache__", f"{app_name}.build", f"{app_name}.onefile-build"]:
|
||
dir_to_remove = script_dir / p
|
||
if dir_to_remove.exists():
|
||
if not robust_rmtree(dir_to_remove):
|
||
cleanup_errors.append(f"build dir: {dir_to_remove}")
|
||
|
||
spec_file = script_dir / f"{app_name}.spec"
|
||
if spec_file.exists():
|
||
try:
|
||
spec_file.unlink()
|
||
except Exception as e:
|
||
logger.debug(f"Failed to remove spec file: {e}")
|
||
|
||
if sandbox_mode == 0 and custom_temp_base and custom_temp_base.exists():
|
||
try:
|
||
if not any(custom_temp_base.iterdir()):
|
||
custom_temp_base.rmdir()
|
||
except Exception as e:
|
||
logger.debug(f"Failed to remove temp base: {e}")
|
||
|
||
if cleanup_errors:
|
||
logger.warning(f"Failed to cleanup: {', '.join(cleanup_errors)}")
|
||
|
||
class PythonInstallMonitorThread(QThread):
|
||
finished_signal = Signal(bool)
|
||
|
||
def __init__(self, exe_path):
|
||
super().__init__()
|
||
self.exe_path = exe_path
|
||
|
||
def run(self):
|
||
try:
|
||
cmd = [self.exe_path, "/passive", "PrependPath=1", "Include_pip=1", "SimpleInstall=1"]
|
||
kwargs = {}
|
||
if os.name == 'nt':
|
||
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
|
||
res = subprocess.run(cmd, **kwargs)
|
||
self.finished_signal.emit(res.returncode == 0)
|
||
except Exception:
|
||
self.finished_signal.emit(False)
|
||
|
||
class PythonInstallerDialog(QDialog):
|
||
def __init__(self, parent=None, is_missing_mode=True):
|
||
super().__init__(parent)
|
||
self.parent_win = parent
|
||
self.is_missing_mode = is_missing_mode
|
||
self.selected_local_path = None
|
||
self.is_selected_ver_local = False
|
||
self.current_local_target_path = None
|
||
|
||
self.setWindowTitle(_("Python Environment Required") if is_missing_mode else _("Python Environment Management"))
|
||
self.setMinimumWidth(580)
|
||
self.setMinimumHeight(440)
|
||
self.setStyleSheet("QDialog { background-color: #ffffff; }")
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setSpacing(12)
|
||
layout.setContentsMargins(22, 22, 22, 22)
|
||
|
||
if is_missing_mode:
|
||
header_html = _(
|
||
"<b>Python Environment Required</b><br>"
|
||
"<span style='color:#475569; font-size:12px; line-height:1.6;'>"
|
||
"Application build depends on a Python interpreter.<br>"
|
||
"Please select a version to automatically download, install, and configure environment variables."
|
||
"</span>"
|
||
)
|
||
else:
|
||
header_html = _(
|
||
"<b>Python Environment Management</b><br>"
|
||
"<span style='color:#475569; font-size:12px; line-height:1.6;'>"
|
||
"Supports switching locally detected Python environments or downloading new versions."
|
||
"</span>"
|
||
)
|
||
|
||
lbl_title = QLabel(header_html)
|
||
lbl_title.setWordWrap(True)
|
||
lbl_title.setStyleSheet("font-size: 13px; color: #1e293b;")
|
||
layout.addWidget(lbl_title)
|
||
|
||
if is_missing_mode:
|
||
tip_card = QLabel(_(
|
||
"<b>Recommendation:</b> Python <b>3.11.9</b> is recommended for optimal build compatibility and engine support."
|
||
))
|
||
tip_card.setWordWrap(True)
|
||
tip_card.setStyleSheet("""
|
||
QLabel {
|
||
background-color: #eff6ff;
|
||
border: 1px solid #bfdbfe;
|
||
border-radius: 6px;
|
||
padding: 10px 14px;
|
||
color: #1e40af;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
""")
|
||
layout.addWidget(tip_card)
|
||
|
||
self.tab_widget = QTabWidget()
|
||
self.tab_widget.setStyleSheet("""
|
||
QTabWidget::pane { border: 1px solid #e2e8f0; border-radius: 8px; background: #ffffff; }
|
||
QTabBar::tab { background: #f8fafc; border: 1px solid #e2e8f0; padding: 8px 16px; font-weight: bold; font-size: 12px; color: #64748b; margin-right: 4px; border-top-left-radius: 6px; border-top-right-radius: 6px; }
|
||
QTabBar::tab:selected { background: #ffffff; color: #2563eb; border-bottom-color: #ffffff; }
|
||
""")
|
||
|
||
self.tab_local = QWidget()
|
||
lay_local = QVBoxLayout(self.tab_local)
|
||
lay_local.setContentsMargins(12, 12, 12, 12)
|
||
lay_local.setSpacing(8)
|
||
|
||
self.local_list = QListWidget()
|
||
self.local_list.setStyleSheet("""
|
||
QListWidget { border: 1px solid #e2e8f0; border-radius: 6px; outline: none; background: #fafafa; }
|
||
QListWidget::item { padding: 10px; border-bottom: 1px solid #f1f5f9; color: #1e293b; font-family: Consolas, monospace; font-size: 12px; }
|
||
QListWidget::item:selected { background-color: #eff6ff; color: #2563eb; font-weight: bold; }
|
||
""")
|
||
lay_local.addWidget(self.local_list)
|
||
|
||
self.btn_use_local = QPushButton(_("Use Selected Environment"))
|
||
self.btn_use_local.setStyleSheet("QPushButton { background-color: #16a34a; color: white; border: none; border-radius: 6px; font-size: 13px; font-weight: bold; padding: 8px 16px; } QPushButton:hover { background-color: #15803d; }")
|
||
self.btn_use_local.clicked.connect(self._on_use_local_clicked)
|
||
lay_local.addWidget(self.btn_use_local)
|
||
|
||
self.tab_widget.addTab(self.tab_local, _("Locally Detected Pythons"))
|
||
|
||
self.tab_download = QWidget()
|
||
lay_dl = QVBoxLayout(self.tab_download)
|
||
lay_dl.setContentsMargins(12, 12, 12, 12)
|
||
lay_dl.setSpacing(10)
|
||
|
||
self.versions = [
|
||
("3.14.6", _("Python 3.14.6 (Experimental)")),
|
||
("3.13.0", _("Python 3.13.0")),
|
||
("3.12.4", _("Python 3.12.4")),
|
||
("3.11.9", _("Python 3.11.9 (Recommended)")),
|
||
("3.10.11", _("Python 3.10.11")),
|
||
("3.9.13", _("Python 3.9.13")),
|
||
("3.8.10", _("Python 3.8.10 (Win7 Support)")),
|
||
]
|
||
|
||
self.dl_combo = QComboBox()
|
||
setup_combo_white_theme(self.dl_combo)
|
||
for ver, desc in self.versions:
|
||
self.dl_combo.addItem(desc, ver)
|
||
self.dl_combo.setCurrentIndex(3)
|
||
|
||
lay_dl.addWidget(QLabel(_("Select Python Version to Download:")))
|
||
lay_dl.addWidget(self.dl_combo)
|
||
|
||
self.lbl_dl_hint = QLabel()
|
||
self.lbl_dl_hint.setWordWrap(True)
|
||
self.lbl_dl_hint.setStyleSheet("font-size: 12px; color: #64748b; line-height: 1.4;")
|
||
lay_dl.addWidget(self.lbl_dl_hint)
|
||
|
||
lay_dl.addStretch()
|
||
|
||
self.btn_dl_install = QPushButton()
|
||
self.btn_dl_install.clicked.connect(self._on_action_btn_clicked)
|
||
lay_dl.addWidget(self.btn_dl_install)
|
||
|
||
self.tab_widget.addTab(self.tab_download, _("Download New Version"))
|
||
|
||
layout.addWidget(self.tab_widget)
|
||
|
||
btn_lay = QHBoxLayout()
|
||
self.btn_manual_config = QPushButton(_("Configure Manually"))
|
||
self.btn_manual_config.setStyleSheet("QPushButton { background-color: #f8fafc; color: #2563eb; border: 1px solid #bfdbfe; border-radius: 6px; font-size: 12px; font-weight: bold; padding: 6px 14px; } QPushButton:hover { background-color: #eff6ff; }")
|
||
self.btn_manual_config.clicked.connect(lambda: self.done(2))
|
||
btn_lay.addWidget(self.btn_manual_config)
|
||
|
||
btn_lay.addStretch()
|
||
self.btn_cancel = QPushButton(_("Cancel"))
|
||
self.btn_cancel.setStyleSheet("QPushButton { background-color: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 12px; font-weight: bold; padding: 6px 14px; } QPushButton:hover { background-color: #e2e8f0; }")
|
||
self.btn_cancel.clicked.connect(self.reject)
|
||
btn_lay.addWidget(self.btn_cancel)
|
||
layout.addLayout(btn_lay)
|
||
|
||
self.installed_map = self._scan_installed_map()
|
||
self.dl_combo.currentIndexChanged.connect(self._on_download_ver_changed)
|
||
self._load_local_environments()
|
||
self._on_download_ver_changed()
|
||
|
||
if is_missing_mode:
|
||
self.tab_widget.setCurrentIndex(1)
|
||
else:
|
||
self.tab_widget.setCurrentIndex(0)
|
||
|
||
def _scan_installed_map(self):
|
||
installed = {}
|
||
if self.parent_win and hasattr(self.parent_win, 'settings_panel'):
|
||
combo = self.parent_win.settings_panel.python_path_combo
|
||
for i in range(combo.count()):
|
||
text = combo.itemText(i)
|
||
path = combo.itemData(i) or text
|
||
m = re.search(r'Python\s+(\d+\.\d+)', text, re.I)
|
||
if m and path and os.path.exists(path):
|
||
installed[m.group(1)] = path
|
||
|
||
if not installed:
|
||
sys_py = find_system_python()
|
||
if sys_py and os.path.exists(sys_py):
|
||
installed["default"] = sys_py
|
||
|
||
return installed
|
||
|
||
def _on_download_ver_changed(self):
|
||
ver_full = self.dl_combo.currentData()
|
||
parts = ver_full.split('.')
|
||
major_minor = f"{parts[0]}.{parts[1]}" if len(parts) >= 2 else ver_full
|
||
|
||
local_path = self.installed_map.get(major_minor) or self.installed_map.get(ver_full)
|
||
|
||
if local_path:
|
||
self.is_selected_ver_local = True
|
||
self.current_local_target_path = local_path
|
||
self.lbl_dl_hint.setText(_(
|
||
"<b><span style='color:#16a34a;'>✔ Detected locally:</span></b><br>"
|
||
"<span style='color:#475569; font-family:Consolas;'>{path}</span><br>"
|
||
"<span style='color:#16a34a; font-size:11px;'>Ready to switch directly without re-downloading.</span>",
|
||
path=local_path
|
||
))
|
||
self.btn_dl_install.setText(_("Use Installed Version Directly"))
|
||
self.btn_dl_install.setStyleSheet("""
|
||
QPushButton { background-color: #16a34a; color: white; border: none; border-radius: 6px; font-size: 13px; font-weight: bold; padding: 10px 16px; }
|
||
QPushButton:hover { background-color: #15803d; }
|
||
""")
|
||
else:
|
||
self.is_selected_ver_local = False
|
||
self.current_local_target_path = None
|
||
self.lbl_dl_hint.setText(_("Click to automatically download, install Python, and configure system environment variables."))
|
||
self.btn_dl_install.setText(_("One-Click Download & Install"))
|
||
self.btn_dl_install.setStyleSheet("""
|
||
QPushButton { background-color: #1A73E8; color: white; border: none; border-radius: 6px; font-size: 13px; font-weight: bold; padding: 10px 16px; }
|
||
QPushButton:hover { background-color: #1B66C9; }
|
||
""")
|
||
|
||
def _on_action_btn_clicked(self):
|
||
if self.is_selected_ver_local and self.current_local_target_path:
|
||
self.selected_local_path = self.current_local_target_path
|
||
self.done(3)
|
||
else:
|
||
self.accept()
|
||
|
||
def _load_local_environments(self):
|
||
self.local_list.clear()
|
||
found_any = False
|
||
if self.parent_win and hasattr(self.parent_win, 'settings_panel'):
|
||
combo = self.parent_win.settings_panel.python_path_combo
|
||
for i in range(combo.count()):
|
||
text = combo.itemText(i)
|
||
path = combo.itemData(i) or text
|
||
if path and os.path.exists(path):
|
||
item = QListWidgetItem(text)
|
||
item.setData(Qt.ItemDataRole.UserRole, path)
|
||
self.local_list.addItem(item)
|
||
found_any = True
|
||
|
||
if not found_any:
|
||
sys_py = find_system_python()
|
||
if sys_py and os.path.exists(sys_py):
|
||
item = QListWidgetItem(sys_py)
|
||
item.setData(Qt.ItemDataRole.UserRole, sys_py)
|
||
self.local_list.addItem(item)
|
||
found_any = True
|
||
|
||
if found_any:
|
||
self.local_list.setCurrentRow(0)
|
||
else:
|
||
self.local_list.addItem(_("No local Python environments detected."))
|
||
self.btn_use_local.setEnabled(False)
|
||
|
||
def _on_use_local_clicked(self):
|
||
item = self.local_list.currentItem()
|
||
if item:
|
||
path = item.data(Qt.ItemDataRole.UserRole)
|
||
if path:
|
||
self.selected_local_path = path
|
||
self.done(3)
|
||
|
||
def get_selected_version(self):
|
||
return self.dl_combo.currentData()
|
||
|
||
def get_selected_local_path(self):
|
||
return getattr(self, 'selected_local_path', None)
|
||
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.script_path = ""
|
||
self.thread = None
|
||
self.analysis_thread = None
|
||
self.current_state = "idle"
|
||
|
||
load_config()
|
||
|
||
self.init_style()
|
||
self.init_ui()
|
||
|
||
I18N.language_changed.connect(self.retranslate_ui)
|
||
self.retranslate_ui()
|
||
|
||
def init_style(self):
|
||
self.setWindowTitle(f"{__app_name__} {__version__}")
|
||
self.setMinimumSize(560, 460)
|
||
|
||
try:
|
||
config = load_config()
|
||
w = int(config.get('Settings', 'window_width', fallback='760'))
|
||
h = int(config.get('Settings', 'window_height', fallback='580'))
|
||
self.resize(w, h)
|
||
if config.get('Settings', 'window_maximized', fallback='False') == 'True':
|
||
self.setWindowState(Qt.WindowState.WindowMaximized)
|
||
except Exception:
|
||
self.resize(760, 580)
|
||
|
||
icon_path = get_resource_path("icon.ico")
|
||
if os.path.exists(icon_path):
|
||
self.setWindowIcon(QIcon(icon_path))
|
||
elif getattr(sys, 'frozen', False):
|
||
|
||
provider = QFileIconProvider()
|
||
exe_icon = provider.icon(QFileInfo(sys.executable))
|
||
if not exe_icon.isNull(): self.setWindowIcon(exe_icon)
|
||
|
||
self.setStyleSheet("""
|
||
QMainWindow { background-color: #ffffff; }
|
||
QTextEdit { border: 1px solid #e8eaed; border-radius: 8px; background-color: #f8f9fa; font-family: Consolas, monospace; font-size: 13px; color: #3c4043; padding: 10px; }
|
||
QStatusBar { background-color: #f8f9fa; color: #5f6368; border-top: 1px solid #e8eaed; padding: 5px; }
|
||
QStatusBar QLabel { color: #5f6368; font-size: 13px; padding: 2px; background: transparent; }
|
||
""")
|
||
|
||
self.icon_btn_style = "QPushButton { background-color: #f1f3f4; border: 1px solid transparent; border-radius: 8px; } QPushButton:hover { background-color: #e8eaed; } QPushButton:pressed { background-color: #dadce0; }"
|
||
self.primary_btn_style = "QPushButton { background-color: #1A73E8; color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: bold; } QPushButton:hover { background-color: #1B66C9; } QPushButton:pressed { background-color: #174EA6; }"
|
||
self.danger_btn_style = "QPushButton { background-color: #D93025; color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: bold; } QPushButton:hover { background-color: #C5221F; } QPushButton:pressed { background-color: #A50E0E; }"
|
||
self.success_btn_style = "QPushButton { background-color: #1E8E3E; color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: bold; } QPushButton:hover { background-color: #188038; } QPushButton:pressed { background-color: #137333; }"
|
||
|
||
def init_ui(self):
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
self.stacked_layout = QStackedLayout(central)
|
||
|
||
self.main_panel = QWidget()
|
||
layout = QVBoxLayout(self.main_panel)
|
||
layout.setSpacing(12)
|
||
layout.setContentsMargins(20, 20, 20, 16)
|
||
|
||
self.drop_area = DropArea(self)
|
||
self.drop_area.fileDropped.connect(self.on_script_selected)
|
||
layout.addWidget(self.drop_area, stretch=1)
|
||
|
||
self.log_container = QWidget()
|
||
log_lay = QVBoxLayout(self.log_container)
|
||
log_lay.setContentsMargins(0, 0, 0, 0)
|
||
|
||
log_header = QHBoxLayout()
|
||
log_header.setContentsMargins(5, 0, 5, 2)
|
||
self.log_title = QLabel(_("Execution Log"))
|
||
self.log_title.setStyleSheet("color: #5f6368; font-weight: bold; font-size: 13px;")
|
||
|
||
self.btn_toggle_log_mode = QPushButton()
|
||
self.btn_toggle_log_mode.setCheckable(True)
|
||
self.btn_toggle_log_mode.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self.btn_toggle_log_mode.setStyleSheet("""
|
||
QPushButton { background-color: transparent; color: #1A73E8; border: none; font-size: 12px; font-weight: bold; }
|
||
QPushButton:hover { color: #1B66C9; text-decoration: underline; }
|
||
""")
|
||
self.btn_toggle_log_mode.clicked.connect(self.on_log_mode_toggled)
|
||
|
||
log_header.addWidget(self.log_title)
|
||
log_header.addStretch(1)
|
||
log_header.addWidget(self.btn_toggle_log_mode)
|
||
|
||
self.log_stack_widget = QWidget()
|
||
self.log_stack = QStackedLayout(self.log_stack_widget)
|
||
self.log_stack.setContentsMargins(0, 0, 0, 0)
|
||
|
||
self.log_concise = QTextEdit()
|
||
self.log_detailed = QTextEdit()
|
||
|
||
for text_edit in (self.log_concise, self.log_detailed):
|
||
text_edit.setReadOnly(True)
|
||
text_edit.setMinimumHeight(80)
|
||
text_edit.setMaximumHeight(160)
|
||
text_edit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||
text_edit.customContextMenuRequested.connect(self.show_log_context_menu)
|
||
self.log_stack.addWidget(text_edit)
|
||
|
||
log_lay.addLayout(log_header)
|
||
log_lay.addWidget(self.log_stack_widget)
|
||
self.log_container.hide()
|
||
layout.addWidget(self.log_container)
|
||
|
||
btn_layout = QHBoxLayout()
|
||
btn_layout.setSpacing(12)
|
||
btn_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
self.btn_left = AnimatedButton("")
|
||
self.btn_left.setFixedSize(44, 44)
|
||
self.btn_left.setStyleSheet(self.icon_btn_style)
|
||
self.btn_left.clicked.connect(self.on_left_btn_clicked)
|
||
btn_layout.addWidget(self.btn_left)
|
||
|
||
self.btn_main = AnimatedButton("")
|
||
self.btn_main.setFixedHeight(44)
|
||
self.btn_main.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||
self.btn_main.clicked.connect(self.on_main_btn_clicked)
|
||
btn_layout.addWidget(self.btn_main)
|
||
|
||
self.btn_right = AnimatedButton("")
|
||
self.btn_right.setFixedSize(44, 44)
|
||
self.btn_right.setIcon(get_svg_icon('settings', "#5F6368"))
|
||
self.btn_right.setStyleSheet(self.icon_btn_style)
|
||
self.btn_right.clicked.connect(self.show_settings)
|
||
btn_layout.addWidget(self.btn_right)
|
||
|
||
layout.addLayout(btn_layout)
|
||
self.stacked_layout.addWidget(self.main_panel)
|
||
|
||
self.settings_panel = SettingsPanel(self)
|
||
self.stacked_layout.addWidget(self.settings_panel)
|
||
self.stacked_layout.setCurrentWidget(self.main_panel)
|
||
|
||
is_concise = self.settings_panel.concise_log_check.isChecked()
|
||
self.btn_toggle_log_mode.setChecked(not is_concise)
|
||
self.btn_toggle_log_mode.setText(_("Detailed Mode") if is_concise else _("Concise Mode"))
|
||
self.log_stack.setCurrentWidget(self.log_concise if is_concise else self.log_detailed)
|
||
|
||
self.status_bar = self.statusBar()
|
||
self.status_label = QLabel(_("Status: Ready"))
|
||
self.status_bar.addWidget(self.status_label)
|
||
|
||
current_year = time.localtime().tm_year
|
||
year_str = f"2026-{current_year}" if current_year > 2026 else "2026"
|
||
self.copyright_label = QLabel(f"Copyright © {year_str} {__author__}. ")
|
||
|
||
self.copyright_label.setStyleSheet("color: #bdc1c6; font-size: 11px; font-weight: bold; background: transparent; padding-right: 5px;")
|
||
self.status_bar.addPermanentWidget(self.copyright_label)
|
||
|
||
self.update_ui_state("idle")
|
||
|
||
def show_notification(self, msg, timeout=4000):
|
||
msg = msg.replace('\n', ' ')
|
||
self.statusBar().showMessage(msg, timeout)
|
||
|
||
def _on_python_install_completed(self, success):
|
||
if success:
|
||
msg_box = QMessageBox(self)
|
||
msg_box.setWindowTitle(_("Install Complete"))
|
||
msg_box.setText(_(
|
||
"<b>Python Environment Installed Successfully!</b><br><br>"
|
||
"System environment variables updated. Recommended to exit and restart the software to apply changes."
|
||
))
|
||
msg_box.setIcon(QMessageBox.Icon.Information)
|
||
|
||
btn_exit = msg_box.addButton(_("Exit Now"), QMessageBox.ButtonRole.AcceptRole)
|
||
btn_later = msg_box.addButton(_("Later"), QMessageBox.ButtonRole.RejectRole)
|
||
|
||
btn_exit.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #1A73E8; color: #ffffff; border: none;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 8px 16px; min-width: 80px;
|
||
}
|
||
QPushButton:hover { background-color: #1B66C9; }
|
||
QPushButton:pressed { background-color: #174EA6; }
|
||
""")
|
||
|
||
btn_later.setStyleSheet("""
|
||
QPushButton {
|
||
background-color: #f1f5f9; color: #475569; border: 1px solid #cbd5e1;
|
||
border-radius: 6px; font-size: 13px; font-weight: bold;
|
||
padding: 8px 16px; min-width: 80px;
|
||
}
|
||
QPushButton:hover { background-color: #e2e8f0; }
|
||
QPushButton:pressed { background-color: #cbd5e1; }
|
||
""")
|
||
|
||
msg_box.exec()
|
||
|
||
if msg_box.clickedButton() == btn_exit:
|
||
QApplication.quit()
|
||
else:
|
||
self.show_notification(_("Python installed. Please restart QPyPack manually later."), 6000)
|
||
else:
|
||
self.show_error_log(_("[ERROR] Python installation failed or was cancelled."))
|
||
|
||
def show_error_log(self, msg):
|
||
if not self.log_container.isVisible():
|
||
self.toggle_log()
|
||
self.append_log(msg, is_error=True)
|
||
self.show_notification(_("Attention: Please check the log for details."), 6000)
|
||
|
||
def set_status(self, text):
|
||
self.status_label.setText(text)
|
||
self.adjust_status_bar()
|
||
|
||
def adjust_status_bar(self):
|
||
if not hasattr(self, 'status_label') or not hasattr(self, 'copyright_label') or not hasattr(self, 'status_bar'):
|
||
return
|
||
text = self.status_label.text()
|
||
metrics = self.status_label.fontMetrics()
|
||
text_width = metrics.horizontalAdvance(text)
|
||
copyright_width = self.copyright_label.fontMetrics().horizontalAdvance(self.copyright_label.text())
|
||
|
||
if text_width + copyright_width + 60 > self.status_bar.width():
|
||
self.copyright_label.hide()
|
||
else:
|
||
self.copyright_label.show()
|
||
|
||
def retranslate_ui(self):
|
||
self.btn_right.setToolTip(_("Configure Build Settings"))
|
||
is_log_open = self.log_container.isVisible()
|
||
if is_log_open:
|
||
self.btn_left.setToolTip(_("Toggle Execution Log"))
|
||
else:
|
||
if self.current_state in ("done", "failed"):
|
||
self.btn_left.setToolTip(_("Reset to Default Config"))
|
||
else:
|
||
self.btn_left.setToolTip(_("Toggle Execution Log"))
|
||
|
||
self.update_ui_state(self.current_state)
|
||
|
||
if hasattr(self, 'log_title'):
|
||
self.log_title.setText(_("Execution Log"))
|
||
is_concise = self.settings_panel.concise_log_check.isChecked()
|
||
self.btn_toggle_log_mode.setText(_("Detailed Mode") if is_concise else _("Concise Mode"))
|
||
|
||
if self.current_state == "idle":
|
||
self.set_status(_("Status: Ready"))
|
||
self.drop_area.label.setText(_("Python Packaging, Reimagined."))
|
||
self.drop_area.sub_label.setText(_("Drop Python source code to start"))
|
||
|
||
elif self.current_state == "ready":
|
||
mode_suffix = _(" [Console]") if not self.settings_panel.noconsole_check.isChecked() else _(" [No Console]")
|
||
self.set_status(_("Status: Loaded {filename}{mode}", filename=Path(self.script_path).name, mode=mode_suffix))
|
||
self.drop_area.label.setText(_("Loaded: {filename}", filename=Path(self.script_path).name))
|
||
self.drop_area.sub_label.setText(_("Ready, waiting for build."))
|
||
|
||
elif self.current_state == "building":
|
||
self.set_status(_("Status: Packaging ({engine}) ...", engine=self.settings_panel.engine_combo.currentText()))
|
||
|
||
elif self.current_state == "done":
|
||
self.set_status(_("Status: Build Completed"))
|
||
self.drop_area.label.setText(_("Build Successful"))
|
||
self.drop_area.sub_label.setText(_("Open output directory or reset workspace."))
|
||
|
||
elif self.current_state == "failed":
|
||
self.set_status(_("Status: Build Failed"))
|
||
self.drop_area.label.setText(_("Build Failed"))
|
||
self.drop_area.sub_label.setText(_("Check log output below for troubleshooting."))
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
self.adjust_status_bar()
|
||
|
||
def closeEvent(self, event):
|
||
try:
|
||
config = load_config()
|
||
if 'Settings' not in config: config['Settings'] = {}
|
||
if not self.isMaximized() and not self.isFullScreen():
|
||
config['Settings']['window_width'] = str(self.width())
|
||
config['Settings']['window_height'] = str(self.height())
|
||
config['Settings']['window_maximized'] = str(self.isMaximized())
|
||
save_config(config)
|
||
except Exception:
|
||
pass
|
||
|
||
if self.thread and self.thread.isRunning():
|
||
self.thread.cancel()
|
||
self.thread.wait(2000)
|
||
|
||
if self.analysis_thread and self.analysis_thread.isRunning():
|
||
self.analysis_thread.requestInterruption()
|
||
self.analysis_thread.wait(1000)
|
||
|
||
if hasattr(self,'settings_panel') and hasattr(self.settings_panel, 'scanner_thread'):
|
||
if self.settings_panel.scanner_thread and self.settings_panel.scanner_thread.isRunning():
|
||
self.settings_panel.scanner_thread.requestInterruption()
|
||
self.settings_panel.scanner_thread.wait(1000)
|
||
|
||
super().closeEvent(event)
|
||
|
||
def update_ui_state(self, state):
|
||
self.current_state = state
|
||
self.btn_right.setEnabled(state != "building")
|
||
self.drop_area.setAcceptDrops(state != "building")
|
||
|
||
if state in ("idle", "ready"):
|
||
is_log_open = self.log_container.isVisible()
|
||
icon_name = 'expand_less' if is_log_open else 'expand_more'
|
||
self.btn_left.setIcon(get_svg_icon(icon_name, "#5F6368"))
|
||
|
||
self.btn_main.setText(_("Start Build"))
|
||
self.btn_main.setIcon(get_svg_icon('play', "white"))
|
||
self.btn_main.setStyleSheet(self.primary_btn_style)
|
||
|
||
elif state == "building":
|
||
is_log_open = self.log_container.isVisible()
|
||
icon_name = 'expand_less' if is_log_open else 'expand_more'
|
||
self.btn_left.setIcon(get_svg_icon(icon_name, "#5F6368"))
|
||
|
||
self.btn_main.setText(_("Stop Build"))
|
||
self.btn_main.setIcon(get_svg_icon('stop', "white"))
|
||
self.btn_main.setStyleSheet(self.danger_btn_style)
|
||
|
||
elif state in ("done", "failed"):
|
||
self.btn_left.setIcon(get_svg_icon('refresh', "#5F6368"))
|
||
if state == "done":
|
||
self.btn_main.setText(_("Open Directory"))
|
||
self.btn_main.setIcon(get_svg_icon('folder', "white"))
|
||
self.btn_main.setStyleSheet(self.success_btn_style)
|
||
else:
|
||
self.btn_main.setText(_("Rebuild"))
|
||
self.btn_main.setIcon(get_svg_icon('refresh', "white"))
|
||
self.btn_main.setStyleSheet(self.danger_btn_style)
|
||
|
||
def on_left_btn_clicked(self):
|
||
if self.current_state in ("done", "failed"):
|
||
self.reset_to_ready()
|
||
else:
|
||
self.toggle_log()
|
||
|
||
def on_main_btn_clicked(self):
|
||
if self.current_state in ("idle", "ready", "failed"): self.start_pack()
|
||
elif self.current_state == "building": self.cancel_pack()
|
||
elif self.current_state == "done": self.open_dist()
|
||
|
||
def toggle_log(self):
|
||
if self.log_container.isVisible(): self.log_container.hide()
|
||
else: self.log_container.show()
|
||
self.update_ui_state(self.current_state)
|
||
|
||
def show_settings(self): self._animate_switch(self.settings_panel)
|
||
|
||
def show_main(self):
|
||
self.settings_panel.load_from_config()
|
||
self._animate_switch(self.main_panel)
|
||
if self.script_path and self.current_state in ("done", "failed"):
|
||
self.reset_to_ready()
|
||
|
||
def save_settings_and_return(self):
|
||
self.settings_panel.save_to_config()
|
||
self._animate_switch(self.main_panel)
|
||
if self.script_path and self.current_state in ("done", "failed"):
|
||
self.reset_to_ready()
|
||
|
||
def _animate_switch(self, target_widget):
|
||
current_widget = self.stacked_layout.currentWidget()
|
||
if current_widget == target_widget:
|
||
return
|
||
|
||
if hasattr(self, 'anim_group') and self.anim_group.state() == QParallelAnimationGroup.State.Running:
|
||
self.anim_group.stop()
|
||
for attr in ('lbl_old', 'lbl_new'):
|
||
if hasattr(self, attr) and getattr(self, attr):
|
||
try: getattr(self, attr).deleteLater()
|
||
except RuntimeError: pass
|
||
|
||
pix_old = current_widget.grab()
|
||
|
||
self.stacked_layout.setCurrentWidget(target_widget)
|
||
target_widget.setGeometry(current_widget.geometry())
|
||
QApplication.processEvents()
|
||
pix_new = target_widget.grab()
|
||
|
||
self.lbl_old = QLabel(self.centralWidget())
|
||
self.lbl_old.setPixmap(pix_old)
|
||
self.lbl_old.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
self.lbl_old.show()
|
||
|
||
self.lbl_new = QLabel(self.centralWidget())
|
||
self.lbl_new.setPixmap(pix_new)
|
||
self.lbl_new.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
self.lbl_new.show()
|
||
|
||
rect = current_widget.geometry()
|
||
x, y, w, h = rect.x(), rect.y(), rect.width(), rect.height()
|
||
direction = 1 if target_widget == self.settings_panel else -1
|
||
|
||
self.lbl_old.setGeometry(x, y, w, h)
|
||
self.lbl_new.setGeometry(x + w * direction, y, w, h)
|
||
|
||
self.anim_group = QParallelAnimationGroup(self)
|
||
|
||
anim_old = QPropertyAnimation(self.lbl_old, b"pos")
|
||
anim_old.setDuration(280)
|
||
anim_old.setEndValue(QPointF(x - w * direction, y))
|
||
anim_old.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||
|
||
anim_new = QPropertyAnimation(self.lbl_new, b"pos")
|
||
anim_new.setDuration(280)
|
||
anim_new.setEndValue(QPointF(x, y))
|
||
anim_new.setEasingCurve(QEasingCurve.Type.OutCubic)
|
||
|
||
self.anim_group.addAnimation(anim_old)
|
||
self.anim_group.addAnimation(anim_new)
|
||
|
||
def cleanup():
|
||
if hasattr(self, 'lbl_old') and self.lbl_old: self.lbl_old.deleteLater()
|
||
if hasattr(self, 'lbl_new') and self.lbl_new: self.lbl_new.deleteLater()
|
||
|
||
self.anim_group.finished.connect(cleanup)
|
||
self.anim_group.start()
|
||
|
||
def _trigger_python_download_dialog(self, is_missing_mode=False):
|
||
dialog = PythonInstallerDialog(self, is_missing_mode=is_missing_mode)
|
||
result = dialog.exec()
|
||
if result == QDialog.DialogCode.Accepted:
|
||
selected_version = dialog.get_selected_version()
|
||
self._auto_install_python(selected_version)
|
||
elif result == 3:
|
||
local_path = dialog.get_selected_local_path()
|
||
if local_path and os.path.exists(local_path):
|
||
self.settings_panel.python_path_combo.setCurrentText(local_path)
|
||
self.show_notification(_("Switched to local Python environment: {path}", path=local_path))
|
||
elif result == 2:
|
||
self.show_settings()
|
||
self.settings_panel.tabs.setCurrentIndex(0)
|
||
self.settings_panel.sub_tabs.setCurrentIndex(0)
|
||
self.settings_panel.python_path_combo.setFocus()
|
||
self.show_notification(_("Switched to [Build Settings] -> [Engine], please set the Python path."), 6000)
|
||
|
||
def _auto_install_python(self, version):
|
||
import urllib.request
|
||
from urllib.error import URLError
|
||
from PySide6.QtWidgets import QProgressDialog
|
||
|
||
url_hw = f"https://repo.huaweicloud.com/python/{version}/python-{version}-amd64.exe"
|
||
url_off = f"https://www.python.org/ftp/python/{version}/python-{version}-amd64.exe"
|
||
|
||
exe_path = Path(tempfile.gettempdir()) / f"python-{version}-amd64.exe"
|
||
|
||
progress = QProgressDialog(_("Downloading Python {ver}... Please wait.", ver=version), _("Cancel"), 0, 100, self)
|
||
progress.setWindowTitle(_("One-Click Install"))
|
||
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||
progress.setMinimumDuration(0)
|
||
progress.setValue(0)
|
||
|
||
def report(block_num, block_size, total_size):
|
||
if total_size > 0:
|
||
percent = int(block_num * block_size * 100 / total_size)
|
||
progress.setValue(min(percent, 100))
|
||
QApplication.processEvents()
|
||
if progress.wasCanceled():
|
||
raise Exception("Cancelled")
|
||
|
||
try:
|
||
target_url = url_hw if I18N.current_lang == "zh_CN" else url_off
|
||
try:
|
||
urllib.request.urlretrieve(target_url, exe_path.as_posix(), reporthook=report)
|
||
except URLError:
|
||
progress.setValue(0)
|
||
urllib.request.urlretrieve(url_off, exe_path.as_posix(), reporthook=report)
|
||
|
||
progress.setValue(100)
|
||
self.show_notification(_("Download complete. Starting Python installation..."), 5000)
|
||
|
||
self.install_monitor_thread = PythonInstallMonitorThread(exe_path.as_posix())
|
||
self.install_monitor_thread.finished_signal.connect(self._on_python_install_completed)
|
||
self.install_monitor_thread.start()
|
||
|
||
except Exception as e:
|
||
if str(e) != "Cancelled":
|
||
self.show_error_log(f"[ERROR] Python download failed: {e}")
|
||
|
||
def on_main_resources_dropped(self, paths):
|
||
if not self.script_path or not Path(self.script_path).exists():
|
||
self.show_error_log(_("[ERROR] Please load a valid Python source file first!"))
|
||
return
|
||
|
||
self.settings_panel.on_resources_dropped(paths)
|
||
|
||
self.drop_area.icon_widget.trigger_drop_pop()
|
||
|
||
add_data_items = [self.settings_panel.add_data_list.item(i).data(Qt.ItemDataRole.UserRole)
|
||
for i in range(self.settings_panel.add_data_list.count())]
|
||
self.drop_area.render_asset_chips(self.script_path, add_data_items)
|
||
|
||
count = len(paths)
|
||
if count == 1:
|
||
name = Path(paths[0]).name
|
||
self.show_notification(_("[INFO] Added '{name}' to Additional Resources.", name=name))
|
||
else:
|
||
self.show_notification(_("[INFO] Added {count} resource item(s) to Additional Resources.", count=count))
|
||
|
||
def on_script_selected(self, path):
|
||
path = Path(path).resolve().as_posix()
|
||
if is_cloud_locked(path):
|
||
self.show_error_log(_("[ERROR] Target file is locked or encrypted by cloud drive. Please decrypt and try again."))
|
||
return
|
||
|
||
if self.script_path != path:
|
||
self.settings_panel.icon_edit.clear()
|
||
self.settings_panel.hidden_edit.clear()
|
||
self.settings_panel.exclude_edit.clear()
|
||
self.settings_panel.add_data_list.clear()
|
||
self.settings_panel.reqs_file_edit.clear()
|
||
self.settings_panel.name_edit.clear()
|
||
|
||
self.settings_panel.keep_venv_check.setChecked(False)
|
||
|
||
self.script_path = path
|
||
self.drop_area.set_loading(Path(path).name)
|
||
self.drop_area.icon_widget.trigger_drop_pop()
|
||
self.set_status(_("Status: Parsing {filename}...", filename=Path(path).name))
|
||
|
||
if self.analysis_thread and self.analysis_thread.isRunning():
|
||
self.analysis_thread.requestInterruption()
|
||
self.analysis_thread.wait(1000)
|
||
|
||
self.analysis_thread = ScriptAnalysisThread(self.script_path)
|
||
self.analysis_thread.analysis_done.connect(self.on_analysis_finished)
|
||
self.analysis_thread.start()
|
||
|
||
def on_analysis_finished(self, app_name, version, author, desc, script_imports):
|
||
path = self.script_path
|
||
if not path: return
|
||
|
||
if version: self.settings_panel.ver_ver.setText(version)
|
||
else: self.settings_panel.ver_ver.setText("1.0.0")
|
||
|
||
self.settings_panel.ver_comp.setText(_(author) if author == "Independent Developer" else author)
|
||
self.settings_panel.ver_desc.setText(_(desc) if desc == "Desktop Application" else desc)
|
||
|
||
gui_libs = {
|
||
'pyqt5', 'pyqt6', 'pyside2', 'pyside6', 'tkinter', 'wx', 'kivy', 'libavg',
|
||
'pysimplegui', 'customtkinter', 'turtle', 'easygui', 'pygame', 'arcade',
|
||
'dearpygui', 'flet', 'webview', 'remi'
|
||
}
|
||
|
||
has_gui = any(lib in {m.lower() for m in script_imports} for lib in gui_libs)
|
||
self.settings_panel.noconsole_check.setChecked(has_gui)
|
||
|
||
script_dir = Path(path).parent
|
||
|
||
found_venv_name = None
|
||
try:
|
||
import hashlib
|
||
import re
|
||
|
||
script_path_obj = Path(path).resolve()
|
||
raw_stem = script_path_obj.stem
|
||
clean_stem = re.sub(r'[^a-zA-Z0-9]', '', raw_stem) or "script"
|
||
short_stem = clean_stem[:12]
|
||
path_hash = hashlib.md5(script_path_obj.as_posix().encode('utf-8')).hexdigest()[:6]
|
||
|
||
target_prefix = f".qpypack_venv_{short_stem}_{path_hash}"
|
||
|
||
if script_dir.exists():
|
||
for p in script_dir.iterdir():
|
||
if p.is_dir() and p.name.startswith(target_prefix):
|
||
found_venv_name = p.name
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
if found_venv_name:
|
||
self.settings_panel.venv_check.setChecked(True)
|
||
self.settings_panel.keep_venv_check.setChecked(True)
|
||
self.append_log(_("[INFO] Auto-detected existing virtual environment '{venv}'. 'Keep Local Venv' is checked.", venv=found_venv_name))
|
||
else:
|
||
self.settings_panel.keep_venv_check.setChecked(False)
|
||
|
||
default_output_name = f"{app_name}_{version}" if version else app_name
|
||
self.settings_panel.name_edit.setText(default_output_name)
|
||
|
||
auto_icon = None
|
||
if self.settings_panel.auto_icon_check.isChecked():
|
||
ext_priority = [".ico", ".png", ".jpg", ".jpeg", ".svg"]
|
||
if sys.platform == "darwin":
|
||
ext_priority = [".icns", ".png", ".svg", ".ico"]
|
||
name_priority = ["logo", "icon", "app", "favicon", Path(path).stem]
|
||
|
||
found = False
|
||
for ext in ext_priority:
|
||
for name in name_priority:
|
||
for n_variant in [name, name.lower(), name.upper(), name.capitalize()]:
|
||
trial = script_dir / f"{n_variant}{ext}"
|
||
if trial.exists() and trial.is_file():
|
||
auto_icon = trial
|
||
found = True
|
||
break
|
||
if found: break
|
||
if found: break
|
||
|
||
if auto_icon:
|
||
self.settings_panel.icon_edit.setText(auto_icon.resolve().as_posix())
|
||
|
||
self.drop_area.set_success(Path(path).name, custom_icon_path=auto_icon)
|
||
add_data_items = [self.settings_panel.add_data_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.settings_panel.add_data_list.count())]
|
||
self.drop_area.render_asset_chips(self.script_path, add_data_items)
|
||
mode_suffix = _(" [Console]") if not has_gui else _(" [No Console]")
|
||
self.set_status(_("Status: Loaded {filename}{mode}", filename=Path(path).name, mode=mode_suffix))
|
||
|
||
self.log_concise.clear()
|
||
self.log_detailed.clear()
|
||
self.append_log(_("Loaded: {filename}", filename=path))
|
||
self.show_notification(_("Script loaded. You can drag asset folders or data files directly here to bundle them."), 6000)
|
||
self.btn_main.setEnabled(True)
|
||
self.update_ui_state("ready")
|
||
|
||
def cancel_pack(self):
|
||
if self.thread and self.thread.isRunning():
|
||
self.thread.cancel()
|
||
self.thread.wait(1000)
|
||
|
||
self.append_log(_("[INFO] Build Cancelled."))
|
||
self.set_status(_("Status: Build Cancelled"))
|
||
|
||
if self.script_path and Path(self.script_path).exists():
|
||
self.reset_to_ready()
|
||
else:
|
||
self.reset_all()
|
||
|
||
self.show_notification(_("Build Cancelled"))
|
||
|
||
def start_pack(self):
|
||
if self.current_state == "building":
|
||
return
|
||
|
||
if not self.script_path or not Path(self.script_path).exists():
|
||
self.show_error_log(_("[ERROR] Please load a valid Python source file first!"))
|
||
return
|
||
|
||
sp = self.settings_panel
|
||
|
||
raw_py = sp.python_path_combo.currentText().strip()
|
||
if " (Python " in raw_py:
|
||
raw_py = raw_py.split(" (Python ")[0].strip()
|
||
if not raw_py:
|
||
raw_py = get_python_executable()
|
||
|
||
if raw_py:
|
||
resolved_py = shutil.which(raw_py)
|
||
if resolved_py:
|
||
raw_py = Path(resolved_py).resolve().as_posix()
|
||
|
||
|
||
is_valid_py = False
|
||
if raw_py:
|
||
try:
|
||
clean_env = os.environ.copy()
|
||
clean_env.pop("PYTHONHOME", None)
|
||
clean_env.pop("PYTHONPATH", None)
|
||
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "env": clean_env, "timeout": 3}
|
||
if os.name == 'nt': kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
|
||
proc = subprocess.run([raw_py, "-c", "import sys; print(sys.version_info.major)"], **kwargs)
|
||
if proc.returncode == 0 and "3" in proc.stdout:
|
||
is_valid_py = True
|
||
except Exception:
|
||
pass
|
||
|
||
if not is_valid_py:
|
||
if os.name == 'nt':
|
||
self._trigger_python_download_dialog(is_missing_mode=True)
|
||
else:
|
||
msg = QMessageBox(self)
|
||
msg.setIcon(QMessageBox.Icon.Warning)
|
||
msg.setWindowTitle(_("Python Environment Required"))
|
||
msg.setText(_("<b>Python is not detected on your system!</b><br><br>QPyPack requires a Python environment to compile your code.<br>If you haven't installed Python, please download and install it."))
|
||
btn_down = msg.addButton(_("Download Python"), QMessageBox.ButtonRole.ActionRole)
|
||
msg.addButton(_("Cancel"), QMessageBox.ButtonRole.RejectRole)
|
||
msg.exec()
|
||
if msg.clickedButton() == btn_down:
|
||
__import__('webbrowser').open("https://www.python.org/downloads/")
|
||
return
|
||
|
||
validation_errors = []
|
||
|
||
if is_cloud_locked(self.script_path):
|
||
validation_errors.append(_("Script file is locked by cloud sync"))
|
||
|
||
icon_path_str = sp.icon_edit.text().strip()
|
||
if icon_path_str and not Path(icon_path_str).exists():
|
||
validation_errors.append(_("Icon file not found: {path}", path=icon_path_str))
|
||
|
||
for i in range(sp.add_data_list.count()):
|
||
item_data = sp.add_data_list.item(i).data(Qt.ItemDataRole.UserRole) if sp.add_data_list.item(i) else None
|
||
if not item_data or len(item_data) != 3:
|
||
continue
|
||
r_type, src, dst = item_data
|
||
if not Path(src).exists():
|
||
validation_errors.append(_("Resource not found: {path}", path=src))
|
||
|
||
if sp.out_mode_combo.currentIndex() == 1:
|
||
custom_out = sp.out_dir_edit.text().strip()
|
||
if custom_out:
|
||
try:
|
||
out_dir = Path(custom_out)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
test_file = out_dir / ".qpypack_write_test"
|
||
test_file.touch()
|
||
test_file.unlink()
|
||
except Exception as e:
|
||
validation_errors.append(_("Output directory not writable: {error}", error=str(e)))
|
||
|
||
if validation_errors:
|
||
error_msg = "[ERROR] Configuration validation failed:\n" + "\n".join(f" • {err}" for err in validation_errors)
|
||
self.show_error_log(error_msg)
|
||
return
|
||
|
||
app_name = sp.name_edit.text().strip() or Path(self.script_path).stem
|
||
engine = sp.engine_combo.currentText()
|
||
|
||
version_file = None
|
||
if engine == "PyInstaller" and os.name == "nt" and sp.ver_ver.text().strip():
|
||
try:
|
||
v_str = sp.ver_ver.text().strip()
|
||
v_nums = re.findall(r'\d+', v_str)
|
||
v_tuple = ",".join((v_nums + ['0', '0', '0', '0'])[:4])
|
||
|
||
comp_escaped = sp.ver_comp.text().replace("\\", "\\\\").replace("'", "\\'")
|
||
desc_escaped = sp.ver_desc.text().replace("\\", "\\\\").replace("'", "\\'")
|
||
v_str_escaped = v_str.replace("\\", "\\\\").replace("'", "\\'")
|
||
|
||
clean_product_name = app_name
|
||
if v_str:
|
||
suffix = f"_{v_str}"
|
||
if clean_product_name.endswith(suffix):
|
||
clean_product_name = clean_product_name[:-len(suffix)]
|
||
|
||
|
||
app_name_escaped = clean_product_name.replace("\\", "\\\\").replace("'", "\\'")
|
||
orig_filename_escaped = app_name.replace("\\", "\\\\").replace("'", "\\'")
|
||
|
||
content = f'''VSVersionInfo(ffi=FixedFileInfo(filevers=({v_tuple}),prodvers=({v_tuple}),mask=0x3f,flags=0x0,OS=0x40004,fileType=0x1,subtype=0x0,date=(0,0)),kids=[StringFileInfo([StringTable('040904B0',[StringStruct('CompanyName','{comp_escaped}'),StringStruct('FileDescription','{desc_escaped}'),StringStruct('FileVersion','{v_str_escaped}'),StringStruct('ProductVersion','{v_str_escaped}'),StringStruct('ProductName','{app_name_escaped}'),StringStruct('InternalName','{app_name_escaped}'),StringStruct('OriginalFilename','{orig_filename_escaped}.exe')])]),VarFileInfo([VarStruct('Translation',[1033,1200])])])'''
|
||
version_file = Path(tempfile.gettempdir()) / f"qpypack_{app_name}_version.txt"
|
||
version_file.write_text(content, encoding='utf-8')
|
||
except: pass
|
||
|
||
icon_path_str = sp.icon_edit.text().strip()
|
||
|
||
if icon_path_str and not Path(icon_path_str).exists():
|
||
self.show_error_log(_("[ERROR] The specified icon file does not exist: {path}", path=icon_path_str))
|
||
return
|
||
|
||
temp_icon_file = None
|
||
if icon_path_str:
|
||
|
||
icon_path = Path(icon_path_str)
|
||
needed_ext = "ico" if os.name == "nt" else ("icns" if sys.platform == "darwin" else "png")
|
||
|
||
is_valid_native_icon = False
|
||
if icon_path.suffix.lower() == f".{needed_ext}":
|
||
try:
|
||
with open(icon_path, 'rb') as f:
|
||
header = f.read(4)
|
||
if needed_ext == "ico" and header == b'\x00\x00\x01\x00':
|
||
is_valid_native_icon = True
|
||
elif needed_ext == "icns" and header == b'icns':
|
||
is_valid_native_icon = True
|
||
except:
|
||
is_valid_native_icon = False
|
||
|
||
if is_valid_native_icon:
|
||
icon_path_str = icon_path.as_posix()
|
||
else:
|
||
temp_ico = Path(tempfile.gettempdir()) / f"qpypack_sanitized_icon_{int(time.time())}.{needed_ext}"
|
||
if convert_image_to_format(icon_path.as_posix(), temp_ico.as_posix(), needed_ext):
|
||
icon_path_str = temp_ico.as_posix()
|
||
temp_icon_file = temp_ico.as_posix()
|
||
else:
|
||
if needed_ext == "icns":
|
||
icon_path_str = ""
|
||
self.show_notification(_("[WARN] Icon conversion to .icns failed, building without icon."))
|
||
else:
|
||
icon_path_str = icon_path.as_posix()
|
||
|
||
add_data_items = [sp.add_data_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(sp.add_data_list.count())]
|
||
main_pip = sp._get_url_from_combo(sp.pip_source_combo)
|
||
backup_pip = sp._get_url_from_combo(sp.pip_backup_combo)
|
||
|
||
mappings = DEFAULT_MAPPINGS.copy()
|
||
for r in range(sp.mapping_table.rowCount()):
|
||
item_k = sp.mapping_table.item(r, 0)
|
||
item_v = sp.mapping_table.item(r, 1)
|
||
k = item_k.text().strip() if item_k else ""
|
||
v = item_v.text().strip() if item_v else ""
|
||
if k and v: mappings[k] = v
|
||
|
||
params = {
|
||
'engine': engine,
|
||
'python_exe': raw_py,
|
||
'script_path': self.script_path,
|
||
'enable_sign': sp.enable_sign_check.isChecked(),
|
||
'cert_path': sp.cert_path_edit.text().strip(),
|
||
'cert_pass': sp.cert_pass_edit.text().strip(),
|
||
'app_name': app_name,
|
||
'onefile': sp.rb_onefile.isChecked(),
|
||
'contents_dir': sp.contents_dir_edit.text().strip() or '_internal',
|
||
'noconsole': sp.noconsole_check.isChecked(),
|
||
'icon': icon_path_str,
|
||
'use_reqs': sp.reqs_check.isChecked(),
|
||
'use_pipreqs': sp.pipreqs_check.isChecked(),
|
||
'use_pipreqs_dir': sp.pipreqs_dir_check.isChecked(),
|
||
'reqs_file': sp.reqs_file_edit.text().strip(),
|
||
'hidden_imports': sp.hidden_edit.text(),
|
||
'add_data_list': add_data_items,
|
||
'upx': sp.upx_check.isChecked(),
|
||
'upx_path': sp.upx_path_edit.text().strip(),
|
||
'cpu_cores': sp.cores_spin.value(),
|
||
'exclude_modules': sp.exclude_edit.text().strip(),
|
||
'out_mode': sp.out_mode_combo.currentIndex(),
|
||
'custom_out_dir': sp.out_dir_edit.text().strip(),
|
||
'temp_sandbox_mode': sp.sandbox_mode_combo.currentIndex(),
|
||
'use_venv': sp.venv_check.isChecked(),
|
||
'keep_venv': sp.keep_venv_check.isChecked(),
|
||
'venv_mode': 'shared' if sp.rb_venv_shared.isChecked() else 'isolated',
|
||
'shared_venv_dir': sp.shared_venv_dir_edit.text().strip(),
|
||
'clean_all': sp.clean_all_check.isChecked(),
|
||
'version_file': version_file.as_posix() if version_file else None,
|
||
'temp_icon_file': temp_icon_file,
|
||
'ver_comp': sp.ver_comp.text(),
|
||
'ver_desc': sp.ver_desc.text(),
|
||
'ver_ver': sp.ver_ver.text(),
|
||
'pip_index_url': main_pip,
|
||
'pip_index_backup': backup_pip,
|
||
'concise_log': sp.concise_log_check.isChecked(),
|
||
'auto_save_log': sp.auto_save_log_check.isChecked(),
|
||
'lite_mode': sp.rb_lite_mode.isChecked(),
|
||
'pyi_version': sp.pyi_ver_edit.text().strip(),
|
||
'nuitka_version': sp.nuitka_ver_edit.text().strip(),
|
||
'mappings': mappings,
|
||
'enable_backport_shield': sp.enable_shield_check.isChecked(),
|
||
'backport_rules': sp.get_current_active_backport_rules(),
|
||
}
|
||
|
||
self.log_concise.clear()
|
||
self.log_detailed.clear()
|
||
if not self.log_container.isVisible(): self.toggle_log()
|
||
|
||
self.thread = PackingThread(params)
|
||
self.thread.progress.connect(self.append_log)
|
||
self.thread.build_finished.connect(self.on_pack_finished)
|
||
self.thread.start()
|
||
|
||
self.set_status(_("Status: Packaging ({engine}) ...", engine=engine))
|
||
self.update_ui_state("building")
|
||
self.drop_area.start_build_anim()
|
||
|
||
def on_pack_finished(self, success, msg, failed_pkgs=None):
|
||
if not success and not (self.thread and getattr(self.thread, '_is_cancelled', False)):
|
||
log_content = self.log_detailed.toPlainText()
|
||
|
||
if "MSVC 14.3 or later is required" in log_content or "scons environment variable 'CC' is not set" in log_content:
|
||
msg = (
|
||
_("[Environment Error] Build failed: C++ compiler version is incompatible.") + "\n\n" +
|
||
_("Reason: Python 3.11+ requires Visual Studio 2022 (MSVC 14.3) with Windows SDK.") + "\n" +
|
||
_("Solution:\n1. Install Visual Studio 2022 (Check 'Desktop development with C++' and 'Windows SDK').\n2. Or switch the engine to [PyInstaller] in Build Settings.")
|
||
)
|
||
elif "MinGW64 does not work with Python 3.13" in log_content or "ziglang" in log_content:
|
||
msg = (
|
||
_("[Environment Error] Python 3.13+ Compiler Requirement.") + "\n\n" +
|
||
_("Reason: MinGW64 is no longer supported on Python 3.13+. Please ensure Zig or VS 2022 is installed.") + "\n" +
|
||
_("Solution:\n1. In Build Settings, switch to [PyInstaller] engine (Recommended).\n2. Or install Visual Studio 2022 with C++ support.")
|
||
)
|
||
|
||
if not success and self.thread and self.thread.params.get('lite_mode') and not getattr(self.thread, '_is_cancelled', False):
|
||
msg += "\n\n" + _("Tip: Build failed in Lite Mode. You can try switching to [Compatibility Mode] in Build Settings and rebuild.")
|
||
|
||
self.append_log("\n" + msg)
|
||
self.drop_area.stop_build_anim()
|
||
|
||
if self.thread and getattr(self.thread, '_is_cancelled', False):
|
||
if self.script_path and Path(self.script_path).exists():
|
||
self.reset_to_ready()
|
||
else:
|
||
self.drop_area.reset()
|
||
self.update_ui_state("idle")
|
||
self.set_status(_("Status: Build Cancelled"))
|
||
return
|
||
play_alert(success, self.settings_panel.sound_notify_check.isChecked())
|
||
|
||
if success:
|
||
icon_path = self.settings_panel.icon_edit.text().strip()
|
||
self.drop_area.show_success(icon_path)
|
||
self.set_status(_("Status: Build Completed"))
|
||
self.update_ui_state("done")
|
||
else:
|
||
self.drop_area.show_failure()
|
||
self.set_status(_("Status: Build Failed"))
|
||
self.update_ui_state("failed")
|
||
|
||
if failed_pkgs and not (self.thread and getattr(self.thread, '_is_cancelled', False)):
|
||
warn_msg = _("Dependency Missing Warning: {pkgs} failed to install. Check log for details.", pkgs=", ".join(failed_pkgs))
|
||
self.show_notification(warn_msg, 6000)
|
||
err_log = _("[ERROR] Build completed, but the following dependencies failed to install:\n\n - {pkgs}\n\nNote: The application might raise ModuleNotFoundError at runtime.", pkgs=", ".join(failed_pkgs))
|
||
self.show_error_log(err_log)
|
||
|
||
def open_dist(self):
|
||
if self.settings_panel.out_mode_combo.currentIndex() == 1 and self.settings_panel.out_dir_edit.text().strip():
|
||
target = Path(self.settings_panel.out_dir_edit.text().strip())
|
||
else:
|
||
target = Path(self.script_path).parent if self.script_path else Path.cwd()
|
||
|
||
if target.exists():
|
||
try:
|
||
QDesktopServices.openUrl(QUrl.fromLocalFile(target.resolve().as_posix()))
|
||
except: pass
|
||
|
||
def reset_to_ready(self):
|
||
if self.script_path and Path(self.script_path).exists():
|
||
icon_path = self.settings_panel.icon_edit.text().strip()
|
||
self.drop_area.set_success(Path(self.script_path).name, custom_icon_path=icon_path)
|
||
self.update_ui_state("ready")
|
||
self.set_status(_("Status: Ready"))
|
||
self.show_notification(_("Script and settings retained. Ready to rebuild."))
|
||
else:
|
||
self.reset_all()
|
||
|
||
def reset_all(self):
|
||
self.script_path = ""
|
||
self.settings_panel.name_edit.clear()
|
||
self.settings_panel.icon_edit.clear()
|
||
self.settings_panel.hidden_edit.clear()
|
||
self.settings_panel.exclude_edit.clear()
|
||
self.settings_panel.add_data_list.clear()
|
||
self.settings_panel.reqs_file_edit.clear()
|
||
self.settings_panel.out_dir_edit.clear()
|
||
self.settings_panel.ver_ver.setText("1.0.0")
|
||
self.settings_panel.ver_comp.setText(_("Independent Developer"))
|
||
self.settings_panel.ver_desc.setText(_("Desktop Application"))
|
||
|
||
self.settings_panel.keep_venv_check.setChecked(False)
|
||
|
||
self.log_concise.clear()
|
||
self.log_detailed.clear()
|
||
|
||
if self.log_container.isVisible(): self.toggle_log()
|
||
self.drop_area.reset()
|
||
self.drop_area.render_asset_chips(None, None)
|
||
self.set_status(_("Status: Workspace Reset"))
|
||
self.update_ui_state("idle")
|
||
|
||
def on_log_mode_toggled(self, checked):
|
||
is_concise = not checked
|
||
self.settings_panel.concise_log_check.setChecked(is_concise)
|
||
self.btn_toggle_log_mode.setText(_("Detailed Mode") if is_concise else _("Concise Mode"))
|
||
self.log_stack.setCurrentWidget(self.log_concise if is_concise else self.log_detailed)
|
||
|
||
def append_log(self, msg, is_error=False):
|
||
if is_error:
|
||
logger.error(msg)
|
||
elif "[WARN]" in msg:
|
||
logger.warning(msg)
|
||
elif "[INFO]" in msg:
|
||
logger.info(msg)
|
||
else:
|
||
logger.debug(msg)
|
||
|
||
if msg.startswith("[DETAILED_ONLY]"):
|
||
clean_msg = msg.replace("[DETAILED_ONLY]", "", 1).lstrip('\r\n')
|
||
self._render_text_edit(self.log_detailed, clean_msg, is_error)
|
||
return
|
||
|
||
concise_lines = []
|
||
|
||
for line in msg.split('\n'):
|
||
line_strip = line.strip()
|
||
|
||
if not line_strip: continue
|
||
|
||
if is_error:
|
||
concise_lines.append(line)
|
||
continue
|
||
|
||
is_concise_kept = False
|
||
valid_prefixes = (
|
||
"[INFO]", "[WARN]", "[SUCCESS]", "[FAILED]", "[ERROR]", "[Syntax Error]",
|
||
"•", "---", "━", "!"
|
||
)
|
||
|
||
is_critical_engine_log = any(kw in line for kw in (
|
||
"FATAL:", "Disable Anti-Virus", "Failed to delete", "PermissionError", "Access is denied"
|
||
))
|
||
|
||
if any(line_strip.startswith(p) for p in valid_prefixes) or is_critical_engine_log:
|
||
concise_lines.append(line)
|
||
is_concise_kept = True
|
||
|
||
if self.current_state == "building":
|
||
if any(k in line for k in ("Nuitka", "Scons", "PyInstaller", "Compiling", "Building", "Linking")):
|
||
clean_sub = re.sub(r'^(Nuitka-Scons:|Nuitka:|INFO:\s*PyInstaller:|\d+\s+INFO:\s*)', '', line).strip()
|
||
if clean_sub and len(clean_sub) > 3 and not clean_sub.startswith("Used command line"):
|
||
if len(clean_sub) > 60: clean_sub = clean_sub[:57] + "..."
|
||
self.drop_area.sub_label.setText(clean_sub)
|
||
|
||
engine_name = self.settings_panel.engine_combo.currentText()
|
||
target_text = _("Status: Packaging ({engine}) ...", engine=engine_name).replace("Status: ", "").replace("状态: ", "")
|
||
if self.drop_area.label.text() != target_text:
|
||
self.drop_area.label.setText(target_text)
|
||
self.drop_area.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
|
||
|
||
if not is_concise_kept:
|
||
concise_lines.append(f"[BUILD] {clean_sub}")
|
||
|
||
elif line_strip.startswith(("[INFO]", "[WARN]", "[SUCCESS]", "[FAILED]", "[ERROR]")):
|
||
clean_text = line_strip
|
||
for prefix in ("[INFO]", "[WARN]", "[SUCCESS]", "[FAILED]", "[ERROR]"):
|
||
if clean_text.startswith(prefix):
|
||
clean_text = clean_text[len(prefix):].strip()
|
||
break
|
||
if len(clean_text) > 40: clean_text = clean_text[:37] + "..."
|
||
self.drop_area.label.setText(clean_text)
|
||
self.drop_area.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
|
||
|
||
concise_msg = '\n'.join(concise_lines)
|
||
|
||
if concise_msg:
|
||
self._render_text_edit(self.log_concise, concise_msg, is_error)
|
||
|
||
self._render_text_edit(self.log_detailed, msg, is_error)
|
||
|
||
def _render_text_edit(self, text_edit, msg, is_error):
|
||
if is_error:
|
||
safe_msg = msg.replace('<', '<').replace('>', '>').replace('\n', '<br>')
|
||
text_edit.append(f'<span style="color: #D93025; font-weight: bold;">{safe_msg}</span>')
|
||
else:
|
||
text_edit.append(msg)
|
||
text_edit.ensureCursorVisible()
|
||
|
||
def show_log_context_menu(self, pos):
|
||
current_log = self.log_stack.currentWidget()
|
||
menu = QMenu(self)
|
||
menu.setStyleSheet("""
|
||
QMenu {
|
||
background-color: #ffffff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
padding: 5px;
|
||
}
|
||
QMenu::item {
|
||
background-color: transparent;
|
||
color: #1e293b;
|
||
padding: 6px 16px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
font-family: Consolas, "Segoe UI", sans-serif;
|
||
}
|
||
QMenu::item:selected {
|
||
background-color: #eff6ff;
|
||
color: #2563eb;
|
||
font-weight: 600;
|
||
}
|
||
QMenu::item:disabled {
|
||
color: #94a3b8;
|
||
background-color: transparent;
|
||
}
|
||
QMenu::separator {
|
||
height: 1px;
|
||
background-color: #f1f5f9;
|
||
margin: 4px 6px;
|
||
}
|
||
""")
|
||
|
||
act_copy = menu.addAction(_("Copy"))
|
||
act_copy.setEnabled(current_log.textCursor().hasSelection())
|
||
act_copy.triggered.connect(current_log.copy)
|
||
|
||
act_select_all = menu.addAction(_("Select All"))
|
||
act_select_all.triggered.connect(current_log.selectAll)
|
||
|
||
menu.addSeparator()
|
||
|
||
act_clear = menu.addAction(_("Clear Log"))
|
||
act_clear.triggered.connect(self.log_concise.clear)
|
||
act_clear.triggered.connect(self.log_detailed.clear)
|
||
|
||
act_save = menu.addAction(_("Export Log..."))
|
||
act_save.triggered.connect(self.save_log_file)
|
||
|
||
menu.exec(current_log.mapToGlobal(pos))
|
||
|
||
def save_log_file(self):
|
||
current_log = self.log_stack.currentWidget()
|
||
content = current_log.toPlainText()
|
||
|
||
if not content.strip():
|
||
return self.show_notification(_("No log content."))
|
||
|
||
default_name = "build.log"
|
||
if self.script_path:
|
||
default_name = f"qpypack_{Path(self.script_path).stem}.log"
|
||
|
||
fp, _filter = QFileDialog.getSaveFileName(self, _("Export Log..."), default_name, "Log Files (*.log);;Text Files (*.txt);;All Files (*)", options=QFileDialog.Option.DontUseNativeDialog)
|
||
if fp:
|
||
try:
|
||
Path(fp).write_text(content, encoding='utf-8')
|
||
self.show_notification(_("Log saved to: {path}", path=fp))
|
||
except Exception as e:
|
||
self.show_error_log(_("[ERROR] Failed to export log file: {error}", error=str(e)))
|
||
|
||
def global_excepthook(exctype, value, tb):
|
||
err_text = "".join(traceback.format_exception(exctype, value, tb))
|
||
logger.critical(f"Unhandled exception: {err_text}")
|
||
print(err_text, file=sys.stderr)
|
||
try:
|
||
log_file = os.path.join(tempfile.gettempdir(), "qpypack_crash.log")
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(err_text + "\n")
|
||
|
||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||
if QApplication.instance():
|
||
QMessageBox.critical(
|
||
None,
|
||
"Fatal Error / 严重错误",
|
||
f"App crashed! Unhandled exception occurred.\nLog saved to: {log_file}\n\n{err_text[:500]}..."
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def main():
|
||
sys.excepthook = global_excepthook
|
||
try:
|
||
QApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
|
||
except Exception: pass
|
||
|
||
app = QApplication(sys.argv)
|
||
app.setStyle("Fusion")
|
||
|
||
palette = QPalette()
|
||
palette.setColor(QPalette.ColorRole.Window, QColor("#ffffff"))
|
||
palette.setColor(QPalette.ColorRole.WindowText, QColor("#111827"))
|
||
palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff"))
|
||
palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#f9fafb"))
|
||
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffff"))
|
||
palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#111827"))
|
||
palette.setColor(QPalette.ColorRole.Text, QColor("#111827"))
|
||
palette.setColor(QPalette.ColorRole.Button, QColor("#f1f5f9"))
|
||
palette.setColor(QPalette.ColorRole.ButtonText, QColor("#111827"))
|
||
palette.setColor(QPalette.ColorRole.Highlight, QColor("#2563eb"))
|
||
palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
|
||
app.setPalette(palette)
|
||
|
||
app.setStyleSheet("""
|
||
QMenu {
|
||
background-color: #ffffff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
padding: 5px;
|
||
}
|
||
QMenu::item {
|
||
background-color: transparent;
|
||
color: #1e293b;
|
||
padding: 6px 16px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
font-family: Consolas, "Segoe UI", sans-serif;
|
||
}
|
||
QMenu::item:selected {
|
||
background-color: #eff6ff;
|
||
color: #2563eb;
|
||
font-weight: 600;
|
||
}
|
||
QMenu::item:disabled {
|
||
color: #94a3b8;
|
||
background-color: transparent;
|
||
}
|
||
QMenu::separator {
|
||
height: 1px;
|
||
background-color: #f1f5f9;
|
||
margin: 4px 6px;
|
||
}
|
||
""")
|
||
|
||
I18N.update_qt_translator(I18N.current_lang)
|
||
|
||
font = QFont()
|
||
font.setFamilies([
|
||
"Segoe UI", "Microsoft YaHei", "PingFang SC",
|
||
"Hiragino Sans GB", "Noto Sans SC", "Helvetica Neue", "Arial", "sans-serif"
|
||
])
|
||
font.setPointSize(9)
|
||
app.setFont(font)
|
||
|
||
win = MainWindow()
|
||
win.show()
|
||
sys.exit(app.exec())
|
||
|
||
if __name__ == "__main__":
|
||
main()
|