3086 lines
137 KiB
Python
3086 lines
137 KiB
Python
import sys
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import re
|
|
import time
|
|
import stat
|
|
import json
|
|
import locale
|
|
import math
|
|
import threading
|
|
import configparser
|
|
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"
|
|
os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1"
|
|
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
|
|
|
|
from PySide6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget,
|
|
QPushButton, QLabel, QLineEdit, QFileDialog, QCheckBox,
|
|
QComboBox, QFrame, QStackedLayout, QFormLayout, QTextEdit,
|
|
QGraphicsOpacityEffect, QGroupBox, QGridLayout, QTabWidget,
|
|
QMessageBox, QInputDialog, QFileIconProvider, QSizePolicy, QScrollArea,
|
|
QGraphicsDropShadowEffect, QSpinBox, QListWidget, QListWidgetItem,
|
|
QListView)
|
|
from PySide6.QtCore import Qt, QThread, Signal, QPropertyAnimation, QEasingCurve, QParallelAnimationGroup, QFileInfo, QVariantAnimation, QTimer, QPointF, QRectF, QRect
|
|
from PySide6.QtGui import QFont, QDragEnterEvent, QDropEvent, QTextCursor, QIcon, QPixmap, QPainter, QColor, QPen, QImage, QImageWriter
|
|
from PySide6.QtSvg import QSvgRenderer
|
|
|
|
__app_name__ = "QPyPack"
|
|
__version__ = "2.5.6"
|
|
__author__ = "QwejayHuang"
|
|
__company__ = "QwejayHuang"
|
|
__description__ = "基于 PyInstaller 与 Nuitka 的跨平台 Python 应用打包构建工具"
|
|
|
|
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'
|
|
}
|
|
|
|
_CONFIG_DIR = Path.home() / ".qpypack"
|
|
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
CONFIG_FILE = (_CONFIG_DIR / "config.ini").as_posix()
|
|
|
|
PYPI_MIRRORS = [
|
|
("清华源", "https://pypi.tuna.tsinghua.edu.cn/simple"),
|
|
("阿里源", "https://mirrors.aliyun.com/pypi/simple/"),
|
|
("腾讯源", "https://mirrors.cloud.tencent.com/pypi/simple/"),
|
|
("华为源", "https://repo.huaweicloud.com/repository/pypi/simple/"),
|
|
("中科大源", "https://mirrors.ustc.edu.cn/pypi/simple/"),
|
|
("网易源", "https://mirrors.163.com/pypi/simple/"),
|
|
("PyPI 官方源", "https://pypi.org/simple"),
|
|
("Test PyPI 源", "https://test.pypi.org/simple"),
|
|
]
|
|
|
|
DEFAULT_MAPPINGS = {
|
|
'win32com': 'pywin32', 'win32api': 'pywin32', 'win32con': 'pywin32', 'win32gui': 'pywin32',
|
|
'win32clipboard': 'pywin32', 'win32print': 'pywin32', 'win32file': 'pywin32', 'win32security': 'pywin32',
|
|
'cv2': 'opencv-python', 'PIL': 'pillow', 'Pillow': 'pillow', 'bs4': 'beautifulsoup4', 'sklearn': 'scikit-learn',
|
|
'yaml': 'pyyaml', 'fitz': 'pymupdf', 'dotenv': 'python-dotenv'
|
|
}
|
|
|
|
def setup_combo_white_theme(combo: QComboBox):
|
|
combo.setView(QListView())
|
|
|
|
if combo.view():
|
|
combo.view().setMinimumWidth(520)
|
|
combo.view().setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
combo.view().setStyleSheet("""
|
|
QListView {
|
|
background-color: #ffffff !important;
|
|
color: #111827 !important;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 6px;
|
|
outline: none;
|
|
padding: 4px;
|
|
font-size: 12px;
|
|
font-family: Consolas, "Segoe UI", sans-serif;
|
|
}
|
|
QListView::item {
|
|
background-color: #ffffff !important;
|
|
color: #111827 !important;
|
|
min-height: 26px;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
QListView::item:hover, QListView::item:selected {
|
|
background-color: #eff6ff !important;
|
|
color: #2563eb !important;
|
|
}
|
|
""")
|
|
|
|
combo.setStyleSheet("""
|
|
QComboBox {
|
|
background-color: #ffffff;
|
|
color: #111827;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 6px;
|
|
padding: 5px 10px;
|
|
font-size: 12px;
|
|
min-height: 22px;
|
|
font-family: Consolas, "Segoe UI", sans-serif;
|
|
}
|
|
QComboBox:hover {
|
|
border-color: #9ca3af;
|
|
}
|
|
QComboBox:focus {
|
|
border-color: #2563eb;
|
|
}
|
|
QComboBox QLineEdit {
|
|
background-color: transparent;
|
|
color: #111827;
|
|
font-size: 12px;
|
|
selection-background-color: #eff6ff;
|
|
selection-color: #2563eb;
|
|
}
|
|
""")
|
|
|
|
combo.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
|
|
combo.setMinimumWidth(150)
|
|
|
|
def load_config():
|
|
config = configparser.ConfigParser()
|
|
if not os.path.exists(CONFIG_FILE):
|
|
config['Mappings'] = DEFAULT_MAPPINGS
|
|
config['Settings'] = {
|
|
'engine': 'PyInstaller',
|
|
'pip_index': 'https://pypi.tuna.tsinghua.edu.cn/simple',
|
|
'pip_index_backup': 'https://mirrors.aliyun.com/pypi/simple/',
|
|
'onefile': 'True', 'noconsole': 'True', 'clean_all': 'True',
|
|
'auto_icon': 'True', 'use_venv': 'True', 'use_reqs': 'True',
|
|
'use_pipreqs': 'True', 'upx': 'False', 'concise_log': 'True',
|
|
'cpu_cores': str(os.cpu_count() or 2), 'upx_path': '',
|
|
'exclude_modules': '', 'out_mode': '0', 'custom_out_dir': '',
|
|
'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:
|
|
config.read(CONFIG_FILE, encoding='utf-8')
|
|
if 'Mappings' not in config: config['Mappings'] = DEFAULT_MAPPINGS
|
|
if 'Settings' not in config: config['Settings'] = {}
|
|
default_updates = {
|
|
'pip_index': 'https://pypi.tuna.tsinghua.edu.cn/simple',
|
|
'pip_index_backup': 'https://mirrors.aliyun.com/pypi/simple/',
|
|
'concise_log': 'True',
|
|
'cpu_cores': str(os.cpu_count() or 2),
|
|
'upx_path': '',
|
|
'exclude_modules': '',
|
|
'out_mode': '0',
|
|
'custom_out_dir': '',
|
|
'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'
|
|
}
|
|
updated = 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
|
|
return config
|
|
|
|
def save_config(config):
|
|
try:
|
|
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
|
|
config.write(f)
|
|
except: 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 extract_imports_via_ast(script_path, python_exe):
|
|
code_snippet = (
|
|
"import ast, sys, json, re\n"
|
|
"code = ''\n"
|
|
"try:\n"
|
|
" with open(sys.argv[1], 'rb') as f: raw = f.read()\n"
|
|
" code = raw.decode('utf-8-sig') if raw.startswith(b'\\xef\\xbb\\xbf') else raw.decode('utf-8', errors='ignore')\n"
|
|
" imports = set()\n"
|
|
" for node in ast.walk(ast.parse(code)):\n"
|
|
" if isinstance(node, ast.Import): imports.update(n.name.split('.')[0] for n in node.names)\n"
|
|
" elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: imports.add(node.module.split('.')[0])\n"
|
|
" print('__QPYPACK_RES__:' + json.dumps(list(imports)))\n"
|
|
"except:\n"
|
|
" try:\n"
|
|
" m = re.findall(r'^\\s*(?:from|import)\\s+([a-zA-Z0-9_]+)', code, re.M)\n"
|
|
" print('__QPYPACK_RES__:' + json.dumps(list(set(m))))\n"
|
|
" except: print('__QPYPACK_RES__:[]')\n"
|
|
)
|
|
try:
|
|
env = os.environ.copy()
|
|
env.pop("PYTHONHOME", None)
|
|
env.pop("PYTHONPATH", None)
|
|
env["PYTHONUTF8"] = "1"
|
|
env["PYTHONIOENCODING"] = "utf-8"
|
|
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "encoding": "utf-8", "env": env}
|
|
if os.name == 'nt': kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
|
|
proc = subprocess.run([python_exe, "-c", code_snippet, script_path], **kwargs)
|
|
m = re.search(r'__QPYPACK_RES__:(.*)', proc.stdout)
|
|
if m:
|
|
return set(json.loads(m.group(1).strip()))
|
|
return set()
|
|
except:
|
|
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)
|
|
|
|
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'}
|
|
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, _ = 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",
|
|
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):
|
|
try: os.chmod(path, stat.S_IWRITE); func(path)
|
|
except: pass
|
|
|
|
def robust_rmtree(path: Path, retries=15, delay=0.8):
|
|
if not path.exists(): return True
|
|
for _ in range(retries):
|
|
try:
|
|
shutil.rmtree(path, onerror=remove_readonly)
|
|
if not path.exists(): return True
|
|
except: time.sleep(delay)
|
|
return False
|
|
|
|
def parse_add_data(add_data_str):
|
|
datas = []
|
|
for d in add_data_str.split(','):
|
|
d = d.strip()
|
|
if not d: continue
|
|
parts = d.rsplit(':', 1)
|
|
if len(parts) == 2 and not (os.name == 'nt' and len(parts[0]) == 1):
|
|
src_path = parts[0].strip()
|
|
dst_path = parts[1].strip()
|
|
else:
|
|
src_path = d.strip()
|
|
dst_path = "."
|
|
datas.append((src_path, dst_path))
|
|
return datas
|
|
|
|
def play_alert(success=True):
|
|
try:
|
|
if os.name == 'nt':
|
|
import winsound
|
|
winsound.MessageBeep(winsound.MB_OK if success else winsound.MB_ICONHAND)
|
|
else:
|
|
sys.stdout.write('\a')
|
|
sys.stdout.flush()
|
|
except:
|
|
pass
|
|
|
|
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()
|
|
|
|
if sys.platform == "darwin" and fmt == "icns":
|
|
try:
|
|
proc = subprocess.run(
|
|
["sips", "-s", "format", "icns", src.as_posix(), "--out", dst.as_posix()],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
|
)
|
|
if proc.returncode == 0 and dst.exists():
|
|
return True
|
|
except:
|
|
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)
|
|
elif fmt == "icns":
|
|
img = img.scaled(512, 512, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
|
|
writer = QImageWriter(dst.as_posix(), fmt.upper().encode('utf-8'))
|
|
if writer.write(img):
|
|
return True
|
|
except:
|
|
pass
|
|
|
|
try:
|
|
from PIL import Image
|
|
img = Image.open(src.as_posix())
|
|
if fmt == "ico":
|
|
img.save(dst.as_posix(), format="ICO", sizes=[(256, 256), (128, 128), (64, 64), (32, 32), (16, 16)])
|
|
return True
|
|
elif fmt == "icns":
|
|
img.save(dst.as_posix(), format="ICNS", sizes=[(512, 512), (256, 256), (128, 128), (64, 64)])
|
|
return True
|
|
else:
|
|
img.save(dst.as_posix(), format=dest_format.upper())
|
|
return True
|
|
except:
|
|
pass
|
|
|
|
return False
|
|
|
|
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.animation_group = QParallelAnimationGroup()
|
|
self.pos_anim = QPropertyAnimation(self, b"geometry")
|
|
self.pos_anim.setDuration(150)
|
|
self.pos_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
|
|
|
|
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
|
|
geom = self.geometry()
|
|
self.pos_anim.setStartValue(geom)
|
|
self.pos_anim.setEndValue(geom.adjusted(0, -2, 0, -2))
|
|
self.op_anim.setStartValue(1.0)
|
|
self.op_anim.setEndValue(0.85)
|
|
self.animation_group.start()
|
|
super().enterEvent(event)
|
|
|
|
def leaveEvent(self, event):
|
|
if self.is_hovered and self.isEnabled():
|
|
self.is_hovered = False
|
|
geom = self.geometry()
|
|
self.pos_anim.setStartValue(geom)
|
|
self.pos_anim.setEndValue(geom.adjusted(0, 2, 0, 2))
|
|
self.op_anim.setStartValue(0.85)
|
|
self.op_anim.setEndValue(1.0)
|
|
self.animation_group.start()
|
|
super().leaveEvent(event)
|
|
|
|
class TargetIconWidget(QWidget):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setFixedSize(200, 200)
|
|
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)
|
|
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) + 10 + self.burst_value * 40
|
|
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 * 65
|
|
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 = QRect(
|
|
int(center_x - draw_size / 2),
|
|
int(icon_center_y - draw_size / 2),
|
|
draw_size,
|
|
draw_size
|
|
)
|
|
scaled_pix = self.pixmap.scaled(draw_size, draw_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
painter.drawPixmap(pix_rect, scaled_pix)
|
|
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.setStyleSheet("""
|
|
#DropArea { background-color: #f8f9fa; border: 2px dashed #dadce0; border-radius: 12px; }
|
|
#DropArea:hover { background-color: #f1f3f4; border: 2px dashed #bdc1c6; }
|
|
""")
|
|
self.init_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(size, size)
|
|
if not pixmap.isNull():
|
|
return pixmap
|
|
return get_svg_pixmap('python', color="#9AA0A6", size=size)
|
|
|
|
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 源代码文件 (.py/.pyw) 拖放到此处\n或 点击手动选择文件")
|
|
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
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.setStyleSheet("QLabel { background: transparent; color: #9AA0A6; font-size: 13px; border: none; }")
|
|
layout.addWidget(self.sub_label)
|
|
|
|
layout.addStretch(1)
|
|
|
|
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, _ = QFileDialog.getOpenFileName(
|
|
self,
|
|
"选择 Python 源代码文件",
|
|
"",
|
|
"Python Scripts (*.py *.pyw);;All Files (*)"
|
|
)
|
|
if fp: self.fileDropped.emit(fp)
|
|
|
|
def set_loading(self, filename):
|
|
pixmap = get_svg_pixmap('package', color="#1A73E8", size=88)
|
|
self.icon_widget.set_file_pixmap(pixmap, 88)
|
|
self.label.setText(f"已载入源文件:{filename}")
|
|
self.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
|
|
self.sub_label.setText("正在解析依赖与项目元数据,请稍候...")
|
|
|
|
def set_success(self, filename, custom_icon_path=None):
|
|
pixmap = None
|
|
if custom_icon_path and Path(custom_icon_path).exists():
|
|
pixmap = QIcon(str(custom_icon_path)).pixmap(88, 88)
|
|
if pixmap.isNull():
|
|
pixmap = None
|
|
|
|
if not pixmap:
|
|
pixmap = get_svg_pixmap('package', color="#1A73E8", size=88)
|
|
|
|
self.icon_widget.set_file_pixmap(pixmap, 88)
|
|
|
|
self.label.setText(f"已载入源文件:{filename}")
|
|
self.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
|
|
self.sub_label.setText("环境配置就绪,可执行构建")
|
|
|
|
def start_build_anim(self):
|
|
self.label.setText("正在初始化构建流程...")
|
|
self.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }")
|
|
self.sub_label.setText("构建引擎准备中...")
|
|
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(size, size)
|
|
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("构建任务执行成功")
|
|
self.label.setStyleSheet("QLabel { background: transparent; color: #1E8E3E; font-size: 20px; font-weight: bold; border: none; }")
|
|
self.sub_label.setText("可打开输出目录查看构建产物,或重置当前工作区")
|
|
|
|
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("构建异常中断")
|
|
self.label.setStyleSheet("QLabel { background: transparent; color: #D93025; font-size: 20px; font-weight: bold; border: none; }")
|
|
self.sub_label.setText("请参阅下方编译日志以排查构建异常")
|
|
|
|
def reset(self):
|
|
self.icon_widget.reset()
|
|
self.label.setText("将 Python 源代码文件 (.py/.pyw) 拖放到此处\n或 点击手动选择文件")
|
|
self.label.setStyleSheet("QLabel { background: transparent; color: #5F6368; font-size: 16px; font-weight: bold; border: none; }")
|
|
self.sub_label.setText("自动解析模块依赖、资源文件与隐式导入")
|
|
|
|
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))
|
|
candidates = resolved_candidates
|
|
|
|
valid_pythons = {}
|
|
for cand in candidates:
|
|
if os.name == 'nt' and "WindowsApps" in cand:
|
|
try:
|
|
if os.path.getsize(cand) == 0: continue
|
|
except: continue
|
|
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(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')"], **kwargs)
|
|
if proc.returncode == 0:
|
|
ver = proc.stdout.strip()
|
|
valid_pythons[cand] = ver
|
|
except: pass
|
|
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;
|
|
image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='white'><path d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/></svg>");
|
|
}
|
|
|
|
/* ===== 扁平卡片(纯白 + 极细浅灰边框) ===== */
|
|
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 14px;
|
|
color: #374151;
|
|
font-weight: 600;
|
|
font-size: 12px;
|
|
}
|
|
QPushButton.ToolBtn:hover {
|
|
background: #e5e7eb;
|
|
color: #111827;
|
|
border-color: #d1d5db;
|
|
}
|
|
QPushButton.ToolBtn:pressed {
|
|
background: #d1d5db;
|
|
}
|
|
|
|
/* ===== 主 TAB 栏 (一级 TAB 居中 + 增大间距) ===== */
|
|
QTabWidget#MainTabWidget::pane {
|
|
border: none;
|
|
background: transparent;
|
|
}
|
|
QTabWidget#MainTabWidget::tab-bar {
|
|
alignment: center;
|
|
}
|
|
QTabBar#MainTabBar {
|
|
|
|
}
|
|
QTabBar#MainTabBar::tab {
|
|
background: transparent;
|
|
border: none;
|
|
padding: 10px 24px;
|
|
color: #6b7280;
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
border-bottom: 2px solid transparent;
|
|
margin: 0 14px;
|
|
}
|
|
QTabBar#MainTabBar::tab:selected {
|
|
color: #2563eb;
|
|
font-weight: 700;
|
|
border-bottom: 2px solid #2563eb;
|
|
}
|
|
QTabBar#MainTabBar::tab:hover:!selected {
|
|
color: #111827;
|
|
}
|
|
|
|
/* ===== 子 TAB 栏 (二级 TAB 居中 + 增大间距) ===== */
|
|
QTabWidget#SubTabWidget::pane {
|
|
border: none;
|
|
background: transparent;
|
|
}
|
|
QTabWidget#SubTabWidget::tab-bar {
|
|
alignment: center;
|
|
}
|
|
QTabBar#SubTabBar {
|
|
}
|
|
QTabBar#SubTabBar::tab {
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
padding: 6px 18px;
|
|
color: #6b7280;
|
|
font-weight: 600;
|
|
font-size: 12px;
|
|
margin: 0 8px 12px 8px;
|
|
border-radius: 6px;
|
|
}
|
|
QTabBar#SubTabBar::tab:selected {
|
|
background: #eff6ff;
|
|
color: #2563eb;
|
|
border: 1px solid #bfdbfe;
|
|
}
|
|
QTabBar#SubTabBar::tab:hover:!selected {
|
|
background: #f3f4f6;
|
|
color: #111827;
|
|
}
|
|
|
|
/* ===== 列表组件 ===== */
|
|
QListWidget {
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 6px;
|
|
background: #ffffff;
|
|
outline: none;
|
|
font-size: 12px;
|
|
}
|
|
QListWidget::item {
|
|
padding: 6px 10px;
|
|
border-bottom: 1px solid #f3f4f6;
|
|
color: #1f2937;
|
|
}
|
|
QListWidget::item:selected {
|
|
background: #eff6ff;
|
|
color: #2563eb;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ===== 极简细体滚动条 ===== */
|
|
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()
|
|
self.load_from_config()
|
|
|
|
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.setStyleSheet("font-size: 15px; font-weight: 800; color: #1a73e8; margin-bottom: 6px;")
|
|
lay.addWidget(lbl)
|
|
return card, 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()
|
|
if "http://" in text or "https://" in text:
|
|
m = re.search(r'https?://[^\s]+', text)
|
|
if m: return m.group(0)
|
|
return text
|
|
|
|
def _set_combo_value(self, combo, url_val):
|
|
url_val = (url_val or '').strip()
|
|
if not url_val: return
|
|
for i in range(combo.count()):
|
|
data = combo.itemData(i)
|
|
if data and data.rstrip('/') == url_val.rstrip('/'):
|
|
combo.setCurrentIndex(i)
|
|
return
|
|
combo.setCurrentText(url_val)
|
|
|
|
def init_ui(self):
|
|
layout = QVBoxLayout(self)
|
|
layout.setSpacing(15)
|
|
layout.setContentsMargins(20, 10, 20, 20)
|
|
|
|
self.tabs = QTabWidget()
|
|
self.tabs.setObjectName("MainTabWidget")
|
|
self.tabs.tabBar().setObjectName("MainTabBar")
|
|
self.tabs.tabBar().setExpanding(False)
|
|
|
|
self.tab_build = QWidget()
|
|
self.tab_pref = QWidget()
|
|
self.tab_about = QWidget()
|
|
|
|
self.tabs.addTab(self.tab_build, get_svg_icon('package', "#5F6368", 16), "构建参数")
|
|
self.tabs.addTab(self.tab_pref, get_svg_icon('settings', "#5F6368", 16), "全局设置")
|
|
self.tabs.addTab(self.tab_about, get_svg_icon('info', "#5F6368", 16), "关于")
|
|
|
|
self.build_build_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("重置为默认配置")
|
|
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(" 保存并返回")
|
|
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("放弃修改并返回")
|
|
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 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)
|
|
|
|
def load_from_config(self):
|
|
config = load_config()
|
|
if 'Settings' in config:
|
|
s = config['Settings']
|
|
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.tuna.tsinghua.edu.cn/simple')
|
|
self._set_combo_value(self.pip_source_combo, pip_main)
|
|
|
|
pip_backup = s.get('pip_index_backup', 'https://mirrors.aliyun.com/pypi/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.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.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()
|
|
res_str = s.get('add_data_list', '')
|
|
if res_str:
|
|
for part in res_str.split("|||"):
|
|
if part.count('|') == 2:
|
|
r_type, src, dst = part.split('|')
|
|
self._add_resource_item(r_type, src, dst)
|
|
else:
|
|
old_str = s.get('add_data', '')
|
|
if old_str:
|
|
for src, dst in parse_add_data(old_str):
|
|
r_type = 'dir' if Path(src).is_dir() else 'file'
|
|
self._add_resource_item(r_type, src, dst)
|
|
|
|
def save_to_config(self):
|
|
config = load_config()
|
|
if 'Settings' not in config: config['Settings'] = {}
|
|
s = config['Settings']
|
|
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_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['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 = []
|
|
for i in range(self.add_data_list.count()):
|
|
r_type, src, dst = self.add_data_list.item(i).data(Qt.ItemDataRole.UserRole)
|
|
res_list.append(f"{r_type}|{src}|{dst}")
|
|
s['add_data_list'] = "|||".join(res_list)
|
|
|
|
save_config(config)
|
|
|
|
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 build_build_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)
|
|
|
|
tab_basic = QWidget()
|
|
tab_assets = QWidget()
|
|
tab_advanced = QWidget()
|
|
|
|
self.sub_tabs.addTab(tab_basic, "基础设置")
|
|
self.sub_tabs.addTab(tab_assets, "资源与依赖")
|
|
self.sub_tabs.addTab(tab_advanced, "高级控制")
|
|
|
|
lay_basic = QVBoxLayout(tab_basic)
|
|
lay_basic.setContentsMargins(0, 5, 0, 0)
|
|
|
|
card1, c_lay1 = self._create_card("编译核心配置")
|
|
form1 = QFormLayout()
|
|
form1.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
|
form1.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.python_path_combo = QComboBox()
|
|
self.python_path_combo.setEditable(True)
|
|
self.python_path_combo.setPlaceholderText("留空则自动检索当前环境下的默认 Python 解释器")
|
|
setup_combo_white_theme(self.python_path_combo)
|
|
|
|
btn_python_path = QPushButton("浏览")
|
|
btn_python_path.setProperty("class", "ToolBtn")
|
|
btn_python_path.clicked.connect(self.select_python_path)
|
|
py_cont = QWidget(); h_py = QHBoxLayout(py_cont); h_py.setContentsMargins(0,0,0,0)
|
|
h_py.addWidget(self.python_path_combo); h_py.addWidget(btn_python_path)
|
|
|
|
self.name_edit = QLineEdit()
|
|
self.name_edit.setPlaceholderText("默认自适应源文件名")
|
|
|
|
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)
|
|
btn_icon = QPushButton("浏览")
|
|
btn_icon.setProperty("class", "ToolBtn")
|
|
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); h_icon.addWidget(self.icon_preview); h_icon.addWidget(btn_icon)
|
|
|
|
form1.addRow("编译引擎 (Engine):", self.engine_combo)
|
|
form1.addRow("解释器 (Interpreter):", py_cont)
|
|
form1.addRow("可执行文件名 (Name):", self.name_edit)
|
|
form1.addRow("应用图标 (Icon):", icon_cont)
|
|
c_lay1.addLayout(form1)
|
|
|
|
card2, c_lay2 = self._create_card("运行属性 (Run Properties)")
|
|
h_mode = QHBoxLayout()
|
|
self.onefile_check = QCheckBox("单文件打包模式 (OneFile / Standalone)")
|
|
self.noconsole_check = QCheckBox("无控制台模式 (Windowed / No Console)")
|
|
h_mode.addWidget(self.onefile_check)
|
|
h_mode.addWidget(self.noconsole_check)
|
|
h_mode.addStretch()
|
|
c_lay2.addLayout(h_mode)
|
|
|
|
lay_basic.addWidget(card1)
|
|
lay_basic.addWidget(card2)
|
|
lay_basic.addStretch()
|
|
|
|
scroll_assets = QScrollArea()
|
|
scroll_assets.setWidgetResizable(True)
|
|
scroll_assets.setFrameShape(QFrame.Shape.NoFrame)
|
|
scroll_assets.setStyleSheet("background: transparent;")
|
|
cont_assets = QWidget()
|
|
lay_assets = QVBoxLayout(cont_assets)
|
|
lay_assets.setContentsMargins(0, 5, 0, 0)
|
|
|
|
card3, c_lay3 = self._create_card("依赖与虚拟环境控制")
|
|
form2 = QFormLayout()
|
|
form2.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
|
form2.setSpacing(15)
|
|
|
|
self.pip_source_combo = QComboBox()
|
|
self.pip_source_combo.setEditable(True)
|
|
setup_combo_white_theme(self.pip_source_combo)
|
|
|
|
self.pip_backup_combo = QComboBox()
|
|
self.pip_backup_combo.setEditable(True)
|
|
setup_combo_white_theme(self.pip_backup_combo)
|
|
|
|
for name, url in PYPI_MIRRORS:
|
|
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("留空则自动检索当前目录下的 requirements.txt")
|
|
btn_reqs = QPushButton("选择"); btn_reqs.setProperty("class", "ToolBtn"); 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); h_reqs.addWidget(btn_reqs)
|
|
|
|
self.hidden_edit = QLineEdit(); self.hidden_edit.setPlaceholderText("显式指定未被隐式导入的依赖,英文逗号分隔 (如: pandas, PyQt5)")
|
|
btn_scan = QPushButton("AST扫描"); btn_scan.setProperty("class", "ToolBtn"); 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); h_hid.addWidget(btn_scan)
|
|
|
|
self.exclude_edit = QLineEdit(); self.exclude_edit.setPlaceholderText("指定不进行打包的模块列表,英文逗号分隔 (如: tkinter, matplotlib)")
|
|
|
|
form2.addRow("PIP 主镜像源 (Primary):", self.pip_source_combo)
|
|
form2.addRow("PIP 备用源 (Backup):", self.pip_backup_combo)
|
|
form2.addRow("声明清单 (Requirements):", reqs_cont)
|
|
form2.addRow("隐式导入 (Hidden Imports):", hid_cont)
|
|
form2.addRow("排除模块 (Exclude):", self.exclude_edit)
|
|
c_lay3.addLayout(form2)
|
|
|
|
c_lay3.addSpacing(5)
|
|
g_dep = QGridLayout()
|
|
g_dep.setSpacing(10)
|
|
self.venv_check = QCheckBox("使用虚拟环境 (Virtualenv)")
|
|
self.reqs_check = QCheckBox("同步 Requirements 声明")
|
|
self.pipreqs_check = QCheckBox("使用 pipreqs 自动分析依赖")
|
|
g_dep.addWidget(self.venv_check, 0, 0)
|
|
g_dep.addWidget(self.reqs_check, 0, 1)
|
|
g_dep.addWidget(self.pipreqs_check, 1, 0, 1, 2)
|
|
c_lay3.addLayout(g_dep)
|
|
|
|
card4, c_lay4 = self._create_card("附加资源文件 (Data Files / Folders)")
|
|
self.add_data_list = QListWidget()
|
|
self.add_data_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
|
self.add_data_list.setFixedHeight(120)
|
|
self.add_data_list.itemDoubleClicked.connect(self.edit_resource)
|
|
c_lay4.addWidget(self.add_data_list)
|
|
|
|
btn_res_lay = QHBoxLayout()
|
|
self.btn_add_file = QPushButton("添加文件"); self.btn_add_file.setProperty("class", "ToolBtn"); self.btn_add_file.clicked.connect(self.add_resource_files)
|
|
self.btn_add_dir = QPushButton("添加目录"); self.btn_add_dir.setProperty("class", "ToolBtn"); self.btn_add_dir.clicked.connect(self.add_resource_dir)
|
|
self.btn_del_res = QPushButton("删除选中"); self.btn_del_res.setProperty("class", "ToolBtn"); self.btn_del_res.clicked.connect(self.del_resource)
|
|
self.btn_clear_res = QPushButton("清空"); 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_lay4.addLayout(btn_res_lay)
|
|
|
|
lay_assets.addWidget(card3)
|
|
lay_assets.addWidget(card4)
|
|
lay_assets.addStretch()
|
|
|
|
scroll_assets.setWidget(cont_assets)
|
|
lay_tabs_assets = QVBoxLayout(tab_assets)
|
|
lay_tabs_assets.setContentsMargins(0,0,0,0)
|
|
lay_tabs_assets.addWidget(scroll_assets)
|
|
|
|
scroll_adv = QScrollArea()
|
|
scroll_adv.setWidgetResizable(True)
|
|
scroll_adv.setFrameShape(QFrame.Shape.NoFrame)
|
|
scroll_adv.setStyleSheet("background: transparent;")
|
|
cont_adv = QWidget()
|
|
lay_adv = QVBoxLayout(cont_adv)
|
|
lay_adv.setContentsMargins(0, 5, 0, 0)
|
|
|
|
card5, c_lay5 = self._create_card("版本信息与元数据 (Metadata)")
|
|
form3 = QFormLayout()
|
|
form3.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
|
form3.setSpacing(15)
|
|
self.ver_ver = QLineEdit("1.0.0")
|
|
self.ver_comp = QLineEdit("My Studio")
|
|
self.ver_desc = QLineEdit("Python Executable")
|
|
form3.addRow("应用版本 (Version):", self.ver_ver)
|
|
form3.addRow("公司名称 (Company):", self.ver_comp)
|
|
form3.addRow("应用描述 (Description):", self.ver_desc)
|
|
c_lay5.addLayout(form3)
|
|
|
|
card6, c_lay6 = self._create_card("性能与编译优化")
|
|
form4 = QFormLayout()
|
|
form4.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
|
form4.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)
|
|
form4.addRow("并发构建线程数 (Jobs):", self.cores_spin)
|
|
|
|
self.upx_check = QCheckBox("启用 UPX 压缩优化")
|
|
self.upx_check.toggled.connect(self.on_upx_toggled)
|
|
self.upx_path_edit = QLineEdit(); self.upx_path_edit.setPlaceholderText("留空则自动检测环境变量")
|
|
btn_upx = QPushButton("选择"); btn_upx.setProperty("class", "ToolBtn"); 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); h_upx.addWidget(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)
|
|
form4.addRow("UPX 压缩工具 (UPX Path):", h_upx_row)
|
|
|
|
self.lite_mode_check = QCheckBox("启用精简模式")
|
|
self.lite_mode_check.setStyleSheet("color: #D93025; font-weight: bold;")
|
|
self.lite_mode_check.setToolTip("动态排除开发及测试环境冗余依赖,提升构建速度并缩减体积。")
|
|
c_lay6.addLayout(form4)
|
|
c_lay6.addWidget(self.lite_mode_check)
|
|
|
|
card7, c_lay7 = self._create_card("版本锁定 (Version Pinning)")
|
|
form5 = QFormLayout()
|
|
form5.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
|
form5.setSpacing(15)
|
|
self.pyi_ver_edit = QLineEdit()
|
|
self.pyi_ver_edit.setPlaceholderText("默认最新版 (旧版本可填 6.6.0)")
|
|
self.nuitka_ver_edit = QLineEdit()
|
|
self.nuitka_ver_edit.setPlaceholderText("默认最新版 (旧版本可填 4.1.3)")
|
|
self.pipreqs_ver_edit = QLineEdit()
|
|
self.pipreqs_ver_edit.setPlaceholderText("默认最新版 (旧版本可填 0.4.13)")
|
|
form5.addRow("PyInstaller 版本:", self.pyi_ver_edit)
|
|
form5.addRow("Nuitka 版本:", self.nuitka_ver_edit)
|
|
form5.addRow("Pipreqs 版本:", self.pipreqs_ver_edit)
|
|
c_lay7.addLayout(form5)
|
|
|
|
lay_adv.addWidget(card5)
|
|
lay_adv.addWidget(card6)
|
|
lay_adv.addWidget(card7)
|
|
lay_adv.addStretch()
|
|
|
|
scroll_adv.setWidget(cont_adv)
|
|
lay_tabs_adv = QVBoxLayout(tab_advanced)
|
|
lay_tabs_adv.setContentsMargins(0,0,0,0)
|
|
lay_tabs_adv.addWidget(scroll_adv)
|
|
|
|
main_lay.addWidget(self.sub_tabs)
|
|
self.on_engine_changed()
|
|
|
|
def build_pref_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)
|
|
|
|
card1, lay1 = self._create_card("输出设置")
|
|
self.out_mode_combo = QComboBox()
|
|
self.out_mode_combo.addItems(["默认路径 (源文件同级目录)", "自定义归档路径 (自定义目录)"])
|
|
setup_combo_white_theme(self.out_mode_combo)
|
|
self.out_mode_combo.currentIndexChanged.connect(self.on_out_mode_changed)
|
|
|
|
self.out_dir_edit = QLineEdit(); self.out_dir_edit.setPlaceholderText("选择具体的输出归档路径...")
|
|
btn_out_dir = QPushButton("浏览..."); btn_out_dir.setProperty("class", "ToolBtn"); 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); h_out_dir.addWidget(btn_out_dir)
|
|
self.out_dir_container.setVisible(False)
|
|
|
|
form1 = QFormLayout()
|
|
form1.setVerticalSpacing(15)
|
|
form1.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
|
form1.addRow("输出路径规则:", self.out_mode_combo)
|
|
form1.addRow("目标输出目录:", self.out_dir_container)
|
|
lay1.addLayout(form1)
|
|
lay.addWidget(card1)
|
|
|
|
card2, lay2 = self._create_card("编译选项")
|
|
lay2.setSpacing(16)
|
|
|
|
self.concise_log_check = QCheckBox("精简日志输出")
|
|
self.auto_save_log_check = QCheckBox("自动保存编译日志")
|
|
self.auto_icon_check = QCheckBox("自动检索图标")
|
|
self.clean_all_check = QCheckBox("清理构建缓存")
|
|
self.sound_notify_check = QCheckBox("启用构建提示音")
|
|
|
|
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)
|
|
|
|
lay.addWidget(card2)
|
|
lay.addStretch()
|
|
|
|
scroll.setWidget(content)
|
|
main_lay = QVBoxLayout(self.tab_pref)
|
|
main_lay.setContentsMargins(0,0,0,0)
|
|
main_lay.addWidget(scroll)
|
|
|
|
def build_about_tab(self):
|
|
main_lay = QVBoxLayout(self.tab_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)
|
|
|
|
desc_lbl = QLabel(__description__)
|
|
desc_lbl.setStyleSheet("font-size: 14px; color: #5f6368;")
|
|
desc_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
main_lay.addWidget(desc_lbl)
|
|
|
|
main_lay.addSpacing(25)
|
|
|
|
btn_lay = QHBoxLayout()
|
|
btn_lay.setSpacing(15)
|
|
btn_lay.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
def create_link_btn(text, url):
|
|
btn = QPushButton(text)
|
|
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #f1f3f4;
|
|
color: #3c4043;
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 10px 20px;
|
|
font-size: 13px;
|
|
font-weight: bold;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #e8eaed;
|
|
color: #1A73E8;
|
|
}
|
|
QPushButton:pressed {
|
|
background-color: #dadce0;
|
|
}
|
|
""")
|
|
btn.clicked.connect(lambda: __import__('webbrowser').open(url))
|
|
return btn
|
|
|
|
btn_github = create_link_btn("GitHub 仓库", "https://github.com/qwejay/QPyPack")
|
|
btn_issue = create_link_btn("反馈与建议", "https://github.com/qwejay/QPyPack/issues")
|
|
btn_pypi = create_link_btn("PyPI 主页", "https://pypi.org/project/qpypack/")
|
|
|
|
btn_lay.addWidget(btn_github)
|
|
btn_lay.addWidget(btn_issue)
|
|
btn_lay.addWidget(btn_pypi)
|
|
|
|
main_lay.addLayout(btn_lay)
|
|
|
|
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 on_engine_changed(self):
|
|
if getattr(self, 'upx_check', None) is not None and getattr(self, 'upx_path_container', None) is not None:
|
|
engine = self.engine_combo.currentText()
|
|
is_pyi = (engine == "PyInstaller")
|
|
self.upx_check.setVisible(is_pyi)
|
|
self.upx_path_container.setVisible(is_pyi and self.upx_check.isChecked())
|
|
|
|
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, "选择输出归档目录")
|
|
if d:
|
|
self.out_dir_edit.setText(Path(d).resolve().as_posix())
|
|
|
|
def select_upx_path(self):
|
|
d = QFileDialog.getExistingDirectory(self, "选择 UPX 工具根目录")
|
|
if d:
|
|
self.upx_path_edit.setText(Path(d).resolve().as_posix())
|
|
|
|
def select_reqs_file(self):
|
|
f, _ = QFileDialog.getOpenFileName(self, "选择依赖清单文件", "", "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, _ = QFileDialog.getOpenFileName(self, "选择 Python 解释器", "", 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, _ = QFileDialog.getOpenFileName(self, "选择图标", "", "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 script_path: return QMessageBox.warning(self, "构建约束", "请先加载有效的 Python 源代码文件!")
|
|
if is_cloud_locked(script_path):
|
|
return QMessageBox.warning(self, "I/O 错误", "目标脚本处于云盘加密或锁定状态,请解密后重试。")
|
|
|
|
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))
|
|
QMessageBox.information(self, "AST 分析完成", f"语法树解析成功,共定位到 {len(hidden)} 项非标准库依赖。")
|
|
except Exception as e:
|
|
QMessageBox.warning(self, "分析异常", f"AST 语法树解析过程中发生异常: {e}")
|
|
|
|
def add_resource_files(self):
|
|
files, _ = QFileDialog.getOpenFileNames(self, "选择附加文件", "", "All Files (*)")
|
|
for f in files:
|
|
src = Path(f).resolve().as_posix()
|
|
dst = "."
|
|
self._add_resource_item('file', src, dst)
|
|
|
|
def add_resource_dir(self):
|
|
folder = QFileDialog.getExistingDirectory(self, "选择附加文件夹")
|
|
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):
|
|
display_text = f"[{'文件' if r_type == 'file' else '目录'}] {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, "修改目标路径", "请输入打包后的相对目标路径\n(根目录请填 . ):", 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))
|
|
item.setText(f"[{'文件' if r_type == 'file' else '目录'}] {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()
|
|
|
|
class ScriptAnalysisThread(QThread):
|
|
analysis_done = Signal(str, str, str, str, set)
|
|
|
|
def __init__(self, path):
|
|
super().__init__()
|
|
self.path = path
|
|
|
|
def run(self):
|
|
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
|
|
|
|
try:
|
|
python_exe = get_python_executable()
|
|
script_imports = extract_imports_via_ast(self.path, python_exe)
|
|
except:
|
|
pass
|
|
|
|
self.analysis_done.emit(app_name, version, author, desc, script_imports)
|
|
|
|
class PackingThread(QThread):
|
|
progress = Signal(str)
|
|
finished = Signal(bool, str)
|
|
|
|
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]
|
|
cmd_raw_lines = []
|
|
|
|
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"
|
|
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()
|
|
|
|
def is_noisy_line(l):
|
|
l_lower = l.lower()
|
|
if "error:" in l_lower:
|
|
return False
|
|
return any(kw in l_lower for kw in ["warning:", "info:", "deprecation:", "userwarning:", "futurewarning:"])
|
|
|
|
for line in self.process.stdout:
|
|
if self._is_cancelled:
|
|
self.process.terminate()
|
|
return False
|
|
|
|
stripped = line.rstrip('\r\n')
|
|
cmd_raw_lines.append(stripped)
|
|
self.all_raw_logs.append(stripped)
|
|
|
|
if self.params.get('concise_log', True) and is_noisy_line(stripped):
|
|
continue
|
|
|
|
buffer.append(stripped)
|
|
if len(buffer) >= 15 or (time.time() - last_emit) > 0.1:
|
|
self.progress.emit('\n'.join(buffer))
|
|
buffer.clear()
|
|
last_emit = time.time()
|
|
|
|
if buffer:
|
|
self.progress.emit('\n'.join(buffer))
|
|
|
|
self.process.wait()
|
|
|
|
if is_timeout[0]:
|
|
self.progress.emit(f"[WARN] 子进程执行超时 (>{timeout}s),正在终止执行...")
|
|
return False
|
|
|
|
success = self.process.returncode == 0
|
|
|
|
if not success and not silent_error and self.params.get('concise_log', True) and cmd_raw_lines:
|
|
self.progress.emit("\n" + "!"*10 + " [诊断回溯: 以下是由于该环节执行异常产生的完整原始日志] " + "!"*10)
|
|
self.progress.emit('\n'.join(cmd_raw_lines))
|
|
self.progress.emit("!"*60 + "\n")
|
|
|
|
return success
|
|
except FileNotFoundError as e:
|
|
cmd_name = cmd[0] if isinstance(cmd, list) and cmd else str(cmd)
|
|
if "python" in str(cmd_name).lower():
|
|
self.progress.emit(f"[ERROR] 环境异常:无法定位 Python 解释器,请检查环境变量配置。")
|
|
else:
|
|
self.progress.emit(f"[ERROR] 子进程调用失败:未检测到系统指令或外部程序 \"{cmd_name}\" ({e})")
|
|
return False
|
|
except Exception as e:
|
|
self.progress.emit(f"[ERROR] 子进程执行发生 system 级异常: {e}")
|
|
return False
|
|
finally:
|
|
if timer:
|
|
timer.cancel()
|
|
|
|
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 = []
|
|
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(f"[WARN] 主镜像源连接超时或失败,正在自动切换至备用源重试: {backup_idx}")
|
|
fallback_cmd = [python_exe, "-m", "pip", "install"] + pkgs_or_args + ["-i", backup_idx]
|
|
success = self.run_cmd(fallback_cmd)
|
|
|
|
return success
|
|
|
|
def sanitize_script(self, orig_path: Path):
|
|
if is_cloud_locked(orig_path):
|
|
return None, False, "目标脚本处于云盘加密或锁定状态,请解密后重试。"
|
|
|
|
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_code = "\n" + "#"*30 + "\n" + (
|
|
"try:\n"
|
|
" import sys\n"
|
|
" if sys.platform == 'win32':\n"
|
|
" import ctypes\n"
|
|
" kernel32 = ctypes.windll.kernel32\n"
|
|
" process_list = (ctypes.c_uint * 10)()\n"
|
|
" num_processes = kernel32.GetConsoleProcessList(process_list, 10)\n"
|
|
" if num_processes <= 2:\n"
|
|
" input('\\n执行完毕,按回车键退出...')\n"
|
|
"except:\n"
|
|
" pass\n"
|
|
)
|
|
|
|
temp_file = orig_path.parent / f"_qpypack_temp_entry_{int(time.time())}.py"
|
|
temp_file.write_text(code + pause_code, encoding='utf-8')
|
|
return temp_file, True, ""
|
|
except Exception as e:
|
|
self.progress.emit(f"[WARN] 自动注入控制台防闪退阻塞机制失败: {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 = "未知"
|
|
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']
|
|
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 ""
|
|
|
|
try:
|
|
self.progress.emit("[INFO] 正在初始化隔离构建环境...")
|
|
|
|
script_path = Path(self.params['script_path']).resolve()
|
|
script_dir = script_path.parent
|
|
|
|
build_script_path, is_temp, err_msg = self.sanitize_script(script_path)
|
|
if not build_script_path and err_msg: return self.finished.emit(False, f"[ERROR] I/O 异常: {err_msg}")
|
|
script_posix = build_script_path.as_posix()
|
|
|
|
system_python_exe = get_python_executable()
|
|
self.progress.emit(f"[INFO] Python 解释器路径: {system_python_exe}")
|
|
|
|
script_imports = set()
|
|
try:
|
|
script_imports = extract_imports_via_ast(script_posix, system_python_exe)
|
|
if self.params.get('lite_mode'):
|
|
scan_count = 0
|
|
for py_file in script_dir.rglob("*.py"):
|
|
if any(p.startswith('.') or p.lower() in ('venv', 'env', 'site-packages', 'node_modules', '__pycache__') for p in py_file.parts):
|
|
continue
|
|
if scan_count > 500: break
|
|
try:
|
|
script_imports.update(extract_imports_via_ast(py_file.as_posix(), system_python_exe))
|
|
scan_count += 1
|
|
except: pass
|
|
except Exception as e:
|
|
self.progress.emit(f"[WARN] 源码 AST 分析异常: {e}")
|
|
|
|
if self.params['use_venv']:
|
|
self.progress.emit("[INFO] 正在创建虚拟环境...")
|
|
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.finished.emit(False, "[ERROR] 虚拟环境 (virtualenv) 创建失败。当前 Python 环境可能缺失必要模块或权限受限。")
|
|
python_exe = (self.venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).as_posix()
|
|
|
|
self.progress.emit("[INFO] 正在同步并升级 pip 包管理器...")
|
|
self.run_pip_install(python_exe, ["--upgrade", "pip", "-q"])
|
|
else:
|
|
python_exe = system_python_exe
|
|
|
|
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']}"
|
|
|
|
self.progress.emit(f"[INFO] 正在安装编译引擎 [{engine_pkg}] 及核心编译依赖...")
|
|
core_pkgs = [engine_pkg]
|
|
if engine == "PyInstaller":
|
|
core_pkgs.append("pillow")
|
|
elif engine == "Nuitka":
|
|
core_pkgs.append("zstandard")
|
|
self.run_pip_install(python_exe, ["-q"] + core_pkgs)
|
|
|
|
if self.params.get('use_reqs'):
|
|
custom_reqs = self.params.get('reqs_file', '').strip()
|
|
if custom_reqs and Path(custom_reqs).exists():
|
|
req_file = Path(custom_reqs)
|
|
else:
|
|
req_file = script_dir / "requirements.txt"
|
|
|
|
if req_file.exists():
|
|
self.progress.emit(f"[INFO] 依赖安装 [1/3]: 正在安装声明依赖 ({req_file.name})...")
|
|
try:
|
|
if is_cloud_locked(req_file): raise ValueError("依赖清单文件已被系统锁定")
|
|
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')
|
|
|
|
temp_req = Path(tempfile.gettempdir()) / f"qpypack_temp_reqs_{int(time.time())}.txt"
|
|
temp_req.write_text(req_content, encoding='utf-8')
|
|
self.run_pip_install(python_exe, ["-q", "-r", temp_req.as_posix()])
|
|
temp_req.unlink(missing_ok=True)
|
|
except Exception as e: self.progress.emit(f"[WARN] 依赖清单读取或安装过程发生异常: {e}")
|
|
|
|
if self.params.get('use_pipreqs'):
|
|
self.progress.emit("[INFO] 依赖安装 [2/3]: 正在调用 pipreqs 分析项目依赖...")
|
|
|
|
pipreqs_pkg = "pipreqs"
|
|
if self.params.get('pipreqs_version'):
|
|
pipreqs_pkg = f"pipreqs=={self.params['pipreqs_version']}"
|
|
|
|
self.run_pip_install(python_exe, [pipreqs_pkg, "-q"])
|
|
temp_pipreqs = Path(tempfile.gettempdir()) / f"qpypack_pipreqs_{int(time.time())}.txt"
|
|
|
|
pypi_server = None
|
|
if pip_idx:
|
|
pypi_server = re.sub(r'/simple/?$', '/pypi', pip_idx, flags=re.I).rstrip('/')
|
|
|
|
pipreqs_cmd = [
|
|
python_exe, "-m", "pipreqs.pipreqs", script_dir.as_posix(),
|
|
"--encoding", "utf-8", "--force", "--savepath", temp_pipreqs.as_posix()
|
|
]
|
|
if pypi_server:
|
|
pipreqs_cmd.extend(["--pypi-server", pypi_server])
|
|
self.progress.emit(f"[INFO] 依赖分析服务源地址: {pypi_server}")
|
|
self.progress.emit("[INFO] 正在查询各依赖库的版本,请稍候...")
|
|
|
|
success_pipreqs = self.run_cmd(pipreqs_cmd, timeout=120, silent_error=True)
|
|
|
|
if not success_pipreqs and pip_backup and pip_backup != pip_idx:
|
|
backup_pypi = re.sub(r'/simple/?$', '/pypi', pip_backup, flags=re.I).rstrip('/')
|
|
self.progress.emit(f"[INFO] 正在切换至备用 PyPI 查询源重新检索: {backup_pypi}")
|
|
backup_pipreqs_cmd = [c if c != pypi_server else backup_pypi for c in pipreqs_cmd]
|
|
success_pipreqs = self.run_cmd(backup_pipreqs_cmd, timeout=120, silent_error=True)
|
|
|
|
if not success_pipreqs:
|
|
self.progress.emit("[INFO] 正在尝试利用兼容编码进行扫描...")
|
|
fallback_cmd = ["iso-8859-1" if c == "utf-8" else c for c in pipreqs_cmd]
|
|
success_pipreqs = self.run_cmd(fallback_cmd, timeout=120, silent_error=True)
|
|
if not success_pipreqs:
|
|
self.progress.emit("[WARN] pipreqs 已跳过深度扫描,将由 AST 扫描引擎补全依赖。")
|
|
|
|
if success_pipreqs and temp_pipreqs.exists():
|
|
self.run_pip_install(python_exe, ["-q", "-r", temp_pipreqs.as_posix()])
|
|
temp_pipreqs.unlink(missing_ok=True)
|
|
|
|
config = load_config()
|
|
known_mappings = DEFAULT_MAPPINGS.copy()
|
|
if 'Mappings' in config:
|
|
for k, v in config['Mappings'].items():
|
|
known_mappings[k] = v
|
|
|
|
local_files = {p.stem.lower() for p in script_dir.glob("*")}
|
|
ast_pkgs = [
|
|
known_mappings.get(m, m)
|
|
for m in script_imports
|
|
if m not in STD_LIBS and m.lower() not in local_files
|
|
]
|
|
|
|
if ast_pkgs:
|
|
self.progress.emit(f"[INFO] 依赖安装 [3/3]: 正在通过 AST 静态扫描提取隐式依赖...")
|
|
self.progress.emit(f"[INFO] 正在解析并安装隐式导入依赖: {', '.join(ast_pkgs)}")
|
|
if not self.run_pip_install(python_exe, ["-q"] + ast_pkgs):
|
|
self.progress.emit("[WARN] 批量依赖同步发生冲突,正在转换为顺序安全安装机制...")
|
|
for pkg in ast_pkgs:
|
|
self.run_pip_install(python_exe, ["-q", pkg])
|
|
|
|
if self._is_cancelled: return self.finished.emit(False, "[INFO] 构建已被终止。")
|
|
|
|
self.progress.emit(f"[INFO] 正在启动 {engine} 引擎,开始编译二进制文件...")
|
|
cmd = []
|
|
app_name = self.params['app_name']
|
|
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")
|
|
|
|
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', '').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()}")
|
|
else:
|
|
cmd.append("--noupx")
|
|
|
|
for imp in self.params.get('hidden_imports', '').split(','):
|
|
if imp.strip(): cmd.extend(["--hidden-import", imp.strip()])
|
|
|
|
for r_type, src, dst in self.params.get('add_data_list', []):
|
|
cmd.extend(["--add-data", f"{src}{os.pathsep}{dst}"])
|
|
|
|
for excl in self.params.get('exclude_modules', '').split(','):
|
|
if excl.strip(): cmd.extend(["--exclude-module", excl.strip()])
|
|
|
|
elif engine == "Nuitka":
|
|
self.temp_out_dir = Path(tempfile.mkdtemp(prefix="nuitka_out_")).resolve()
|
|
cmd = [
|
|
python_exe, "-m", "nuitka",
|
|
"--remove-output",
|
|
"--assume-yes-for-downloads",
|
|
f"--output-dir={self.temp_out_dir.as_posix()}",
|
|
f"--output-filename={app_name}{ext}"
|
|
]
|
|
|
|
cores = self.params.get('cpu_cores', os.cpu_count() or 2)
|
|
cmd.append(f"--jobs={cores}")
|
|
|
|
if self.params['onefile']:
|
|
cmd.append("--onefile")
|
|
else:
|
|
cmd.append("--standalone")
|
|
|
|
if self.params['noconsole']:
|
|
cmd.append("--windows-console-mode=disable")
|
|
if sys.platform == "darwin":
|
|
cmd.append("--macos-create-app-bundle")
|
|
else:
|
|
cmd.append("--windows-console-mode=force")
|
|
|
|
if icon_path:
|
|
if os.name == "nt":
|
|
cmd.append(f"--windows-icon-from-ico={icon_path}")
|
|
elif 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}")
|
|
|
|
imports_lower = {m.lower() for m in script_imports}
|
|
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 'tkinter' in imports_lower: cmd.append("--enable-plugin=tk-inter")
|
|
|
|
for imp in self.params.get('hidden_imports', '').split(','):
|
|
if imp.strip(): cmd.append(f"--include-module={imp.strip()}")
|
|
|
|
for r_type, src, dst in self.params.get('add_data_list', []):
|
|
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', '').split(','):
|
|
if excl.strip(): cmd.append(f"--nofollow-import-to={excl.strip()}")
|
|
|
|
if self.params.get('lite_mode'):
|
|
self.progress.emit("[INFO] 已开启精简模式,正在执行体积缩减策略...")
|
|
if not self.params.get('use_venv'):
|
|
self.progress.emit("[WARN] 强烈建议勾选 [虚拟环境] 以最大化精简效果。")
|
|
|
|
lite_excludes = ['pip', 'setuptools', 'distutils', 'wheel', 'pydoc']
|
|
|
|
for ex in lite_excludes:
|
|
if engine == "PyInstaller": cmd.append(f"--exclude-module={ex}")
|
|
elif engine == "Nuitka": cmd.append(f"--nofollow-import-to={ex}")
|
|
|
|
if engine == "Nuitka":
|
|
self.progress.emit("[INFO] 已启用 Nuitka 优化指令...")
|
|
cmd.append("--python-flag=-OO")
|
|
|
|
cmd.append(script_posix)
|
|
|
|
success = self.run_cmd(cmd, cwd=script_dir.as_posix())
|
|
if self._is_cancelled: return self.finished.emit(False, "[INFO] 构建已被终止。")
|
|
|
|
self.progress.emit("[INFO] 编译核心完成,正在提取并归档编译文件...")
|
|
|
|
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))
|
|
custom_out = self.params.get('custom_out_dir', '').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
|
|
|
|
final_out = final_out_dir / src_out.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: self.progress.emit(f"[ERROR] 产物移交失败,文件可能被系统进程占用或权限不足: {e}")
|
|
else: self.progress.emit(f"[ERROR] 未在临时构建目录中定位到有效可执行产物: {src_out}")
|
|
|
|
if success and final_out.exists():
|
|
self.progress.emit("[INFO] 正在校验输出文件并生成最终产物...")
|
|
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(f"[INFO] 编译日志已导出至: {log_file.as_posix()}")
|
|
except: pass
|
|
self.finished.emit(True, f"[SUCCESS] 编译已完成,输出路径: {final_out.resolve().as_posix()}")
|
|
else:
|
|
err_info = self.detect_python_syntax_errors()
|
|
if err_info["is_code_error"]:
|
|
msg = (
|
|
f"[语法异常] 源程序存在语法不合规或缩进异常错误!\n"
|
|
f" - 错误源文件: {err_info['file']}\n"
|
|
f" - 异常类型: {err_info['type']}\n"
|
|
f" - 异常位置: 第 {err_info['line']} 行附近\n"
|
|
f" - 错误描述: {err_info['desc']}\n"
|
|
f"\n提示: 此问题为源码逻辑本身错误。请在确保本地运行无误后,再次执行编译流程。"
|
|
)
|
|
else:
|
|
if self.params.get('concise_log', True) and self.all_raw_logs:
|
|
self.progress.emit("\n" + "!"*10 + " [诊断回溯: 以下为最后段编译运行日志汇总] " + "!"*10)
|
|
self.progress.emit('\n'.join(self.all_raw_logs[-100:]))
|
|
msg = "[FAILED] 编译异常中断,请参阅上方日志以定位构建异常。"
|
|
self.finished.emit(False, msg)
|
|
|
|
except Exception as e:
|
|
if self.params.get('concise_log', True) and self.all_raw_logs:
|
|
self.progress.emit("\n" + "!"*10 + " [系统诊断: 原始日志] " + "!"*10)
|
|
self.progress.emit('\n'.join(self.all_raw_logs[-100:]))
|
|
self.finished.emit(False, f"[ERROR] 构建流程发生致命异常: {str(e)}")
|
|
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] 正在释放空间,安全清除临时构建环境...")
|
|
|
|
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}.dist", 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"
|
|
self.init_style()
|
|
self.init_ui()
|
|
|
|
def init_style(self):
|
|
self.setWindowTitle(f"{__app_name__} {__version__}")
|
|
|
|
self.setMinimumSize(710, 620)
|
|
self.resize(680, 620)
|
|
|
|
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(15)
|
|
layout.setContentsMargins(20, 20, 20, 20)
|
|
|
|
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)
|
|
self.log = QTextEdit()
|
|
self.log.setReadOnly(True)
|
|
self.log.setFixedHeight(120)
|
|
log_lay.addWidget(self.log)
|
|
self.log_container.hide()
|
|
layout.addWidget(self.log_container)
|
|
|
|
btn_layout = QHBoxLayout()
|
|
btn_layout.setSpacing(12)
|
|
btn_layout.setContentsMargins(0, 5, 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.setToolTip("配置构建参数")
|
|
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)
|
|
|
|
self.status_bar = self.statusBar()
|
|
self.status_label = QLabel(" 状态: 准备就绪")
|
|
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 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_metrics = self.copyright_label.fontMetrics()
|
|
copyright_width = copyright_metrics.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 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.terminate()
|
|
self.analysis_thread.wait()
|
|
|
|
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.terminate()
|
|
self.settings_panel.scanner_thread.wait()
|
|
|
|
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_left.setToolTip("显示/隐藏执行日志")
|
|
|
|
self.btn_main.setText(" 开始构建")
|
|
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_left.setToolTip("显示/隐藏执行日志")
|
|
|
|
self.btn_main.setText(" 停止构建")
|
|
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"))
|
|
self.btn_left.setToolTip("重置工作区")
|
|
|
|
if state == "done":
|
|
self.btn_main.setText(" 打开输出目录")
|
|
self.btn_main.setIcon(get_svg_icon('folder', "white"))
|
|
self.btn_main.setStyleSheet(self.success_btn_style)
|
|
else:
|
|
self.btn_main.setText(" 重新构建")
|
|
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_all()
|
|
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._animate_switch(self.main_panel)
|
|
|
|
def save_settings_and_return(self):
|
|
self.settings_panel.save_to_config()
|
|
self.show_main()
|
|
|
|
def _animate_switch(self, target_widget):
|
|
self.anim = QPropertyAnimation(self.stacked_layout.currentWidget(), b"geometry")
|
|
self.anim.setDuration(250)
|
|
self.anim.setEasingCurve(QEasingCurve.Type.OutCubic)
|
|
self.anim.setStartValue(self.stacked_layout.currentWidget().geometry())
|
|
self.stacked_layout.setCurrentWidget(target_widget)
|
|
self.anim.setEndValue(target_widget.geometry())
|
|
self.anim.start()
|
|
|
|
def on_script_selected(self, path):
|
|
path = Path(path).resolve().as_posix()
|
|
|
|
if is_cloud_locked(path):
|
|
QMessageBox.critical(self, "加载失败", "目标脚本处于锁定状态,请稍后重试。")
|
|
return
|
|
|
|
if self.script_path and self.script_path != path:
|
|
self.settings_panel.icon_edit.clear()
|
|
self.settings_panel.hidden_edit.clear()
|
|
self.settings_panel.add_data_list.clear()
|
|
|
|
self.script_path = path
|
|
|
|
self.drop_area.set_loading(Path(path).name)
|
|
self.set_status(f" 状态: 正在解析源文件 {Path(path).name} ...")
|
|
|
|
if self.analysis_thread and self.analysis_thread.isRunning():
|
|
self.analysis_thread.terminate()
|
|
self.analysis_thread.wait()
|
|
|
|
self.analysis_thread = ScriptAnalysisThread(self.script_path)
|
|
self.analysis_thread.analysis_done.connect(self.on_analysis_finished)
|
|
self.analysis_thread.start()
|
|
|
|
def on_analysis_finished(self, app_name, version, author, desc, script_imports):
|
|
path = self.script_path
|
|
if not path: return
|
|
|
|
if version:
|
|
self.settings_panel.ver_ver.setText(version)
|
|
else:
|
|
self.settings_panel.ver_ver.setText("1.0.0")
|
|
|
|
self.settings_panel.ver_comp.setText(author)
|
|
self.settings_panel.ver_desc.setText(desc)
|
|
|
|
gui_libs = {'pyqt5', 'pyqt6', 'pyside2', 'pyside6', 'tkinter', 'wx', 'kivy', 'libavg'}
|
|
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():
|
|
preferred_extensions = [".ico", ".icns", ".png", ".svg"]
|
|
if sys.platform == "darwin":
|
|
preferred_extensions = [".icns", ".png", ".svg", ".ico"]
|
|
elif sys.platform == "linux":
|
|
preferred_extensions = [".png", ".svg", ".ico", ".icns"]
|
|
|
|
found = False
|
|
for ext in preferred_extensions:
|
|
for name in ["icon", "logo", "ICON", "LOGO"]:
|
|
trial = script_dir / f"{name}{ext}"
|
|
if trial.exists():
|
|
auto_icon = trial
|
|
|
|
self.settings_panel.icon_edit.setText(trial.resolve().as_posix())
|
|
|
|
found = True
|
|
break
|
|
if found: break
|
|
|
|
self.drop_area.set_success(Path(path).name, custom_icon_path=auto_icon)
|
|
|
|
status_suffix = ",已配置为控制台模式" if not has_gui else ",已配置为无控制台模式"
|
|
self.set_status(f" 状态: 已载入源文件 {Path(path).name}{status_suffix}")
|
|
|
|
if not self.log_container.isVisible(): self.toggle_log()
|
|
self.log.clear()
|
|
self.append_log(f"[INFO] 载入应用入口文件成功: {path}")
|
|
if not has_gui:
|
|
self.append_log("[INFO] 静态扫描分析:未检测到图形框架。运行属性已自动配置为保留控制台。")
|
|
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(" 状态: 构建已终止")
|
|
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():
|
|
QMessageBox.warning(self, "构建约束", "请先加载有效的 Python 源代码文件!")
|
|
return
|
|
|
|
sp = self.settings_panel
|
|
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()
|
|
temp_icon_file = None
|
|
if icon_path_str and Path(icon_path_str).exists():
|
|
icon_path = Path(icon_path_str)
|
|
needed_ext = "ico" if os.name == "nt" else ("icns" if sys.platform == "darwin" else "png")
|
|
|
|
if icon_path.suffix.lower() != f".{needed_ext}":
|
|
temp_ico_name = f"qpypack_temp_icon_{int(time.time())}.{needed_ext}"
|
|
temp_ico = Path(tempfile.gettempdir()) / temp_ico_name
|
|
|
|
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:
|
|
self.append_log(f"[WARN] 图像处理模块异常:目标图标转换失败,尝试使用源格式直接转交编译引擎。")
|
|
|
|
add_data_items = []
|
|
for i in range(sp.add_data_list.count()):
|
|
add_data_items.append(sp.add_data_list.item(i).data(Qt.ItemDataRole.UserRole))
|
|
|
|
main_pip = sp._get_url_from_combo(sp.pip_source_combo)
|
|
backup_pip = sp._get_url_from_combo(sp.pip_backup_combo)
|
|
|
|
params = {
|
|
'engine': engine,
|
|
'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(),
|
|
'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() if engine == "PyInstaller" else False,
|
|
'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(),
|
|
'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.clear()
|
|
if not self.log_container.isVisible(): self.toggle_log()
|
|
|
|
self.thread = PackingThread(params)
|
|
self.thread.progress.connect(self.append_log)
|
|
self.thread.finished.connect(self.on_pack_finished)
|
|
self.thread.start()
|
|
|
|
self.set_status(f" 状态: 正在通过 {engine} 构建...")
|
|
self.update_ui_state("building")
|
|
self.drop_area.start_build_anim()
|
|
|
|
def on_pack_finished(self, success, msg):
|
|
self.append_log("\n" + "━"*50 + "\n" + msg)
|
|
self.drop_area.stop_build_anim()
|
|
|
|
if self.settings_panel.sound_notify_check.isChecked():
|
|
play_alert(success)
|
|
|
|
if success:
|
|
icon_path = self.settings_panel.icon_edit.text().strip()
|
|
self.drop_area.show_success(icon_path)
|
|
self.set_status(" 状态: 构建完成")
|
|
self.update_ui_state("done")
|
|
else:
|
|
self.drop_area.show_failure()
|
|
self.set_status(" 状态: 构建失败")
|
|
self.update_ui_state("failed")
|
|
|
|
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:
|
|
if os.name == 'nt': os.startfile(target)
|
|
elif sys.platform == 'darwin': subprocess.call(('open', target.as_posix()))
|
|
else: subprocess.call(('xdg-open', target.as_posix()))
|
|
except: pass
|
|
|
|
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.add_data_list.clear()
|
|
self.log.clear()
|
|
if self.log_container.isVisible(): self.toggle_log()
|
|
self.drop_area.reset()
|
|
self.set_status(" 状态: 工作区已重置")
|
|
self.update_ui_state("idle")
|
|
|
|
def append_log(self, msg):
|
|
self.log.append(msg)
|
|
self.log.ensureCursorVisible()
|
|
|
|
for line in msg.split('\n'):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
if line.startswith(("[INFO]", "[WARN]", "[SUCCESS]", "[FAILED]", "[ERROR]")):
|
|
clean_text = line
|
|
for prefix in ("[INFO]", "[WARN]", "[SUCCESS]", "[FAILED]", "[ERROR]"):
|
|
if clean_text.startswith(prefix):
|
|
clean_text = clean_text[len(prefix):].strip()
|
|
break
|
|
|
|
if self.current_state == "building" and clean_text:
|
|
if len(clean_text) > 35:
|
|
clean_text = clean_text[:32] + "..."
|
|
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; }")
|
|
|
|
def main():
|
|
try:
|
|
QApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
|
|
except Exception:
|
|
pass
|
|
|
|
app = QApplication(sys.argv)
|
|
app.setStyle("Fusion")
|
|
app.setFont(QFont("Microsoft YaHei", 9))
|
|
win = MainWindow()
|
|
win.show()
|
|
sys.exit(app.exec())
|
|
|
|
if __name__ == "__main__":
|
|
main() |