Files
QPyPack/main.py
T

1410 lines
66 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PyPack 2.0 - 自动化构建工具
支持引擎: PyInstaller / Nuitka / cx_Freeze
"""
import sys
import os
import shutil
import subprocess
import tempfile
import re
import time
import stat
import ast
import locale
import math
from pathlib import Path
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 PyQt5.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)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QPropertyAnimation, QEasingCurve, QParallelAnimationGroup, QFileInfo, QVariantAnimation, QTimer, QPointF, QRectF, QRect
from PyQt5.QtGui import QFont, QDragEnterEvent, QDropEvent, QTextCursor, QIcon, QPixmap, QPainter, QColor, QPen
from PyQt5.QtSvg import QSvgRenderer
# ======================== Material Design 内置 SVG 引擎 ========================
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.54 c0.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.96 c0.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.74 c0,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'
}
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" width="{size}" height="{size}"><path fill="{color}" d="{path_data}"/></svg>'
renderer = QSvgRenderer(svg_str.encode('utf-8'))
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
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', 'multprocessing', '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'}
if sys.version_info >= (3, 10): libs.update(sys.stdlib_module_names)
return libs
STD_LIBS = get_stdlib_names()
KNOWN_MAPPINGS = {
'win32com': 'pywin32', 'win32api': 'pywin32', 'win32con': 'pywin32', 'win32gui': 'pywin32',
'cv2': 'opencv-python', 'PIL': 'pillow', 'bs4': 'beautifulsoup4', 'sklearn': 'scikit-learn',
'yaml': 'pyyaml', 'fitz': 'pymupdf', 'dotenv': 'python-dotenv'
}
def get_resource_path(relative_path):
if hasattr(sys, '_MEIPASS'):
return os.path.join(sys._MEIPASS, relative_path)
return os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path)
def get_python_executable():
if getattr(sys, 'frozen', False): return shutil.which("python") or shutil.which("python3") or "python"
return sys.executable
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
# ======================== 界面动效组件 ========================
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.OutCubic)
self.op_anim = QPropertyAnimation(self.opacity_effect, b"opacity")
self.op_anim.setDuration(200)
self.op_anim.setEasingCurve(QEasingCurve.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) # ~60fps
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.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.Antialiasing)
painter.setRenderHint(QPainter.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.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.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.KeepAspectRatio, Qt.SmoothTransformation)
painter.drawPixmap(pix_rect, scaled_pix)
painter.end()
class DropArea(QFrame):
fileDropped = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("DropArea")
self.setAcceptDrops(True)
self.setFrameStyle(QFrame.NoFrame)
self.setCursor(Qt.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("将脚本(.py)拖拽至此处\n或 点击浏览")
self.label.setAlignment(Qt.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.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() and event.mimeData().urls()[0].toLocalFile().lower().endswith('.py'):
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 and urls[0].toLocalFile().lower().endswith('.py'):
self.fileDropped.emit(urls[0].toLocalFile())
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
fp, _ = QFileDialog.getOpenFileName(self, "选择 Python 源代码文件", "", "Python Scripts (*.py)")
if fp: self.fileDropped.emit(fp)
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)
# 使用 set_file_pixmap,避免覆盖 base_pixmap 导致复位失败
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.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("将脚本(.py)拖拽至此处\n或 点击浏览")
self.label.setStyleSheet("QLabel { background: transparent; color: #5F6368; font-size: 16px; font-weight: bold; border: none; }")
self.sub_label.setText("智能解析工程依赖、附件及隐藏模块")
# ======================== 设置面板 ========================
class SettingsPanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.parent_win = parent
self.setStyleSheet("""
SettingsPanel { background-color: white; }
QLabel { color: #3c4043; font-size: 13px; font-weight: bold; background: transparent; }
QComboBox, QLineEdit { color: #3c4043; font-size: 13px; padding: 6px 10px; border: 1px solid #dadce0; border-radius: 6px; background: #fff; min-height: 24px; }
QComboBox:hover, QLineEdit:hover { border-color: #bdc1c6; }
QComboBox:focus, QLineEdit:focus { border-color: #1A73E8; }
QGroupBox { border: 1px solid #e8eaed; border-radius: 8px; margin-top: 15px; padding-top: 15px; background-color: #f8f9fa; }
QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; left: 15px; top: 0px; color: #1A73E8; font-weight: bold; font-size: 13px; padding: 0 5px; background: transparent; }
QCheckBox { font-size: 13px; color: #3c4043; spacing: 8px; background: transparent; }
QCheckBox::indicator { width: 16px; height: 16px; border: 1px solid #bdc1c6; border-radius: 4px; background: white; }
QCheckBox::indicator:checked { background: #1A73E8; border-color: #1A73E8; 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>"); }
QPushButton.ToolBtn { padding: 5px 12px; border: 1px solid #dadce0; border-radius: 6px; background: #f1f3f4; font-size: 12px; font-weight: bold; color: #5f6368;}
QPushButton.ToolBtn:hover { background: #e8eaed; color: #202124; }
QTabWidget::pane { border: 1px solid #e8eaed; border-radius: 8px; background: white; top: -1px; }
QTabBar::tab { background: #f1f3f4; border: 1px solid #e8eaed; padding: 10px 20px; margin-right: 4px; border-top-left-radius: 8px; border-top-right-radius: 8px; color: #5f6368; font-weight: bold; font-size: 13px; }
QTabBar::tab:selected { background: white; border-bottom-color: white; color: #1A73E8; }
QTabBar::tab:hover:!selected { background: #e8eaed; }
""")
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(15)
layout.setContentsMargins(20, 20, 20, 20)
self.tabs = QTabWidget()
self.tab_basic = QWidget()
self.tab_env = QWidget()
self.tab_adv = QWidget()
self.tab_meta = QWidget()
self.tabs.addTab(self.tab_basic, get_svg_icon('package', "#1A73E8"), " 基础打包")
self.tabs.addTab(self.tab_env, get_svg_icon('python', "#1A73E8"), " 环境与依赖")
self.tabs.addTab(self.tab_adv, get_svg_icon('settings', "#1A73E8"), " 高级选项")
self.tabs.addTab(self.tab_meta, get_svg_icon('info', "#1A73E8"), " 版本信息")
self.build_basic_tab()
self.build_env_tab()
self.build_adv_tab()
self.build_meta_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.Expanding, QSizePolicy.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.show_main)
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 build_basic_tab(self):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
scroll.setStyleSheet("background: transparent;")
content = QWidget()
content.setStyleSheet("background: white;")
lay = QVBoxLayout(content)
lay.setContentsMargins(15, 15, 15, 15)
grp_core = QGroupBox("核心参数")
form_core = QFormLayout(grp_core)
form_core.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
form_core.setContentsMargins(15, 20, 15, 15)
form_core.setVerticalSpacing(12)
self.engine_combo = QComboBox()
self.engine_combo.addItems(["PyInstaller", "Nuitka", "cx_Freeze"])
self.engine_combo.currentIndexChanged.connect(self.on_engine_changed)
form_core.addRow("构建引擎:", self.engine_combo)
self.name_edit = QLineEdit()
self.name_edit.setPlaceholderText("留空则与入口脚本同名")
form_core.addRow("输出名称:", self.name_edit)
self.icon_edit = QLineEdit()
self.icon_preview = QLabel()
self.icon_preview.setFixedSize(28, 28)
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)
h_icon = QHBoxLayout()
h_icon.addWidget(self.icon_edit)
h_icon.addWidget(self.icon_preview)
h_icon.addWidget(btn_icon)
form_core.addRow("应用图标:", h_icon)
lay.addWidget(grp_core)
grp_mode = QGroupBox("输出模式与行为")
grid_mode = QGridLayout(grp_mode)
grid_mode.setContentsMargins(15, 20, 15, 15)
grid_mode.setVerticalSpacing(15)
self.onefile_check = QCheckBox("单文件模式 (OneFile)")
self.onefile_check.setChecked(True)
self.noconsole_check = QCheckBox("隐藏控制台 (GUI模式)")
self.noconsole_check.setChecked(True)
self.clean_all_check = QCheckBox("编译后自动清理缓存")
self.clean_all_check.setChecked(True)
self.auto_icon_check = QCheckBox("自动匹配同目录图标")
self.auto_icon_check.setChecked(True)
grid_mode.addWidget(self.onefile_check, 0, 0)
grid_mode.addWidget(self.noconsole_check, 0, 1)
grid_mode.addWidget(self.clean_all_check, 1, 0)
grid_mode.addWidget(self.auto_icon_check, 1, 1)
lay.addWidget(grp_mode)
lay.addStretch()
scroll.setWidget(content)
main_lay = QVBoxLayout(self.tab_basic)
main_lay.setContentsMargins(0,0,0,0)
main_lay.addWidget(scroll)
def build_env_tab(self):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
scroll.setStyleSheet("background: transparent;")
content = QWidget()
content.setStyleSheet("background: white;")
lay = QVBoxLayout(content)
lay.setContentsMargins(15, 15, 15, 15)
grp_env = QGroupBox("隔离与网络")
form_env = QFormLayout(grp_env)
form_env.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
form_env.setContentsMargins(15, 20, 15, 15)
form_env.setVerticalSpacing(12)
self.pip_source_edit = QLineEdit("https://pypi.tuna.tsinghua.edu.cn/simple")
form_env.addRow("PIP 镜像:", self.pip_source_edit)
self.venv_check = QCheckBox("启用独立虚拟环境 (推荐,保证依赖纯净)")
self.venv_check.setChecked(True)
form_env.addRow("", self.venv_check)
lay.addWidget(grp_env)
grp_dep = QGroupBox("包与依赖分析机制")
dep_lay = QVBoxLayout(grp_dep)
dep_lay.setContentsMargins(15, 20, 15, 15)
dep_lay.setSpacing(15)
grid_dep = QGridLayout()
self.reqs_check = QCheckBox("优先读取 requirements.txt")
self.reqs_check.setChecked(True)
self.pipreqs_check = QCheckBox("启用 pipreqs 深度代码分析")
self.pipreqs_check.setChecked(True)
grid_dep.addWidget(self.reqs_check, 0, 0)
grid_dep.addWidget(self.pipreqs_check, 0, 1)
dep_lay.addLayout(grid_dep)
form_dep = QFormLayout()
form_dep.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
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)
h_hid = QHBoxLayout()
h_hid.addWidget(self.hidden_edit)
h_hid.addWidget(btn_scan)
form_dep.addRow("隐式依赖:", h_hid)
dep_lay.addLayout(form_dep)
lay.addWidget(grp_dep)
lay.addStretch()
scroll.setWidget(content)
main_lay = QVBoxLayout(self.tab_env)
main_lay.setContentsMargins(0,0,0,0)
main_lay.addWidget(scroll)
def build_adv_tab(self):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
scroll.setStyleSheet("background: transparent;")
content = QWidget()
content.setStyleSheet("background: white;")
lay = QVBoxLayout(content)
lay.setContentsMargins(15, 15, 15, 15)
grp_res = QGroupBox("附加资源与优化")
form_res = QFormLayout(grp_res)
form_res.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
form_res.setContentsMargins(15, 20, 15, 15)
form_res.setVerticalSpacing(12)
self.add_data_edit = QLineEdit()
self.add_data_edit.setPlaceholderText("例如: data/*:data (跨平台自动处理路径)")
btn_add = QPushButton("添加...")
btn_add.setProperty("class", "ToolBtn")
btn_add.clicked.connect(self.add_resource)
h_res = QHBoxLayout()
h_res.addWidget(self.add_data_edit)
h_res.addWidget(btn_add)
form_res.addRow("附加资源:", h_res)
self.upx_check = QCheckBox("启用 UPX 压缩优化 (仅 PyInstaller)")
form_res.addRow("", self.upx_check)
lay.addWidget(grp_res)
lay.addStretch()
scroll.setWidget(content)
main_lay = QVBoxLayout(self.tab_adv)
main_lay.setContentsMargins(0,0,0,0)
main_lay.addWidget(scroll)
def build_meta_tab(self):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
scroll.setStyleSheet("background: transparent;")
content = QWidget()
content.setStyleSheet("background: white;")
lay = QVBoxLayout(content)
lay.setContentsMargins(15, 15, 15, 15)
self.grp_meta = QGroupBox("版本元数据 (Metadata)")
meta_form = QFormLayout(self.grp_meta)
meta_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
meta_form.setContentsMargins(15, 20, 15, 15)
meta_form.setVerticalSpacing(12)
self.ver_ver = QLineEdit("1.0.0")
self.ver_comp = QLineEdit("My Studio")
self.ver_desc = QLineEdit("Python Executable")
meta_form.addRow("版本序列:", self.ver_ver)
meta_form.addRow("发行公司:", self.ver_comp)
meta_form.addRow("文件描述:", self.ver_desc)
lay.addWidget(self.grp_meta)
tip = QLabel("注:仅 PyInstaller 和 Nuitka 引擎支持写入 EXE 版本信息。")
tip.setStyleSheet("color: #9AA0A6; font-size: 12px; font-weight: normal; background: transparent;")
lay.addWidget(tip)
lay.addStretch()
scroll.setWidget(content)
main_lay = QVBoxLayout(self.tab_meta)
main_lay.setContentsMargins(0,0,0,0)
main_lay.addWidget(scroll)
def on_engine_changed(self):
engine = self.engine_combo.currentText()
self.onefile_check.setVisible(engine in ("PyInstaller", "Nuitka"))
self.upx_check.setVisible(engine == "PyInstaller")
self.tabs.setTabEnabled(3, engine != "cx_Freeze")
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.KeepAspectRatio, Qt.SmoothTransformation))
return
self.icon_preview.clear()
def select_icon(self):
p, _ = QFileDialog.getOpenFileName(self, "选择图标", "", "Icon Files (*.ico)")
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, "提示", "请先在主界面加载脚本!")
try:
raw = Path(script_path).read_bytes()
if b"__CLOUDSYNC_ENC__" in raw[:1024]: return QMessageBox.critical(self, "错误", "脚本被云盘加密,请解密!")
try: code = raw.decode('utf-8-sig')
except: code = raw.decode('gbk', errors='ignore')
hidden = set()
try:
tree = ast.parse(code)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names: hidden.add(alias.name.split('.')[0])
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
hidden.add(node.module.split('.')[0])
except:
hidden = set(re.findall(r'^\s*(?:from|import)\s+([a-zA-Z0-9_]+)', code, re.M))
hidden = [m for m in hidden if m not in STD_LIBS]
self.hidden_edit.setText(','.join(hidden))
QMessageBox.information(self, "扫描完成", f"捕获到 {len(hidden)} 项依赖。")
except Exception as e: QMessageBox.critical(self, "错误", f"扫描失败: {e}")
def add_resource(self):
choice = QMessageBox.question(self, "添加资源", "Yes=文件, No=文件夹", QMessageBox.Yes | QMessageBox.No)
if choice == QMessageBox.Yes:
files, _ = QFileDialog.getOpenFileNames(self, "选择文件", "", "All Files (*)")
for f in files:
f = Path(f).resolve().as_posix()
default_dest = Path(f).name
dest, ok = QInputDialog.getText(self, "相对路径", f"目标位置:", text=default_dest)
if ok:
dest = dest.strip().replace('\\', '/') if dest else default_dest
curr = self.add_data_edit.text().strip()
self.add_data_edit.setText(f"{curr}, {f}:{dest}" if curr else f"{f}:{dest}")
else:
folder = QFileDialog.getExistingDirectory(self, "选择文件夹")
if folder:
folder = Path(folder).resolve().as_posix()
default_dest = Path(folder).name
dest, ok = QInputDialog.getText(self, "相对路径", f"目标位置:", text=default_dest)
if ok:
dest = dest.strip().replace('\\', '/') if dest else default_dest
curr = self.add_data_edit.text().strip()
self.add_data_edit.setText(f"{curr}, {folder}:{dest}" if curr else f"{folder}:{dest}")
# ======================== 核心构建引擎 ========================
class PackingThread(QThread):
progress = pyqtSignal(str)
finished = pyqtSignal(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
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)], capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
else: self.process.kill()
except: pass
def run_cmd(self, cmd, cwd=None):
if self._is_cancelled: return False
try:
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.STDOUT, "cwd": cwd, "text": True, "errors": "replace"}
if os.name == 'nt': kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
self.process = subprocess.Popen(cmd, **kwargs)
for line in self.process.stdout:
if self._is_cancelled:
self.process.terminate()
return False
self.progress.emit(line.strip())
self.process.wait()
return self.process.returncode == 0
except Exception as e:
self.progress.emit(f"[系统异常] 进程执行失败: {e}")
return False
def sanitize_script(self, orig_path: Path):
try:
raw = orig_path.read_bytes()
if b"__CLOUDSYNC_ENC__" in raw[:1024]:
return None, False, "目标脚本被云盘锁定加密!"
except Exception:
pass
# 移除了所有重命名及生成 pypack_temp_ 前缀文件的死代码
# 直接透传原始文件可防止 Nuitka 生成带有临时前缀的 DLL 触发 Windows 智能应用控制的启发式拦截
return orig_path, False, ""
def run(self):
os.environ["NUITKA_ACCEPT_DOWNLOADS"] = "yes"
engine = self.params['engine']
pip_idx = self.params.get('pip_index_url', '').strip()
is_temp = False
build_script_path = None
ext = ".exe" if os.name == "nt" else ""
try:
self.progress.emit("[清理] 初始化构建管线...")
robust_rmtree(Path.cwd() / "build")
robust_rmtree(Path.cwd() / "dist")
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"[安全拦截] {err_msg}")
script_posix = build_script_path.as_posix()
if self.params['use_venv']:
self.progress.emit("[环境] 初始化虚拟隔离沙盒...")
self.venv_dir = Path(tempfile.mkdtemp(prefix="pypack_env_")).resolve()
if not self.run_cmd([get_python_executable(), "-m", "venv", self.venv_dir.as_posix()]):
return self.finished.emit(False, "虚拟环境初始化失败。")
python_exe = (self.venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).as_posix()
else: python_exe = Path(get_python_executable()).resolve().as_posix()
pip_args = ["-i", pip_idx] if pip_idx else []
engine_pkg = "nuitka[onefile]" if engine == "Nuitka" else "cx_Freeze" if engine == "cx_Freeze" else "pyinstaller"
self.progress.emit(f"[环境] 下载核心组件...")
core_pkgs = [engine_pkg]
if engine == "PyInstaller": core_pkgs.append("pillow")
self.run_cmd([python_exe, "-m", "pip", "install", "-q"] + core_pkgs + pip_args)
req_success = False
if self.params.get('use_reqs'):
req_file = script_dir / "requirements.txt"
if req_file.exists():
self.progress.emit("[依赖] 部署 requirements.txt...")
try:
raw_req = req_file.read_bytes()
if b"__CLOUDSYNC_ENC__" in raw_req[:1024]: raise ValueError("文件被锁定")
try: req_content = raw_req.decode('utf-8-sig')
except: req_content = raw_req.decode(locale.getpreferredencoding(), errors='ignore')
temp_req = script_dir / "pypack_temp_reqs.txt"
temp_req.write_text(req_content, encoding='utf-8')
if self.run_cmd([python_exe, "-m", "pip", "install", "-q", "-r", temp_req.as_posix()] + pip_args):
req_success = True
temp_req.unlink(missing_ok=True)
except Exception as e: self.progress.emit(f"[警告] {e}")
if self.params.get('use_pipreqs') and not req_success:
self.progress.emit("[依赖] 执行 pipreqs 深度推导...")
self.run_cmd([python_exe, "-m", "pip", "install", "pipreqs", "-q"] + pip_args)
temp_pipreqs = script_dir / "pypack_pipreqs_out.txt"
self.run_cmd([python_exe, "-m", "pipreqs.pipreqs", script_dir.as_posix(), "--encoding", "utf-8", "--force", "--savepath", temp_pipreqs.as_posix()])
if temp_pipreqs.exists():
if self.run_cmd([python_exe, "-m", "pip", "install", "-q", "-r", temp_pipreqs.as_posix()] + pip_args): req_success = True
temp_pipreqs.unlink(missing_ok=True)
if not req_success and (self.params.get('use_reqs') or self.params.get('use_pipreqs')):
self.progress.emit("[依赖] 执行 AST 语法树降级解析...")
try:
code = build_script_path.read_text(encoding='utf-8', errors='ignore')
third_party = set()
try:
for node in ast.walk(ast.parse(code)):
if isinstance(node, ast.Import):
for alias in node.names: third_party.add(alias.name.split('.')[0])
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
third_party.add(node.module.split('.')[0])
except: third_party = set(re.findall(r'^\s*(?:from|import)\s+([a-zA-Z0-9_]+)', code, re.M))
pkgs_to_install = [KNOWN_MAPPINGS.get(m, m) for m in third_party if m not in STD_LIBS]
if pkgs_to_install:
self.progress.emit(f"[依赖] 同步外部模块: {', '.join(pkgs_to_install)}")
self.run_cmd([python_exe, "-m", "pip", "install", "-q"] + pkgs_to_install + pip_args)
except Exception as e: self.progress.emit(f"[异常] {e}")
if self._is_cancelled: return self.finished.emit(False, "构建已被中断。")
self.progress.emit(f"[编译] 启动 {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="pypack_build_")).resolve()
cmd = [python_exe, "-m", "PyInstaller", "--clean", "-y", f"--workpath={self.temp_workpath.as_posix()}", f"--name={app_name}"]
if self.params.get('onefile'): cmd.append("--onefile")
else: cmd.append("--onedir")
if self.params.get('noconsole'): cmd.append("--noconsole")
if icon_path:
cmd.extend(["--icon", icon_path])
sep = ';' if os.name == 'nt' else ':'
cmd.extend(["--add-data", f"{icon_path}{sep}."])
if self.params.get('version_file') and os.name == "nt": cmd.extend(["--version-file", self.params['version_file']])
if self.params.get('upx'):
upx_dir = (Path.cwd() / "upx").resolve()
if upx_dir.exists(): cmd.append(f"--upx-dir={upx_dir.as_posix()}")
for imp in self.params.get('hidden_imports', '').split(','):
if imp.strip(): cmd.extend(["--hidden-import", imp.strip()])
for d in self.params.get('add_data', '').split(','):
d = d.strip()
if d:
d = d.replace('\\', '/')
if os.name == 'nt' and ':' in d and ';' not in d:
parts = d.rsplit(':', 1)
if len(parts) == 2: d = f"{parts[0]};{parts[1]}"
cmd.extend(["--add-data", d])
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}"]
cmd.append(f"--jobs={os.cpu_count() or 4}")
if self.params.get('onefile'): cmd.append("--onefile")
else: cmd.append("--standalone")
if self.params.get('noconsole'): cmd.append("--windows-console-mode=disable")
if icon_path:
if os.name == "nt":
cmd.append(f"--windows-icon-from-ico={icon_path}")
elif sys.platform == "darwin" and icon_path.endswith(".icns"):
cmd.append(f"--macos-app-icon={icon_path}")
# 使用精确的文件名作为目标部署位置,以避开 Nuitka '.' 校验致命错误
cmd.append(f"--include-data-files={icon_path}={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"--product-name={self.params['ver_desc']}")
if self.params.get('ver_ver'): cmd.append(f"--file-version={self.params['ver_ver']}")
try:
if 'PyQt5' in build_script_path.read_text(encoding='utf-8', errors='ignore'):
cmd.append("--enable-plugin=pyqt5")
except: pass
for imp in self.params.get('hidden_imports', '').split(','):
if imp.strip(): cmd.append(f"--include-module={imp.strip()}")
for d in self.params.get('add_data', '').split(','):
d = d.strip()
if d:
d = d.replace('\\', '/')
parts = d.split(':', 1)
if len(parts) == 2:
cmd.append(f"--include-data-files={parts[0]}={parts[1]}")
elif engine == "cx_Freeze":
cmd = [python_exe, "-m", "cx_Freeze", script_posix, f"--target-dir=dist/{app_name}", f"--target-name={app_name}{ext}"]
if self.params.get('noconsole'): cmd.append("--base=Win32GUI")
else: cmd.append("--base=Console")
add_data_list = []
if icon_path:
cmd.extend(["--icon", icon_path])
add_data_list.append(f"{icon_path}={Path(icon_path).name}")
for imp in self.params.get('hidden_imports', '').split(','):
if imp.strip(): cmd.extend(["--include-modules", imp.strip()])
for d in self.params.get('add_data', '').split(','):
d = d.strip()
if d:
d = d.replace('\\', '/')
parts = d.split(':', 1)
if len(parts) == 2: add_data_list.append(f"{parts[0]}={parts[1]}")
if add_data_list: cmd.append(f"--include-files={','.join(add_data_list)}")
if engine in ["PyInstaller", "Nuitka"]: cmd.append(script_posix)
success = self.run_cmd(cmd, cwd=Path.cwd().resolve().as_posix())
if self._is_cancelled: return self.finished.emit(False, "构建已被中断。")
self.progress.emit("[收尾] 归档可执行文件...")
cwd = Path.cwd().resolve()
if engine == "PyInstaller": src_out = cwd / "dist" / (f"{app_name}{ext}" if self.params['onefile'] else app_name)
elif engine == "Nuitka": src_out = self.temp_out_dir / (f"{app_name}{ext}" if self.params['onefile'] else f"{app_name}.dist")
elif engine == "cx_Freeze": src_out = cwd / "dist" / app_name
final_out = script_dir / src_out.name
if success and src_out.exists():
try:
if final_out.exists():
if final_out.is_dir(): shutil.rmtree(final_out, ignore_errors=True)
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"[归档异常] 程序被占用: {e}")
else: self.progress.emit(f"[错误] 未找到程序: {src_out}")
if success and final_out.exists():
self.progress.emit("[收尾] 执行收尾指令...")
self.finished.emit(True, f"[构建成功] 程序路径: {final_out.resolve().as_posix()}")
else:
self.finished.emit(False, "[构建失败] 请检查日志。")
except Exception as e:
self.finished.emit(False, f"[系统异常] {str(e)}")
finally:
if is_temp and build_script_path and build_script_path.exists():
try: build_script_path.unlink()
except: pass
if self.params.get('clean_all'):
self.progress.emit("[清理] 抹除环境与沙盒...")
for p in [self.venv_dir, self.temp_workpath, self.temp_out_dir]:
if p and p.exists(): robust_rmtree(p)
cwd = Path.cwd().resolve()
app_name = self.params.get('app_name', 'app')
robust_rmtree(cwd / "dist")
for p in ["build", "__pycache__", f"{app_name}.build", f"{app_name}.dist", f"{app_name}.onefile-build"]:
robust_rmtree(cwd / p)
Path(cwd / f"{app_name}.spec").unlink(missing_ok=True)
if self.params.get('version_file'): Path(self.params['version_file']).unlink(missing_ok=True)
# ======================== 界面主窗口 ========================
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.script_path = ""
self.thread = None
self.current_state = "idle"
self.init_style()
self.init_ui()
def init_style(self):
self.setWindowTitle("PyPack 2.0")
self.setMinimumSize(580, 520)
self.resize(600, 560)
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.Expanding, QSizePolicy.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.statusBar = self.statusBar()
self.status_label = QLabel(" 状态: 就绪")
self.statusBar.addWidget(self.status_label)
self.update_ui_state("idle")
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 _animate_switch(self, target_widget):
self.anim = QPropertyAnimation(self.stacked_layout.currentWidget(), b"geometry")
self.anim.setDuration(250)
self.anim.setEasingCurve(QEasingCurve.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()
try:
if b"__CLOUDSYNC_ENC__" in Path(path).read_bytes()[:1024]:
QMessageBox.critical(self, "加载失败", "脚本已被锁定!")
return
except: pass
self.script_path = path
self.settings_panel.name_edit.setText(Path(path).stem)
script_dir = Path(path).parent
auto_icon = None
if self.settings_panel.auto_icon_check.isChecked():
for name in ["ICON.ICO", "icon.ico", "logo.ico", "icon.svg", "logo.svg"]:
trial = script_dir / name
if trial.exists():
auto_icon = trial
curr = self.settings_panel.icon_edit.text()
if not curr or not Path(curr).exists():
self.settings_panel.icon_edit.setText(trial.resolve().as_posix())
break
self.drop_area.set_success(Path(path).name, custom_icon_path=auto_icon)
self.status_label.setText(f" 状态: 已锁定源文件 {Path(path).name}")
if not self.log_container.isVisible(): self.toggle_log()
self.log.clear()
self.append_log(f"已识别脚本: {path}")
self.update_ui_state("ready")
def cancel_pack(self):
if self.thread and self.thread.isRunning():
self.thread.cancel()
self.status_label.setText(" 状态: 已被用户终止")
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 sp.ver_ver.text().strip():
try:
content = f'''VSVersionInfo(ffi=FixedFileInfo(filevers=(1,0,0,0),prodvers=(1,0,0,0),mask=0x3f,flags=0x0,OS=0x40004,fileType=0x1,subtype=0x0,date=(0,0)),kids=[StringFileInfo([StringTable('040904B0',[StringStruct('CompanyName','{sp.ver_comp.text()}'),StringStruct('FileDescription','{sp.ver_desc.text()}'),StringStruct('FileVersion','{sp.ver_ver.text()}'),StringStruct('ProductVersion','{sp.ver_ver.text()}'),StringStruct('OriginalFilename','{app_name}.exe')])])])'''
version_file = Path(tempfile.gettempdir()) / f"pypack_{app_name}_version.txt"
version_file.write_text(content, encoding='utf-8')
except: pass
params = {
'engine': engine,
'script_path': self.script_path,
'app_name': app_name,
'onefile': sp.onefile_check.isChecked() if engine != "cx_Freeze" else False,
'noconsole': sp.noconsole_check.isChecked(),
'icon': sp.icon_edit.text().strip(),
'use_reqs': sp.reqs_check.isChecked(),
'use_pipreqs': sp.pipreqs_check.isChecked(),
'hidden_imports': sp.hidden_edit.text(),
'add_data': sp.add_data_edit.text(),
'upx': sp.upx_check.isChecked() if engine == "PyInstaller" else False,
'use_venv': sp.venv_check.isChecked(),
'clean_all': sp.clean_all_check.isChecked(),
'version_file': version_file.as_posix() if version_file else None,
'ver_comp': sp.ver_comp.text() if engine != "cx_Freeze" else "",
'ver_desc': sp.ver_desc.text() if engine != "cx_Freeze" else "",
'ver_ver': sp.ver_ver.text() if engine != "cx_Freeze" else "",
'pip_index_url': sp.pip_source_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.status_label.setText(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 success:
icon_path = self.settings_panel.icon_edit.text().strip()
self.drop_area.show_success(icon_path)
self.status_label.setText(" 状态: 构建完成 ✅")
self.update_ui_state("done")
else:
self.drop_area.show_failure()
self.status_label.setText(" 状态: 构建失败 ❌")
self.update_ui_state("failed")
def open_dist(self):
if self.settings_panel.clean_all_check.isChecked() and self.script_path:
target = Path(self.script_path).parent
else: target = Path.cwd() / "dist"
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_edit.clear()
self.log.clear()
if self.log_container.isVisible(): self.toggle_log()
self.drop_area.reset()
self.status_label.setText(" 状态: 工作区已重置")
self.update_ui_state("idle")
def append_log(self, msg):
self.log.append(msg)
self.log.moveCursor(QTextCursor.End)
if __name__ == "__main__":
if hasattr(Qt, 'HighDpiScaleFactorRoundingPolicy'):
QApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
app = QApplication(sys.argv)
app.setStyle("Fusion")
app.setFont(QFont("Microsoft YaHei", 9))
win = MainWindow()
win.show()
sys.exit(app.exec_())