4601 lines
242 KiB
Python
4601 lines
242 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 threading
|
||
import configparser
|
||
import locale
|
||
import ast
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from pathlib import Path
|
||
|
||
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)
|
||
from PySide6.QtCore import (Qt, QThread, Signal, QPropertyAnimation, QEasingCurve,
|
||
QParallelAnimationGroup, QFileInfo, QVariantAnimation,
|
||
QTimer, QPointF, QRectF, QRect, QSize, QUrl, QLocale, QObject)
|
||
from PySide6.QtGui import (QFont, QDragEnterEvent, QDropEvent, QIcon, QPixmap,
|
||
QPainter, QColor, QPen, QImage, QImageWriter, QDesktopServices)
|
||
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.2"
|
||
__author__ = "QwejayHuang"
|
||
__company__ = "QwejayHuang"
|
||
__description__ = "Modern Cross-Platform Python Packaging GUI Powered by PyInstaller & Nuitka"
|
||
|
||
_CONFIG_DIR = Path.home() / ".qpypack"
|
||
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||
CONFIG_FILE = (_CONFIG_DIR / "config.ini").as_posix()
|
||
|
||
ZH_CN_DICT = {
|
||
# 1. 源镜像名称 (PIP Mirrors)
|
||
"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)": "中科大镜像",
|
||
|
||
# 2. 软件元数据与标语 (Metadata & Taglines)
|
||
"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或点击浏览",
|
||
|
||
# 3. 导航与选项卡 (Tabs & Navigation)
|
||
"Build Settings": "构建设置",
|
||
"Preferences": "偏好设置",
|
||
"About": "关于",
|
||
"Engine": "构建引擎",
|
||
"Dependencies": "依赖管理",
|
||
"Resources": "附加资源",
|
||
"Optimization": "性能优化",
|
||
"Package Map": "包名映射",
|
||
"Execution Log": "执行日志",
|
||
|
||
# 4. 主界面状态与引导 (Main UI & Status Prompts)
|
||
"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": "状态: 工作区已重置",
|
||
" [Console]": " [控制台]",
|
||
" [No Console]": " [无控制台]",
|
||
|
||
# 5. 操作按钮与右键菜单 (Buttons & Context Menus)
|
||
"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...": "导出日志...",
|
||
|
||
# 6. 设置分组卡片与表单标签 (Card Titles & Form Labels)
|
||
"Engine & Environment": "引擎与环境",
|
||
"Execution Mode": "执行模式",
|
||
"Mirrors & Scanner": "镜像与扫描",
|
||
"Additional Resources (Drag & Drop Supported)": "附加资源 (支持拖拽)",
|
||
"Performance Optimization": "性能优化",
|
||
"Lock Core Dependencies": "锁定核心依赖",
|
||
"Package Name Mappings": "包名映射",
|
||
"UI Language:": "界面语言:",
|
||
"App Metadata & Presets": "元数据与预设",
|
||
"Output Location": "输出位置",
|
||
"Preferences & System Behavior": "偏好与行为",
|
||
"Build Engine:": "构建引擎:",
|
||
"Python Interpreter:": "Python 解释器:",
|
||
"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 版本:",
|
||
"Pipreqs Version:": "Pipreqs 版本:",
|
||
"Version:": "版本:",
|
||
"Author/Company:": "作者/公司:",
|
||
"Description:": "描述:",
|
||
"Output Location:": "保存位置:",
|
||
"Target Directory:": "自定义路径:",
|
||
"Temporary Directory:": "临时目录:",
|
||
"Source Directory (.qpypack_build)": "源码同级目录 (.qpypack_build)",
|
||
"System Temp Directory": "系统 Temp 目录",
|
||
"System Temp Directory (Anti-Lock / Cloud-Friendly)": "系统 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 包名:",
|
||
"Script and settings retained. Ready to rebuild.": "已保留当前脚本与配置,可直接重新构建。",
|
||
|
||
# 7. 复选框、占位符与悬停提示 (Checkboxes, Placeholders & Tooltips)
|
||
"One-File Mode (--onefile)": "单文件 (--onefile)",
|
||
"Hide Console (--noconsole)": "隐藏控制台 (--noconsole)",
|
||
"Use Virtual Environment (Recommended)": "使用虚拟环境 (推荐)",
|
||
"Install requirements.txt": "安装 requirements.txt",
|
||
"Analyze Dependencies (pipreqs)": "分析依赖 (pipreqs)",
|
||
"Scan Entire Folder": "扫描整个文件夹",
|
||
"Enable UPX Compression": "启用 UPX 压缩",
|
||
"Lite Mode (Exclude Dev/Test Dependencies)": "精简模式 (排除开发/测试依赖)",
|
||
"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": "留空从环境变量自动检测",
|
||
"Dynamically exclude redundant dependencies in build environment, improving speed and reducing size.": "动态排除构建环境的冗余依赖,提升速度并减小体积。",
|
||
"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.": "双击编辑目标路径;支持拖拽。建议使用“导出预设”进行保存。",
|
||
|
||
# 8. 引擎说明与平台兼容性矩阵 (Engine Help & Platform Matrices)
|
||
"PyInstaller — Bundles Python interpreter and bytecode. Fast build speed, zero configuration (no C compiler needed), and excellent compatibility.":
|
||
"PyInstaller — 打包 Python 解释器与字节码。构建快,零配置(无需 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 编译器)。",
|
||
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} Platform Matrix:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><span style="color:#16a34a; font-weight:bold;">✔ Windows 7</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 8 / 8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10 / 11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.9+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#f1f5f9; color:#475569; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">Legacy OS</span> <span style="color:#6b7280; font-size:11px;">Full backward compatibility</span>':
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} 平台支持:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">下载 Python</a></td></tr></table><span style="color:#16a34a; font-weight:bold;">✔ Windows 7</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 8/8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10/11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.9+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#f1f5f9; color:#475569; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">兼容旧系统</span> <span style="color:#6b7280; font-size:11px;">支持 Windows 7 及旧版操作系统</span>',
|
||
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} Platform Matrix:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><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;">✔ macOS 10.9+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span>':
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} 平台支持:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">下载 Python</a></td></tr></table><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;">✔ macOS 10.9+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span>',
|
||
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} Platform Matrix:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><span style="color:#dc2626; font-weight:bold;">✖ Windows 7</span> <span style="color:#dc2626; font-weight:bold;">✖ Windows 8 / 8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10 / 11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.13+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#dcfce7; color:#15803d; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">Nuitka</span> <span style="color:#16a34a; font-size:11px;">MinGW64 supported out-of-the-box</span>':
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} 平台支持:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">下载 Python</a></td></tr></table><span style="color:#dc2626; font-weight:bold;">✖ Windows 7</span> <span style="color:#dc2626; font-weight:bold;">✖ Windows 8/8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10/11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.13+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#dcfce7; color:#15803d; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">Nuitka</span> <span style="color:#16a34a; font-size:11px;">开箱支持 MinGW64 编译器</span>',
|
||
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} Platform Matrix:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><span style="color:#dc2626; font-weight:bold;">✖ Windows 7</span> <span style="color:#dc2626; font-weight:bold;">✖ Windows 8 / 8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10 / 11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.15+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#fef3c7; color:#b45309; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">Nuitka</span> <span style="color:#d97706; font-size:11px;">Requires MSVC / Clang / Zig (MinGW64 unsupported)</span>':
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} 平台支持:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">下载 Python</a></td></tr></table><span style="color:#dc2626; font-weight:bold;">✖ Windows 7</span> <span style="color:#dc2626; font-weight:bold;">✖ Windows 8/8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10/11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.15+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#fef3c7; color:#b45309; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">Nuitka</span> <span style="color:#d97706; font-size:11px;">需 MSVC / Clang / Zig (不支持 MinGW64)</span>',
|
||
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} Platform Matrix:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><span style="color:#dc2626; font-weight:bold;">✖ Windows 7</span> <span style="color:#dc2626; font-weight:bold;">✖ Windows 8 / 8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10 / 11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.13+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#dcfce7; color:#15803d; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">Nuitka</span> <span style="color:#16a34a; font-size:11px;">Auto-detecting and managing C backend compiler</span>':
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} 平台支持:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">下载 Python</a></td></tr></table><span style="color:#dc2626; font-weight:bold;">✖ Windows 7</span> <span style="color:#dc2626; font-weight:bold;">✖ Windows 8/8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10/11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.13+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#dcfce7; color:#15803d; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">Nuitka</span> <span style="color:#16a34a; font-size:11px;">自动检测并管理 C 后端编译器</span>',
|
||
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python Interpreter</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><span style="color:#6b7280;">Auto-detecting system environment for Windows, macOS & Linux...</span>':
|
||
'<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python 解释器</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">下载 Python</a></td></tr></table><span style="color:#6b7280;">自动检测系统环境 (Win/macOS/Linux)...</span>',
|
||
|
||
# 9. 构建日志与异常错误提示 (Log Output, Errors & Warnings)
|
||
"[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] Python interpreter path: {path}": "[INFO] Python 路径: {path}",
|
||
"[INFO] Creating virtual environment...": "[INFO] 创建虚拟环境...",
|
||
"[INFO] Synchronizing and upgrading pip package manager...": "[INFO] 更新 pip...",
|
||
"[INFO] Scanning project source code via pipreqs...": "[INFO] pipreqs 扫描依赖...",
|
||
"[INFO] Installing build engine [{pkg}] and core compilation dependencies...": "[INFO] 安装构建引擎 [{pkg}] 及核心依赖...",
|
||
"[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] Writing application icon...": "[INFO] 写入应用图标...",
|
||
"[INFO] Application icon written successfully.": "[INFO] 应用图标写入成功。",
|
||
"[INFO] Lite mode enabled, excluding non-essential modules...": "[INFO] 启用精简模式,排除非必要模块...",
|
||
"[INFO] Lite mode enabled, applying bytecode optimization (-OO) and stripping dev modules...": "[INFO] 启用精简模式,应用字节码优化 (-OO) 并剔除开发环境模块...",
|
||
"[INFO] Enabled Nuitka optimization directives...": "[INFO] 启用 Nuitka 优化指令...",
|
||
"[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] 清理临时构建环境...",
|
||
"[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] Parse pipreqs output warning: {error}": "[WARN] 解析 pipreqs 警告: {error}",
|
||
"[WARN] Command timeout (>{timeout}s)": "[WARN] 命令超时 (>{timeout}s)",
|
||
"[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] 构建中止: 磁盘空间不足。请清理至少 5 GB 空间后重试。",
|
||
"[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}",
|
||
"[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] Compilation interrupted with exceptions, please click 'Detailed Mode' above the log window for troubleshooting.": "[FAILED] 构建异常中断,请查看“详细日志”。",
|
||
"[ERROR] Target file is locked or encrypted by cloud drive. Please decrypt 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] 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。",
|
||
"[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}",
|
||
"Dependency Missing Warning: {pkgs} failed to install. Check log for details.": "依赖缺失警告: {pkgs} 安装失败,详情见日志。",
|
||
"Package mappings have been reset to defaults.": "包映射已重置为默认值。",
|
||
"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.": "注意:发生异常,请查看日志详情。",
|
||
"Are you sure you want to reset all preferences to default?": "确定要将全局偏好设置重置为默认值吗?",
|
||
"Global configuration has been reset.": "全局配置已重置为默认值。",
|
||
"\\nProgram execution completed, press Enter to exit...": "\\n程序执行完成,按回车键退出...",
|
||
"Project-specific. Not saved to global preferences. Use 'Export Preset' to save config.":
|
||
"提示:此处的资源仅对当前工作区生效(不保存到全局偏好)。如需长期复用请使用【导出预设】。",
|
||
"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",
|
||
"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
|
||
|
||
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:
|
||
print(f"[i18n] Failed to load {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": f"System Default ({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 set_language(self, lang_code: str):
|
||
target = self.normalize_locale(lang_code)
|
||
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'
|
||
}
|
||
|
||
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': '', 'nuitka_version': '', 'pipreqs_version': '',
|
||
'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:
|
||
pass
|
||
|
||
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',
|
||
'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': '',
|
||
'nuitka_version': '',
|
||
'pipreqs_version': '',
|
||
'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
|
||
|
||
lang_pref = config['Settings'].get('language', 'auto')
|
||
I18N.set_language(lang_pref)
|
||
return config
|
||
|
||
def save_config(config):
|
||
try:
|
||
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
|
||
config.write(f)
|
||
except: pass
|
||
|
||
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):
|
||
global _AUDIO_EFFECT_REF
|
||
try:
|
||
config = load_config()
|
||
if not config['Settings'].getboolean('sound_notify', True):
|
||
return
|
||
|
||
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_imports_via_ast(script_path, python_exe=None):
|
||
code = ""
|
||
try:
|
||
raw = Path(script_path).read_bytes()
|
||
try:
|
||
code = raw.decode('utf-8-sig')
|
||
except Exception:
|
||
code = raw.decode(locale.getpreferredencoding(), errors='ignore')
|
||
|
||
imports = set()
|
||
tree = ast.parse(code, filename=script_path)
|
||
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])
|
||
return imports
|
||
except Exception:
|
||
try:
|
||
m = re.findall(r'^\s*(?:from|import)\s+([a-zA-Z0-9_]+)', code, re.M)
|
||
return set(m)
|
||
except Exception:
|
||
return set()
|
||
|
||
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 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))
|
||
|
||
if min_view_width and combo.view():
|
||
combo.view().setMinimumWidth(min_view_width)
|
||
combo.view().setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
|
||
if combo.view():
|
||
combo.view().setStyleSheet("""
|
||
QListView {
|
||
background-color: #ffffff; color: #111827; border: 1px solid #d1d5db;
|
||
border-radius: 6px; outline: none; padding: 4px; font-size: 12px;
|
||
font-family: Consolas, "Segoe UI", sans-serif;
|
||
selection-background-color: #2563eb; selection-color: #ffffff;
|
||
}
|
||
QListView::item { background-color: #ffffff; color: #111827; padding: 4px 8px; border-radius: 4px; }
|
||
QListView::item:hover, QListView::item:selected { background-color: #2563eb; color: #ffffff; }
|
||
""")
|
||
|
||
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 = {'os', 'sys', 're', 'math', 'time', 'datetime', 'json', 'urllib', 'sqlite3', 'csv',
|
||
'subprocess', 'shutil', 'threading', 'multiprocessing', 'queue', 'socket',
|
||
'collections', 'itertools', 'functools', 'random', 'hashlib', 'base64',
|
||
'binascii', 'xml', 'logging', 'argparse', 'typing', 'pathlib', 'traceback',
|
||
'warnings', 'tempfile', 'platform', 'zipfile', 'tarfile', 'gzip', 'bz2',
|
||
'lzma', 'hmac', 'ssl', 'email', 'http', 'uuid', 'io', 'contextlib', 'winreg',
|
||
'concurrent', 'ctypes', 'dataclasses', 'enum', 'importlib', 'inspect',
|
||
'pickle', 'copy', 'ast', 'asyncio', 'calendar', 'configparser',
|
||
'curses', 'decimal', 'difflib', 'getopt', 'getpass', 'glob', 'html',
|
||
'mimetypes', 'numbers', 'operator', 'pdb', 'pprint', 'profile', 'pstats',
|
||
'runpy', 'sched', 'secrets', 'selectors', 'shelve', 'shlex',
|
||
'signal', 'site', 'smtpd', 'smtplib', 'sndhdr', 'socketserver',
|
||
'stat', 'statistics', 'string', 'struct', 'symtable', 'sysconfig',
|
||
'syslog', 'tabnanny', 'telnetlib', 'termios', 'future',
|
||
'textwrap', 'timeit', 'tkinter', 'token', 'tokenize', 'trace',
|
||
'tracemalloc', 'tty', 'turtle', 'turtledemo', 'types',
|
||
'unittest', 'uu', 'venv', 'wave', '__future__',
|
||
'weakref', 'webbrowser', 'winsound', 'wsgiref', 'xdrlib',
|
||
'xmlrpc', 'zipapp', 'zipimport', 'zlib', 'zoneinfo',
|
||
'tomllib', 'graphlib', 'ipaddress', 'builtins', 'gc', 'faulthandler',
|
||
'plistlib', 'select', 'unicodedata', 'ensurepip', 'rlcompleter', 'msvcrt'}
|
||
if sys.version_info >= (3, 10):
|
||
libs.update(sys.stdlib_module_names)
|
||
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 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", 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 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", "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 "python"
|
||
|
||
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 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 _ 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 Exception: 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(["sysctl", "-n", "hw.memsize"], text=True)
|
||
return int(out.strip()) / (1024 ** 3)
|
||
except Exception: pass
|
||
else:
|
||
try:
|
||
with open('/proc/meminfo', 'r') as f:
|
||
for line in f:
|
||
if line.startswith('MemAvailable:'):
|
||
kb = int(line.split()[1])
|
||
return kb / (1024 ** 2)
|
||
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
|
||
|
||
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)
|
||
|
||
elif self.burst_value > 0.0:
|
||
pop_scale = 1.0 + math.sin(self.burst_value * math.pi) * 0.15
|
||
draw_size = int(self.current_size * pop_scale)
|
||
if self.burst_value < 1.0:
|
||
alpha = int(255 * (1.0 - self.burst_value))
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(QColor(26, 115, 232, alpha))
|
||
burst_radius_1 = (self.current_size / 2) + 5 + self.burst_value * 25
|
||
dot_size_1 = 8 * (1.0 - self.burst_value)
|
||
for i in range(8):
|
||
angle = math.radians(i * 45)
|
||
dx = center_x + math.cos(angle) * burst_radius_1
|
||
dy = center.y() + math.sin(angle) * burst_radius_1
|
||
painter.drawEllipse(QPointF(dx, dy), dot_size_1, dot_size_1)
|
||
|
||
painter.setBrush(QColor(255, 193, 7, alpha))
|
||
burst_radius_2 = (self.current_size / 2) + self.burst_value * 35
|
||
dot_size_2 = 6 * (1.0 - self.burst_value)
|
||
for i in range(8):
|
||
angle = math.radians(i * 45 + 22.5)
|
||
dx = center_x + math.cos(angle) * burst_radius_2
|
||
dy = center.y() + math.sin(angle) * burst_radius_2
|
||
painter.drawEllipse(QPointF(dx, dy), dot_size_2, dot_size_2)
|
||
|
||
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()
|
||
|
||
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)
|
||
layout.addStretch(1)
|
||
|
||
self.retranslate_ui()
|
||
|
||
def retranslate_ui(self):
|
||
pass
|
||
|
||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||
if event.mimeData().hasUrls():
|
||
file_path = event.mimeData().urls()[0].toLocalFile().lower()
|
||
if file_path.endswith('.py') or file_path.endswith('.pyw'):
|
||
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 urls:
|
||
file_path = urls[0].toLocalFile()
|
||
if file_path.lower().endswith(('.py', '.pyw')):
|
||
self.fileDropped.emit(file_path)
|
||
|
||
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 (*)"
|
||
)
|
||
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; }")
|
||
|
||
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 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", "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 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; }
|
||
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()
|
||
|
||
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("background: transparent;")
|
||
|
||
content = QWidget()
|
||
content.setStyleSheet("background: transparent;")
|
||
lay = QVBoxLayout(content)
|
||
lay.setContentsMargins(0, 5, 0, 15)
|
||
lay.setSpacing(15)
|
||
|
||
scroll.setWidget(content)
|
||
return scroll, content, lay
|
||
|
||
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)
|
||
return m.group(0) if m else 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"))
|
||
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)
|
||
|
||
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)
|
||
|
||
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.lbl_eng_title = QLabel(_("Build Engine:"))
|
||
self.lbl_py_title = QLabel(_("Python 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_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"))
|
||
h_mode = QHBoxLayout()
|
||
self.onefile_check = QCheckBox(_("One-File Mode (--onefile)"))
|
||
self.noconsole_check = QCheckBox(_("Hide Console (--noconsole)"))
|
||
h_mode.addWidget(self.onefile_check)
|
||
h_mode.addWidget(self.noconsole_check)
|
||
h_mode.addStretch()
|
||
c_lay_mode.addLayout(h_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 (Recommended)"))
|
||
self.reqs_check = QCheckBox(_("Install requirements.txt"))
|
||
self.pipreqs_check = QCheckBox(_("Analyze Dependencies (pipreqs)"))
|
||
self.pipreqs_dir_check = QCheckBox(_("Scan Entire Folder"))
|
||
|
||
g_dep.addWidget(self.venv_check, 0, 0)
|
||
g_dep.addWidget(self.reqs_check, 0, 1)
|
||
g_dep.addWidget(self.pipreqs_check, 1, 0)
|
||
g_dep.addWidget(self.pipreqs_dir_check, 1, 1)
|
||
c_lay_deps.addLayout(g_dep)
|
||
|
||
lay_sub2.addWidget(self.card_deps)
|
||
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)
|
||
|
||
self.lite_mode_check = QCheckBox(_("Lite Mode (Exclude Dev/Test Dependencies)"))
|
||
self.lite_mode_check.setStyleSheet("color: #D93025; font-weight: bold;")
|
||
self.lite_mode_check.setToolTip(_("Dynamically exclude redundant dependencies in build environment, improving speed and reducing size."))
|
||
|
||
c_lay_opt.addLayout(self.form_opt)
|
||
c_lay_opt.addWidget(self.lite_mode_check)
|
||
|
||
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.pipreqs_ver_edit = QLineEdit()
|
||
|
||
self.lbl_pyi_ver = QLabel(_("PyInstaller Version:"))
|
||
self.lbl_nuitka_ver = QLabel(_("Nuitka Version:"))
|
||
self.lbl_pipreqs_ver = QLabel(_("Pipreqs 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)
|
||
self.form_ver.addRow(self.lbl_pipreqs_ver, self.pipreqs_ver_edit)
|
||
c_lay_ver.addLayout(self.form_ver)
|
||
|
||
lay_sub4.addWidget(self.card_opt)
|
||
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.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||
self.mapping_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||
self.mapping_table.setMinimumHeight(150)
|
||
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)
|
||
|
||
lay_sub5.addWidget(self.card_map)
|
||
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("My Studio")
|
||
self.ver_desc = QLineEdit("Python Executable")
|
||
|
||
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)
|
||
h_preset = QHBoxLayout()
|
||
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)
|
||
|
||
h_preset.addWidget(self.btn_exp_preset)
|
||
h_preset.addWidget(self.btn_imp_preset)
|
||
h_preset.addWidget(self.btn_reset_config)
|
||
h_preset.addStretch()
|
||
c_lay_meta.addLayout(h_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, 20, 40, 20)
|
||
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(110, 110, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||
logo_lbl.setPixmap(logo_pixmap)
|
||
else:
|
||
logo_lbl.setPixmap(get_svg_pixmap('package', color="#1A73E8", size=110))
|
||
else:
|
||
logo_lbl.setPixmap(get_svg_pixmap('package', color="#1A73E8", size=110))
|
||
|
||
logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
main_lay.addWidget(logo_lbl)
|
||
|
||
title_lbl = QLabel(__app_name__)
|
||
title_lbl.setStyleSheet("font-size: 36px; font-weight: 900; color: #202124; letter-spacing: -1px; margin-top: 10px;")
|
||
title_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
main_lay.addWidget(title_lbl)
|
||
|
||
ver_lbl = QLabel(f"Version {__version__} · GPL-3.0")
|
||
ver_lbl.setStyleSheet("font-size: 14px; font-weight: bold; color: #1A73E8; margin-bottom: 5px;")
|
||
ver_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
main_lay.addWidget(ver_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: #5f6368;")
|
||
self.about_desc_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
main_lay.addWidget(self.about_desc_lbl)
|
||
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.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"
|
||
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"
|
||
|
||
btn_github = create_link_btn(_("GitHub Repository"), "https://github.com/qwejay/QPyPack", p_github, "#24292e", "#f1f3f4", "#e8eaed", "#dadce0")
|
||
btn_issue = create_link_btn(_("Issues & Feedback"), "https://github.com/qwejay/QPyPack/issues", p_issue, "#d93025", "#f1f3f4", "#e8eaed", "#dadce0")
|
||
btn_pypi = create_link_btn(_("PyPI Home"), "https://pypi.org/project/qpypack/", p_pypi, "#1A73E8", "#f1f3f4", "#e8eaed", "#dadce0")
|
||
|
||
btn_sponsor = create_link_btn(_("Sponsor"), "https://www.ifdian.net/a/qwejay", p_heart, "#d93025", "#fce8e6", "#fad2cf", "#f6aea9")
|
||
btn_sponsor.setStyleSheet(btn_sponsor.styleSheet().replace("color: #3c4043;", "color: #d93025;"))
|
||
|
||
btn_lay.addWidget(btn_github)
|
||
btn_lay.addWidget(btn_issue)
|
||
btn_lay.addWidget(btn_pypi)
|
||
btn_lay.addWidget(btn_sponsor)
|
||
|
||
main_lay.addLayout(btn_lay)
|
||
main_lay.addSpacing(20)
|
||
|
||
self.sponsor_desc = QLabel()
|
||
self.sponsor_desc.setWordWrap(True)
|
||
self.sponsor_desc.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
text_p1 = _("QPyPack is a free and open-source tool. If it has improved your efficiency or solved packaging problems, consider buying the author a coffee!")
|
||
text_p2 = _("* 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.setText(
|
||
f"<div style='text-align: center; font-family: sans-serif;'>"
|
||
f" <p style='color: #5f6368; font-size: 13px; line-height: 1.8; margin-top: 0; margin-bottom: 12px;'>"
|
||
f" {text_p1}"
|
||
f" </p>"
|
||
f" <p style='color: #9aa0a6; font-size: 12px; line-height: 1.6; margin: 0;'>"
|
||
f" {text_p2}"
|
||
f" </p>"
|
||
f"</div>"
|
||
)
|
||
main_lay.addWidget(self.sponsor_desc)
|
||
|
||
main_lay.addStretch(1)
|
||
|
||
rights_lbl = QLabel(f"Copyright © {__company__}.")
|
||
rights_lbl.setStyleSheet("font-size: 12px; color: #bdc1c6; font-weight: bold;")
|
||
rights_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
main_lay.addWidget(rights_lbl)
|
||
|
||
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"))
|
||
self.sub_tabs.setTabText(4, _("Package Map"))
|
||
|
||
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"))
|
||
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_py_title.setText(_("Python 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_pipreqs_ver.setText(_("Pipreqs 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 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_icon.setText(_("Browse"))
|
||
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.onefile_check.setText(_("One-File Mode (--onefile)"))
|
||
self.noconsole_check.setText(_("Hide Console (--noconsole)"))
|
||
self.venv_check.setText(_("Use Virtual Environment (Recommended)"))
|
||
self.reqs_check.setText(_("Install requirements.txt"))
|
||
self.pipreqs_check.setText(_("Analyze Dependencies (pipreqs)"))
|
||
self.pipreqs_dir_check.setText(_("Scan Entire Folder"))
|
||
self.upx_check.setText(_("Enable UPX Compression"))
|
||
self.lite_mode_check.setText(_("Lite Mode (Exclude Dev/Test Dependencies)"))
|
||
|
||
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.about_desc_lbl.setText(_("Modern Cross-Platform Python Packaging GUI Powered by PyInstaller & Nuitka"))
|
||
|
||
text_p1 = _("QPyPack is a free and open-source tool. If it has improved your efficiency or solved packaging problems, consider buying the author a coffee!")
|
||
text_p2 = _("* 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.setText(
|
||
f"<div style='text-align: center; font-family: sans-serif;'>"
|
||
f" <p style='color: #5f6368; font-size: 13px; line-height: 1.8; margin-top: 0; margin-bottom: 12px;'>{text_p1}</p>"
|
||
f" <p style='color: #9aa0a6; font-size: 12px; line-height: 1.6; margin: 0;'>{text_p2}</p>"
|
||
f"</div>"
|
||
)
|
||
|
||
|
||
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.lite_mode_check.setToolTip(_("Dynamically exclude redundant dependencies in build environment, improving speed and reducing size."))
|
||
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):
|
||
imp_name, ok1 = QInputDialog.getText(self, _("Add Mapping"), _("Import name (e.g. cv2):"))
|
||
if not ok1 or not imp_name.strip(): return
|
||
pypi_name, ok2 = QInputDialog.getText(self, _("Add Mapping"), _("PyPI package name for [{imp_name}]:", imp_name=imp_name.strip()))
|
||
if not ok2 or not pypi_name.strip(): return
|
||
|
||
row = self.mapping_table.rowCount()
|
||
self.mapping_table.insertRow(row)
|
||
self.mapping_table.setItem(row, 0, QTableWidgetItem(imp_name.strip()))
|
||
self.mapping_table.setItem(row, 1, QTableWidgetItem(pypi_name.strip()))
|
||
|
||
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 export_preset(self):
|
||
fp, _filter = QFileDialog.getSaveFileName(self, _("Export Preset..."), "project_config.qpypack", "QPyPack Presets (*.qpypack *.json)")
|
||
if fp:
|
||
try:
|
||
data = {
|
||
"engine": self.engine_combo.currentText(),
|
||
"onefile": self.onefile_check.isChecked(),
|
||
"noconsole": self.noconsole_check.isChecked(),
|
||
"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(),
|
||
"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),
|
||
"lite_mode": self.lite_mode_check.isChecked(),
|
||
"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 (*)")
|
||
if fp:
|
||
try:
|
||
data = json.loads(Path(fp).read_text(encoding='utf-8'))
|
||
if "engine" in data: self.engine_combo.setCurrentText(data["engine"])
|
||
if "onefile" in data: self.onefile_check.setChecked(bool(data["onefile"]))
|
||
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"]))
|
||
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 "lite_mode" in data: self.lite_mode_check.setChecked(bool(data["lite_mode"]))
|
||
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'))
|
||
self.onefile_check.setChecked(s.getboolean('onefile', True))
|
||
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.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.lite_mode_check.setChecked(s.getboolean('lite_mode', False))
|
||
|
||
self.pyi_ver_edit.setText(s.get('pyi_version', ''))
|
||
self.nuitka_ver_edit.setText(s.get('nuitka_version', ''))
|
||
self.pipreqs_ver_edit.setText(s.get('pipreqs_version', ''))
|
||
|
||
self.add_data_list.clear()
|
||
|
||
if 'Mappings' in config:
|
||
self.populate_mapping_table(dict(config['Mappings']))
|
||
|
||
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['onefile'] = str(self.onefile_check.isChecked())
|
||
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['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['lite_mode'] = str(self.lite_mode_check.isChecked())
|
||
|
||
s['pyi_version'] = self.pyi_ver_edit.text().strip()
|
||
s['nuitka_version'] = self.nuitka_ver_edit.text().strip()
|
||
s['pipreqs_version'] = self.pipreqs_ver_edit.text().strip()
|
||
|
||
res_list = []
|
||
s['add_data_list'] = ''
|
||
|
||
config['Mappings'] = {}
|
||
for r in range(self.mapping_table.rowCount()):
|
||
k = self.mapping_table.item(r, 0).text().strip()
|
||
v = self.mapping_table.item(r, 1).text().strip()
|
||
if k and v: config['Mappings'][k] = v
|
||
|
||
save_config(config)
|
||
I18N.set_language(new_lang)
|
||
|
||
def _check_pip_mirrors(self):
|
||
sender = self.sender()
|
||
src_text = self.pip_source_combo.currentText()
|
||
bak_text = self.pip_backup_combo.currentText()
|
||
|
||
if src_text == bak_text and src_text.strip() != "":
|
||
if sender == self.pip_source_combo:
|
||
for i in range(self.pip_backup_combo.count()):
|
||
if self.pip_backup_combo.itemText(i) != src_text:
|
||
self.pip_backup_combo.blockSignals(True)
|
||
self.pip_backup_combo.setCurrentIndex(i)
|
||
self.pip_backup_combo.blockSignals(False)
|
||
break
|
||
elif sender == self.pip_backup_combo:
|
||
for i in range(self.pip_source_combo.count()):
|
||
if self.pip_source_combo.itemText(i) != bak_text:
|
||
self.pip_source_combo.blockSignals(True)
|
||
self.pip_source_combo.setCurrentIndex(i)
|
||
self.pip_source_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: 8px 12px; font-size: 12px; line-height: 1.5; }")
|
||
|
||
if ver_str:
|
||
try:
|
||
parts = [int(x) for x in ver_str.split('.')]
|
||
major, minor = parts[0], parts[1]
|
||
|
||
if (major, minor) <= (3, 8):
|
||
self.python_desc_lbl.setText(_('<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} Platform Matrix:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><span style="color:#16a34a; font-weight:bold;">✔ Windows 7</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 8 / 8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10 / 11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.9+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#f1f5f9; color:#475569; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">Legacy OS</span> <span style="color:#6b7280; font-size:11px;">Full backward compatibility</span>', ver=ver_str))
|
||
elif (major, minor) <= (3, 10):
|
||
self.python_desc_lbl.setText(_('<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} Platform Matrix:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><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;">✔ macOS 10.9+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span>', ver=ver_str))
|
||
else:
|
||
self.python_desc_lbl.setText(_('<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} Platform Matrix:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><span style="color:#dc2626; font-weight:bold;">✖ Windows 7</span> <span style="color:#dc2626; font-weight:bold;">✖ Windows 8 / 8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10 / 11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.13+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#dcfce7; color:#15803d; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">Nuitka</span> <span style="color:#16a34a; font-size:11px;">Auto-detecting and managing C backend compiler</span>', ver=ver_str))
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
self.python_desc_lbl.setText(_('<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python Interpreter</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><span style="color:#6b7280;">Auto-detecting system environment for Windows, macOS & Linux...</span>'))
|
||
|
||
if ver_str:
|
||
try:
|
||
parts = [int(x) for x in ver_str.split('.')]
|
||
major, minor = parts[0], parts[1]
|
||
|
||
if (major, minor) <= (3, 8):
|
||
self.python_desc_lbl.setText(_('<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} Platform Matrix:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><span style="color:#16a34a; font-weight:bold;">✔ Windows 7</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 8 / 8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10 / 11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.9+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#f1f5f9; color:#475569; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">Legacy OS</span> <span style="color:#6b7280; font-size:11px;">Full backward compatibility</span>', ver=ver_str))
|
||
elif (major, minor) <= (3, 10):
|
||
self.python_desc_lbl.setText(_('<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} Platform Matrix:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><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;">✔ macOS 10.9+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span>', ver=ver_str))
|
||
else:
|
||
self.python_desc_lbl.setText(_('<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python {ver} Platform Matrix:</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><span style="color:#dc2626; font-weight:bold;">✖ Windows 7</span> <span style="color:#dc2626; font-weight:bold;">✖ Windows 8 / 8.1</span> <span style="color:#16a34a; font-weight:bold;">✔ Windows 10 / 11</span> <span style="color:#16a34a; font-weight:bold;">✔ macOS 10.13+</span> <span style="color:#16a34a; font-weight:bold;">✔ Linux</span><br><span style="background-color:#dcfce7; color:#15803d; padding:1px 5px; border-radius:3px; font-weight:bold; font-size:10px;">Nuitka</span> <span style="color:#16a34a; font-size:11px;">Auto-detecting and managing C backend compiler</span>', ver=ver_str))
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
self.python_desc_lbl.setText(_('<table border="0" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom: 5px;"><tr><td><b>Python Interpreter</b></td><td align="right"><a href="https://www.python.org/downloads/" style="color:#64748b; text-decoration:none;">Download Python</a></td></tr></table><span style="color:#6b7280;">Auto-detecting system environment for Windows, macOS & Linux...</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:"))
|
||
if d: self.out_dir_edit.setText(Path(d).resolve().as_posix())
|
||
|
||
def select_upx_path(self):
|
||
d = QFileDialog.getExistingDirectory(self, _("UPX Path:"))
|
||
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 (*)")
|
||
if f: self.reqs_file_edit.setText(Path(f).resolve().as_posix())
|
||
|
||
def select_python_path(self):
|
||
exe_filter = "Executable (*.exe);;All Files (*)" if os.name == 'nt' else "All Files (*)"
|
||
f, _filter = QFileDialog.getOpenFileName(self, _("Python Interpreter:"), "", exe_filter)
|
||
if f: self.python_path_combo.setCurrentText(Path(f).resolve().as_posix())
|
||
|
||
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)")
|
||
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:
|
||
python_exe = get_python_executable()
|
||
hidden = extract_imports_via_ast(script_path, python_exe)
|
||
hidden = [m for m in hidden if m not in STD_LIBS]
|
||
self.hidden_edit.setText(','.join(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 (*)")
|
||
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"))
|
||
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 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)
|
||
new_dst, ok = QInputDialog.getText(self, _("Edit Path"), _("Target relative path:"), text=dst)
|
||
if ok and new_dst:
|
||
new_dst = new_dst.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):
|
||
reply = QMessageBox.question(self, _("Reset to Default Config"),
|
||
_("Are you sure you want to reset all preferences to default?"),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
if os.path.exists(CONFIG_FILE):
|
||
try:
|
||
os.remove(CONFIG_FILE)
|
||
except: 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, python_exe=None):
|
||
super().__init__()
|
||
self.path = path
|
||
self.python_exe = python_exe or get_python_executable()
|
||
|
||
def run(self):
|
||
if self.isInterruptionRequested(): return
|
||
|
||
app_name = Path(self.path).stem
|
||
version = ""
|
||
author = "My Studio"
|
||
desc = "Python Executable"
|
||
script_imports = set()
|
||
|
||
try:
|
||
with open(self.path, 'r', encoding='utf-8', errors='ignore') as f:
|
||
content = f.read(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_imports_via_ast(self.path, self.python_exe)
|
||
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 self.process:
|
||
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:
|
||
self.process.kill()
|
||
except: pass
|
||
|
||
def run_cmd(self, cmd, cwd=None, timeout=None, silent_error=False):
|
||
if self._is_cancelled: return False
|
||
|
||
timer = None
|
||
is_timeout = [False]
|
||
|
||
clean_env = os.environ.copy()
|
||
clean_env.pop("PYTHONHOME", None)
|
||
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"
|
||
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
|
||
|
||
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:
|
||
self.process.kill()
|
||
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:
|
||
self.process.terminate()
|
||
return False
|
||
|
||
stripped = line.rstrip('\r\n')
|
||
self.all_raw_logs.append(stripped)
|
||
|
||
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: 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:
|
||
self.progress.emit(_("[ERROR] Process error: command or binary missing ({error})", error=str(e)))
|
||
return False
|
||
except Exception as e:
|
||
self.progress.emit(_("[ERROR] System execution exception: {error}", error=str(e)))
|
||
return False
|
||
finally:
|
||
if timer: timer.cancel()
|
||
if self.process and self.process.stdout:
|
||
try:
|
||
self.process.stdout.close()
|
||
except Exception:
|
||
pass
|
||
|
||
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 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"]
|
||
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:
|
||
raw = orig_path.read_bytes()
|
||
try: code = raw.decode('utf-8-sig')
|
||
except: code = raw.decode(locale.getpreferredencoding(), errors='ignore')
|
||
|
||
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')
|
||
|
||
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 run(self):
|
||
os.environ["NUITKA_ACCEPT_DOWNLOADS"] = "yes"
|
||
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 = []
|
||
|
||
try:
|
||
self.progress.emit(_("[INFO] Analyzing source code and project dependencies..."))
|
||
script_path = Path(self.params['script_path']).resolve()
|
||
script_dir = script_path.parent
|
||
|
||
try:
|
||
compile(script_path.read_bytes(), script_path.name, 'exec')
|
||
except SyntaxError as e:
|
||
err_type = type(e).__name__
|
||
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=script_path.name, type=err_type, line=e.lineno or _("Unknown"), desc=e.msg or "Invalid syntax")
|
||
return self.build_finished.emit(False, msg, [])
|
||
|
||
self.progress.emit(_("[INFO] Performing pre-flight environment checks..."))
|
||
|
||
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), [])
|
||
|
||
free_disk = get_free_disk_gb(script_dir.as_posix())
|
||
if free_disk < 0.5:
|
||
return self.build_finished.emit(False, _("[ERROR] Insufficient disk space (Available: {free:.1f} GB). At least 0.5 GB is required to safely initialize the build environment.", free=free_disk), [])
|
||
|
||
system_python_exe = get_python_executable()
|
||
if not system_python_exe or not shutil.which(system_python_exe):
|
||
return self.build_finished.emit(False, _("[ERROR] Python interpreter is invalid or not found: {path}", path=system_python_exe), [])
|
||
|
||
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()
|
||
|
||
system_python_exe = get_python_executable()
|
||
|
||
script_imports = set()
|
||
try:
|
||
script_imports = extract_imports_via_ast(script_posix, system_python_exe)
|
||
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()
|
||
|
||
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() != 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")
|
||
raw_req = req_file.read_bytes()
|
||
try: req_content = raw_req.decode('utf-8-sig')
|
||
except: req_content = raw_req.decode(locale.getpreferredencoding(), errors='ignore')
|
||
|
||
for line in req_content.splitlines():
|
||
canon_name, full_spec = parse_req_line(line)
|
||
if canon_name:
|
||
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 in STD_LIBS or m.lower() in local_modules: continue
|
||
canon_name = get_canonical_pypi_name(m)
|
||
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)
|
||
|
||
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']:
|
||
self.progress.emit(_("[INFO] Creating virtual environment..."))
|
||
self.venv_dir = Path(tempfile.mkdtemp(prefix="qpypack_env_")).resolve()
|
||
if not self.run_cmd([system_python_exe, "-m", "venv", self.venv_dir.as_posix()]):
|
||
return self.build_finished.emit(False, _("[ERROR] Failed to create virtual environment. Current Python environment might be missing necessary modules or have restricted permissions."), [])
|
||
python_exe = (self.venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).as_posix()
|
||
|
||
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.params.get('use_pipreqs'):
|
||
self.progress.emit(_("[INFO] Scanning project source code via pipreqs..."))
|
||
sandbox_dir = Path(tempfile.mkdtemp(prefix="qpypack_sandbox_")).resolve() if not self.params.get('use_pipreqs_dir', False) else script_dir
|
||
if not self.params.get('use_pipreqs_dir', False):
|
||
shutil.copy2(build_script_path, sandbox_dir / build_script_path.name)
|
||
|
||
pipreqs_pkg = f"pipreqs=={self.params['pipreqs_version']}" if self.params.get('pipreqs_version') else "pipreqs"
|
||
self.run_pip_install(python_exe, [pipreqs_pkg, "-q"])
|
||
temp_pipreqs = Path(tempfile.gettempdir()) / f"qpypack_pipreqs_{int(time.time())}.txt"
|
||
|
||
pypi_server = re.sub(r'/simple/?$', '/pypi', pip_idx, flags=re.I).rstrip('/') if pip_idx else None
|
||
pipreqs_cmd = [
|
||
python_exe, "-m", "pipreqs.pipreqs", sandbox_dir.as_posix(),
|
||
"--encoding", "utf-8", "--force", "--savepath", temp_pipreqs.as_posix()
|
||
]
|
||
if pypi_server: pipreqs_cmd.extend(["--pypi-server", pypi_server])
|
||
|
||
success_pipreqs = self.run_cmd(pipreqs_cmd, timeout=120, silent_error=True)
|
||
|
||
if success_pipreqs and temp_pipreqs.exists():
|
||
try:
|
||
pipreqs_lines = temp_pipreqs.read_text(encoding='utf-8', errors='ignore').splitlines()
|
||
for line in pipreqs_lines:
|
||
canon_name, unused_spec = parse_req_line(line)
|
||
if canon_name:
|
||
auto_detected_pkgs.add(canon_name)
|
||
if canon_name not in final_dependencies:
|
||
final_dependencies[canon_name] = unused_spec
|
||
auto_added_supplements.add(canon_name)
|
||
except Exception as e:
|
||
self.progress.emit(_("[WARN] Parse pipreqs output warning: {error}", error=str(e)))
|
||
temp_pipreqs.unlink(missing_ok=True)
|
||
|
||
if not self.params.get('use_pipreqs_dir', False) and sandbox_dir.exists():
|
||
robust_rmtree(sandbox_dir)
|
||
|
||
engine_pkg = "nuitka" if engine == "Nuitka" else "pyinstaller"
|
||
if engine == "Nuitka" and self.params.get('nuitka_version'):
|
||
engine_pkg = f"nuitka=={self.params['nuitka_version']}"
|
||
elif engine == "PyInstaller" and self.params.get('pyi_version'):
|
||
engine_pkg = f"pyinstaller=={self.params['pyi_version']}"
|
||
|
||
engine_pkgs = [engine_pkg]
|
||
if engine == "PyInstaller":
|
||
engine_pkgs.append("pillow")
|
||
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 = list(final_install_dict.values())
|
||
|
||
temp_unified_reqs = Path(tempfile.gettempdir()) / 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 not self.run_pip_install(python_exe, ["-q", "-r", temp_unified_reqs.as_posix()]):
|
||
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]
|
||
temp_flex_reqs = Path(tempfile.gettempdir()) / f"qpypack_flex_reqs_{int(time.time())}.txt"
|
||
temp_flex_reqs.write_text('\n'.join(flex_install_list), encoding='utf-8')
|
||
|
||
if not self.run_pip_install(python_exe, ["-q", "-r", temp_flex_reqs.as_posix()]):
|
||
self.progress.emit(_("[WARN] Some dependencies failed to install, build will proceed with risk..."))
|
||
failed_packages.extend(flex_install_list)
|
||
else:
|
||
self.progress.emit(_("[INFO] Successfully installed compatible versions."))
|
||
|
||
temp_flex_reqs.unlink(missing_ok=True)
|
||
|
||
|
||
|
||
temp_unified_reqs.unlink(missing_ok=True)
|
||
|
||
if self._is_cancelled: return self.build_finished.emit(False, "[INFO] Build Cancelled.", failed_packages)
|
||
|
||
self.progress.emit(_("[INFO] Starting {engine} engine to compile binary files...", engine=engine))
|
||
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_")).resolve()
|
||
self.temp_dist_dir = Path(tempfile.mkdtemp(prefix="qpypack_dist_")).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")
|
||
cmd.append("--contents-directory=_internal")
|
||
|
||
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":
|
||
comp = self.params.get('ver_comp', 'mycompany').strip().lower().replace(" ", "")
|
||
bundle_id = f"com.{comp or 'anonymous'}.{app_name.lower().replace(' ', '')}"
|
||
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=python*.dll", "--upx-exclude=vcruntime140.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:
|
||
cmd.extend(["--collect-all", "playwright"])
|
||
if 'moviepy' in all_imports_lower:
|
||
cmd.extend(["--collect-data", "moviepy"])
|
||
|
||
elif engine == "Nuitka":
|
||
sandbox_mode = int(self.params.get('temp_sandbox_mode', 0) or 0)
|
||
if sandbox_mode == 1:
|
||
self.temp_out_dir = Path(tempfile.mkdtemp(prefix="qpypack_nuitka_")).resolve()
|
||
else:
|
||
self.temp_out_dir = (script_dir / ".qpypack_build").resolve()
|
||
self.temp_out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
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":
|
||
has_msvc = shutil.which('cl.exe') is not None
|
||
if not has_msvc:
|
||
vswhere = Path(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")) / "Microsoft Visual Studio/Installer/vswhere.exe"
|
||
if vswhere.exists():
|
||
try:
|
||
res = subprocess.run([vswhere.as_posix(), "-latest", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64"], capture_output=True, text=True, creationflags=subprocess.CREATE_NO_WINDOW)
|
||
if res.stdout.strip(): has_msvc = True
|
||
except: pass
|
||
|
||
has_clang = (shutil.which('clang.exe') is not None) or (shutil.which('clang-cl.exe') is not None)
|
||
if not has_clang:
|
||
llvm_paths = [
|
||
Path(os.environ.get("ProgramFiles", "C:\\Program Files")) / "LLVM/bin/clang.exe",
|
||
Path(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")) / "LLVM/bin/clang.exe"
|
||
]
|
||
if any(p.exists() for p in llvm_paths):
|
||
has_clang = True
|
||
|
||
has_gcc = (shutil.which('gcc.exe') is not None) or (shutil.which('g++.exe') is not None)
|
||
|
||
py_ver_num = (3, 12)
|
||
try:
|
||
kw = {"capture_output": True, "text": True, "timeout": 2}
|
||
if os.name == 'nt': kw["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
check_ver = subprocess.run([python_exe, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"], **kw)
|
||
ver_str = check_ver.stdout.strip()
|
||
if ver_str:
|
||
parts = ver_str.split('.')
|
||
py_ver_num = (int(parts[0]), int(parts[1]))
|
||
except: pass
|
||
|
||
if has_msvc:
|
||
cmd.append("--msvc=latest")
|
||
self.progress.emit(_("[INFO] Found local MSVC environment, prioritizing native C++ compiler."))
|
||
elif has_clang:
|
||
cmd.append("--clang")
|
||
self.progress.emit(_("[INFO] Found local Clang environment, prioritizing LLVM Clang compiler."))
|
||
elif py_ver_num >= (3, 13):
|
||
cmd.append("--zig")
|
||
self.progress.emit(_("[INFO] Python 3.13+ detected: Using Zig compiler (--zig) as C backend."))
|
||
elif has_gcc:
|
||
cmd.append("--mingw64")
|
||
self.progress.emit(_("[INFO] Found local GCC/MinGW-w64 environment, using MinGW64 compiler."))
|
||
else:
|
||
cmd.append("--mingw64")
|
||
self.progress.emit(_("[INFO] No local C compiler detected. Nuitka will automatically download and manage a compatible MinGW-w64 toolchain."))
|
||
|
||
free_disk = get_free_disk_gb(script_dir.as_posix())
|
||
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)
|
||
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")
|
||
|
||
is_mac_bundle = (sys.platform == "darwin" and self.params['noconsole'])
|
||
if self.params['onefile'] and not is_mac_bundle:
|
||
cmd.append("--onefile")
|
||
elif not is_mac_bundle:
|
||
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 'ttkbootstrap' in imports_lower:
|
||
cmd.append("--include-package=ttkbootstrap")
|
||
cmd.append("--include-package-data=ttkbootstrap")
|
||
|
||
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")
|
||
cmd.append("--lto=yes")
|
||
|
||
cmd.append(script_posix)
|
||
|
||
success = self.run_cmd(cmd, cwd=script_dir.as_posix())
|
||
|
||
if not success:
|
||
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]
|
||
self.all_raw_logs.clear()
|
||
success = self.run_cmd(clean_cmd, cwd=script_dir.as_posix())
|
||
|
||
if not success and icon_path:
|
||
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."), failed_packages)
|
||
|
||
self.progress.emit(_("[INFO] Compilation completed, archiving built files..."))
|
||
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():
|
||
try:
|
||
if final_out.exists():
|
||
if final_out.is_dir(): shutil.rmtree(final_out, onerror=remove_readonly)
|
||
else: final_out.unlink(missing_ok=True)
|
||
shutil.move(src_out.as_posix(), final_out.as_posix())
|
||
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)))
|
||
else:
|
||
success = False
|
||
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():
|
||
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
|
||
self.build_finished.emit(True, _("[SUCCESS] Compilation completed, output path: {path}", path=final_out.resolve().as_posix()), failed_packages)
|
||
else:
|
||
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] Compilation interrupted with exceptions, please click 'Detailed Mode' above the log window for troubleshooting.")
|
||
self.build_finished.emit(False, msg, failed_packages)
|
||
|
||
except Exception as e:
|
||
self.build_finished.emit(False, f"[ERROR] {str(e)}", failed_packages)
|
||
finally:
|
||
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(): robust_rmtree(pycache_dir)
|
||
except: pass
|
||
|
||
if self.params.get('version_file'):
|
||
try:
|
||
p = Path(self.params['version_file'])
|
||
if p.exists(): p.unlink()
|
||
except: pass
|
||
|
||
if self.params.get('temp_icon_file'):
|
||
try:
|
||
p = Path(self.params['temp_icon_file'])
|
||
if p.exists(): p.unlink()
|
||
except: pass
|
||
|
||
if self.params['clean_all']:
|
||
self.progress.emit(_("[INFO] Freeing up space, cleaning temporary build environment..."))
|
||
for p in [self.venv_dir, self.temp_workpath, self.temp_out_dir, self.temp_dist_dir]:
|
||
if p and p.exists(): robust_rmtree(p)
|
||
|
||
app_name = self.params.get('app_name', 'app')
|
||
for p in ["__pycache__", f"{app_name}.build", f"{app_name}.onefile-build"]:
|
||
robust_rmtree(script_dir / p)
|
||
|
||
spec_file = script_dir / f"{app_name}.spec"
|
||
if spec_file.exists():
|
||
try: spec_file.unlink()
|
||
except: pass
|
||
|
||
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)
|
||
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)
|
||
|
||
self.copyright_label = QLabel(f"Copyright © {__company__}. ")
|
||
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 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."))
|
||
|
||
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"))
|
||
elif self.current_state == "failed":
|
||
self.set_status(_("Status: Build Failed"))
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
self.adjust_status_bar()
|
||
|
||
def closeEvent(self, event):
|
||
if self.thread and self.thread.isRunning():
|
||
self.thread.cancel()
|
||
self.thread.wait()
|
||
|
||
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 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.script_path = path
|
||
self.drop_area.set_loading(Path(path).name)
|
||
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, get_python_executable())
|
||
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)
|
||
self.settings_panel.ver_desc.setText(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)
|
||
|
||
default_output_name = f"{app_name}_{version}" if version else app_name
|
||
self.settings_panel.name_edit.setText(default_output_name)
|
||
|
||
script_dir = Path(path).parent
|
||
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]
|
||
|
||
auto_icon = None
|
||
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)
|
||
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.btn_main.setEnabled(True)
|
||
self.update_ui_state("ready")
|
||
|
||
def cancel_pack(self):
|
||
if self.thread and self.thread.isRunning():
|
||
self.thread.cancel()
|
||
self.set_status(_("Status: Workspace Reset"))
|
||
self.drop_area.stop_build_anim()
|
||
self.update_ui_state("ready")
|
||
|
||
def start_pack(self):
|
||
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 not raw_py or not shutil.which(raw_py):
|
||
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 (remember to check <b>'Add Python.exe to PATH'</b> during installation)."))
|
||
|
||
btn_down = msg.addButton(_("Download Python"), QMessageBox.ButtonRole.ActionRole)
|
||
btn_cancel = msg.addButton(_("Cancel"), QMessageBox.ButtonRole.RejectRole)
|
||
|
||
msg.exec()
|
||
if msg.clickedButton() == btn_down:
|
||
__import__('webbrowser').open("https://www.python.org/downloads/")
|
||
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("'", "\\'")
|
||
desc_escaped = sp.ver_desc.text().replace("'", "\\'")
|
||
v_str_escaped = v_str.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('OriginalFilename','{app_name}.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:
|
||
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()):
|
||
k = sp.mapping_table.item(r, 0).text().strip()
|
||
v = sp.mapping_table.item(r, 1).text().strip()
|
||
if k and v: mappings[k] = v
|
||
|
||
params = {
|
||
'engine': engine,
|
||
'mappings': mappings,
|
||
'script_path': self.script_path,
|
||
'app_name': app_name,
|
||
'onefile': sp.onefile_check.isChecked(),
|
||
'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(),
|
||
'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.lite_mode_check.isChecked(),
|
||
'pyi_version': sp.pyi_ver_edit.text().strip(),
|
||
'nuitka_version': sp.nuitka_ver_edit.text().strip(),
|
||
'pipreqs_version': sp.pipreqs_ver_edit.text().strip()
|
||
}
|
||
|
||
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):
|
||
self.append_log("\n" + msg)
|
||
self.drop_area.stop_build_anim()
|
||
play_alert(success)
|
||
|
||
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:
|
||
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("My Studio")
|
||
self.settings_panel.ver_desc.setText("Python Executable")
|
||
self.log_concise.clear()
|
||
self.log_detailed.clear()
|
||
|
||
if self.log_container.isVisible(): self.toggle_log()
|
||
self.drop_area.reset()
|
||
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):
|
||
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]",
|
||
"•", "---", "━", "!"
|
||
)
|
||
|
||
if any(line_strip.startswith(p) for p in valid_prefixes):
|
||
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; color: #111827; border: 1px solid #d1d5db; border-radius: 8px; padding: 4px; }
|
||
QMenu::item { padding: 6px 20px; border-radius: 4px; font-size: 12px; font-family: Consolas, "Segoe UI", sans-serif; }
|
||
QMenu::item:selected { background-color: #eff6ff; color: #2563eb; font-weight: 600; }
|
||
QMenu::separator { height: 1px; background-color: #e5e7eb; margin: 4px 2px; }
|
||
""")
|
||
|
||
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 (*)")
|
||
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 main():
|
||
try:
|
||
QApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
|
||
except Exception: pass
|
||
|
||
app = QApplication(sys.argv)
|
||
app.setStyle("Fusion")
|
||
|
||
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() |