1849 lines
84 KiB
Python
1849 lines
84 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':
|
|
import winreg
|
|
|
|
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)
|
|
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
|
|
from PySide6.QtSvg import QSvgRenderer
|
|
|
|
__app_name__ = "QPyPack"
|
|
__version__ = "2.4.0"
|
|
__author__ = "QwejayHuang"
|
|
__company__ = "QwejayHuang"
|
|
__description__ = "自动化 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.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'
|
|
}
|
|
|
|
_CONFIG_DIR = Path.home() / ".qpypack"
|
|
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
CONFIG_FILE = (_CONFIG_DIR / "config.ini").as_posix()
|
|
|
|
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 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',
|
|
'onefile': 'True', 'noconsole': 'True', 'clean_all': 'True',
|
|
'auto_icon': 'True', 'use_venv': 'True', 'use_reqs': 'True',
|
|
'use_pipreqs': 'True', 'upx': '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'] = {}
|
|
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 (PermissionError, OSError):
|
|
return True
|
|
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(a.name.split('.')[0] for a 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)
|
|
flags = getattr(subprocess, 'CREATE_NO_WINDOW', 0)
|
|
proc = subprocess.run([python_exe, "-c", code_snippet, script_path],
|
|
capture_output=True, text=True, env=env, creationflags=flags)
|
|
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" width="{size}" height="{size}"><path fill="{color}" d="{path_data}"/></svg>'
|
|
renderer = QSvgRenderer()
|
|
renderer.load(svg_str.encode('utf-8'))
|
|
pixmap = QPixmap(size, size)
|
|
pixmap.fill(Qt.GlobalColor.transparent)
|
|
painter = QPainter(pixmap)
|
|
painter.setRenderHint(QPainter.RenderHint.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', '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 os.name == 'nt':
|
|
try:
|
|
py = shutil.which("py")
|
|
if py:
|
|
clean_env = os.environ.copy()
|
|
clean_env.pop("PYTHONHOME", None)
|
|
clean_env.pop("PYTHONPATH", None)
|
|
proc = subprocess.Popen(
|
|
[py, "-c", "import sys; print(sys.executable)"],
|
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
|
|
text=True, 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)
|
|
|
|
for cand in candidates:
|
|
cand = os.path.normpath(cand)
|
|
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:
|
|
flags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0
|
|
proc = subprocess.run(
|
|
[cand, "-V"], capture_output=True, env=clean_env,
|
|
creationflags=flags, timeout=3
|
|
)
|
|
if proc.returncode == 0:
|
|
return cand
|
|
except:
|
|
continue
|
|
|
|
return "python"
|
|
|
|
def get_python_executable():
|
|
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
|
|
if ":" in d:
|
|
last_colon_idx = d.rfind(':')
|
|
if last_colon_idx > 1:
|
|
src_path = d[:last_colon_idx].strip()
|
|
dst_path = d[last_colon_idx+1:].strip()
|
|
else:
|
|
src_path = d.strip()
|
|
dst_path = "."
|
|
else:
|
|
src_path = d.strip()
|
|
dst_path = "."
|
|
datas.append((src_path, dst_path))
|
|
return datas
|
|
|
|
|
|
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.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 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()
|
|
self.load_from_config()
|
|
|
|
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.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 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))
|
|
self.pip_source_edit.setText(s.get('pip_index', 'https://pypi.tuna.tsinghua.edu.cn/simple'))
|
|
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.upx_check.setChecked(s.getboolean('upx', False))
|
|
|
|
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.pip_source_edit.text().strip()
|
|
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['upx'] = str(self.upx_check.isChecked())
|
|
save_config(config)
|
|
|
|
def build_basic_tab(self):
|
|
scroll = QScrollArea()
|
|
scroll.setWidgetResizable(True)
|
|
scroll.setFrameShape(QFrame.Shape.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.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
|
form_core.setContentsMargins(15, 20, 15, 15)
|
|
form_core.setVerticalSpacing(12)
|
|
|
|
self.engine_combo = QComboBox()
|
|
self.engine_combo.addItems(["PyInstaller", "Nuitka"])
|
|
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.noconsole_check = QCheckBox("隐藏控制台 (GUI模式)")
|
|
self.clean_all_check = QCheckBox("编译后自动清理缓存")
|
|
self.auto_icon_check = QCheckBox("自动匹配同目录图标")
|
|
|
|
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(QFrame.Shape.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.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
|
form_env.setContentsMargins(15, 20, 15, 15)
|
|
form_env.setVerticalSpacing(12)
|
|
|
|
self.pip_source_edit = QLineEdit()
|
|
form_env.addRow("PIP 镜像:", self.pip_source_edit)
|
|
|
|
self.venv_check = QCheckBox("启用独立打包环境")
|
|
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.pipreqs_check = QCheckBox("启用 pipreqs 深度代码分析")
|
|
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.FieldGrowthPolicy.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(QFrame.Shape.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.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
|
form_res.setContentsMargins(15, 20, 15, 15)
|
|
form_res.setVerticalSpacing(12)
|
|
|
|
self.add_data_edit = QLineEdit()
|
|
self.add_data_edit.setPlaceholderText("例如: data/model.bin: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("启用 PyInstaller UPX")
|
|
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(QFrame.Shape.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.FieldGrowthPolicy.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("注:写入 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.upx_check.setVisible(engine == "PyInstaller")
|
|
|
|
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)")
|
|
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(self):
|
|
choice = QMessageBox.question(self, "添加资源", "Yes=文件, No=文件夹", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
|
if choice == QMessageBox.StandardButton.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 ScriptAnalysisThread(QThread):
|
|
analysis_done = Signal(str, str, str, str, set) # app_name, version, author, desc, imports
|
|
|
|
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
|
|
|
|
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, timeout=None):
|
|
if self._is_cancelled: return False
|
|
|
|
timer = None
|
|
is_timeout = [False]
|
|
|
|
clean_env = os.environ.copy()
|
|
clean_env.pop("PYTHONHOME", None)
|
|
clean_env.pop("PYTHONPATH", None)
|
|
|
|
try:
|
|
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.STDOUT, "cwd": cwd,
|
|
"text": True, "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)],
|
|
capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
else:
|
|
self.process.kill()
|
|
except:
|
|
pass
|
|
timer = threading.Timer(timeout, kill_proc)
|
|
timer.start()
|
|
|
|
buffer = []
|
|
last_emit = time.time()
|
|
for line in self.process.stdout:
|
|
if self._is_cancelled:
|
|
self.process.terminate()
|
|
return False
|
|
buffer.append(line.strip())
|
|
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"[System] 子进程执行超时 (>{timeout}s),已自动中断并继续执行后续流水线...")
|
|
return False
|
|
|
|
return self.process.returncode == 0
|
|
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] 子进程执行发生系统级异常: {e}")
|
|
return False
|
|
finally:
|
|
if timer:
|
|
timer.cancel()
|
|
|
|
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_dir = Path(tempfile.gettempdir())
|
|
temp_file = temp_dir / f"qpypack_build_target_{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 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("[Init] 正在初始化工作区并清理历史残留数据...")
|
|
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"[Error] I/O 异常: {err_msg}")
|
|
script_posix = build_script_path.as_posix()
|
|
|
|
system_python_exe = get_python_executable()
|
|
self.progress.emit(f"[Env] 宿主解释器路径: {system_python_exe}")
|
|
|
|
script_imports = set()
|
|
try:
|
|
script_imports = extract_imports_via_ast(script_posix, system_python_exe)
|
|
except Exception as e:
|
|
self.progress.emit(f"[Warn] 源码 AST 预处理异常: {e}")
|
|
|
|
pip_args = ["-i", pip_idx] if pip_idx else []
|
|
|
|
if self.params['use_venv']:
|
|
self.progress.emit("[Env] 正在分配独立隔离的虚拟环境 (Virtual Environment)...")
|
|
self.venv_dir = Path(tempfile.mkdtemp(prefix="qpypack_env_")).resolve()
|
|
if not self.run_cmd([system_python_exe, "-m", "venv", self.venv_dir.as_posix()]):
|
|
return self.finished.emit(False, "[Error] 虚拟环境(venv)创建失败。宿主 Python 环境可能存在内核文件缺失或权限受限。")
|
|
python_exe = (self.venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).as_posix()
|
|
|
|
self.progress.emit("[Env] 正在同步并升级 Pip 构建工具链...")
|
|
self.run_cmd([python_exe, "-m", "pip", "install", "--upgrade", "pip", "-q"] + pip_args)
|
|
else:
|
|
python_exe = system_python_exe
|
|
|
|
engine_pkg = "nuitka" if engine == "Nuitka" else "pyinstaller"
|
|
|
|
self.progress.emit(f"[Env] 正在配置基础构建引擎 ({engine_pkg} & 核心支持库)...")
|
|
core_pkgs = [engine_pkg]
|
|
if engine == "PyInstaller":
|
|
core_pkgs.append("pillow")
|
|
elif engine == "Nuitka":
|
|
core_pkgs.append("zstandard")
|
|
self.run_cmd([python_exe, "-m", "pip", "install", "-q"] + core_pkgs + pip_args)
|
|
|
|
if self.params.get('use_reqs'):
|
|
req_file = script_dir / "requirements.txt"
|
|
if req_file.exists():
|
|
self.progress.emit("[Deps] 阶段 1/3: 正在读取并部署清单依赖 (requirements.txt)...")
|
|
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_cmd([python_exe, "-m", "pip", "install", "-q", "-r", temp_req.as_posix()] + pip_args)
|
|
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("[Deps] 阶段 2/3: 正在启动深度依赖分析 (Pipreqs)...")
|
|
self.run_cmd([python_exe, "-m", "pip", "install", "pipreqs", "-q"] + pip_args)
|
|
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.strip(), 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"[Deps] 依赖分析镜像地址: {pypi_server}")
|
|
|
|
if self.run_cmd(pipreqs_cmd, timeout=15) and temp_pipreqs.exists():
|
|
self.run_cmd([python_exe, "-m", "pip", "install", "-q", "-r", temp_pipreqs.as_posix()] + pip_args)
|
|
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"[Deps] 阶段 3/3: 启动 AST 源码扫描...")
|
|
self.progress.emit(f"==> [Deps] 正在对齐隐式依赖项: {', '.join(ast_pkgs)}")
|
|
if not self.run_cmd([python_exe, "-m", "pip", "install", "-q"] + ast_pkgs + pip_args):
|
|
self.progress.emit("[Warn] 批量依赖同步存在冲突,正在降级为逐项安全安装模式...")
|
|
for pkg in ast_pkgs:
|
|
self.run_cmd([python_exe, "-m", "pip", "install", "-q", pkg] + pip_args)
|
|
|
|
if self._is_cancelled: return self.finished.emit(False, "[System] 构建任务已被用户主动终止。")
|
|
|
|
self.progress.emit(f"[Build] 正在初始化 {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()
|
|
cmd = [python_exe, "-m", "PyInstaller", "--clean", "--noconfirm", 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")
|
|
|
|
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']])
|
|
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 src, dst in parse_add_data(self.params.get('add_data', '')):
|
|
cmd.extend(["--add-data", f"{src}{os.pathsep}{dst}"])
|
|
|
|
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['onefile']: cmd.append("--onefile")
|
|
else: cmd.append("--standalone")
|
|
|
|
if self.params['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}")
|
|
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"--product-name={self.params['ver_desc']}")
|
|
if self.params.get('ver_ver'): cmd.append(f"--file-version={self.params['ver_ver']}")
|
|
|
|
cmd.append("--enable-plugin=anti-bloat")
|
|
|
|
if 'PyQt5' in script_imports: cmd.append("--enable-plugin=pyqt5")
|
|
elif 'PyQt6' in script_imports: cmd.append("--enable-plugin=pyqt6")
|
|
elif 'PySide2' in script_imports: cmd.append("--enable-plugin=pyside2")
|
|
elif 'PySide6' in script_imports: cmd.append("--enable-plugin=pyside6")
|
|
|
|
if 'numpy' in script_imports: cmd.append("--enable-plugin=numpy")
|
|
if 'matplotlib' in script_imports: cmd.append("--enable-plugin=matplotlib")
|
|
if 'tkinter' in script_imports: 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 src, dst in parse_add_data(self.params.get('add_data', '')):
|
|
src_path = Path(src)
|
|
if src_path.is_dir():
|
|
cmd.append(f"--include-data-dir={src_path.resolve().as_posix()}={dst}")
|
|
else:
|
|
cmd.append(f"--include-data-files={src_path.resolve().as_posix()}={dst}")
|
|
|
|
cmd.append(script_posix)
|
|
|
|
success = self.run_cmd(cmd, cwd=Path.cwd().resolve().as_posix())
|
|
if self._is_cancelled: return self.finished.emit(False, "[System] 构建任务已被用户主动终止。")
|
|
|
|
self.progress.emit("[Pack] 编译阶段完成,正在提取并归档最终可执行产物...")
|
|
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")
|
|
|
|
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"[Error] 产物转移异常,文件可能被系统防御机制拦截或占用: {e}")
|
|
else: self.progress.emit(f"[Error] 构建目标缺失,未能在沙盒中定位到有效产物: {src_out}")
|
|
|
|
if success and final_out.exists():
|
|
self.progress.emit("[Pack] 正在校验最终程序完整性...")
|
|
self.finished.emit(True, f"[Success] 构建结束!路径: {final_out.resolve().as_posix()}")
|
|
else:
|
|
self.finished.emit(False, "[Failed] 构建任务异常中断,请参阅上方运行日志以排查错误。")
|
|
except Exception as e:
|
|
self.finished.emit(False, f"[System Error] 发生未处理的严重错误: {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('version_file'):
|
|
try: Path(self.params['version_file']).unlink(missing_ok=True)
|
|
except: pass
|
|
|
|
if self.params.get('temp_icon_file'):
|
|
try: Path(self.params['temp_icon_file']).unlink(missing_ok=True)
|
|
except: pass
|
|
|
|
if self.params['clean_all']:
|
|
self.progress.emit("[Clean] 正在释放工作区,抹除虚拟环境与沙盒临时数据...")
|
|
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)
|
|
|
|
|
|
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__} - {__author__}")
|
|
|
|
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.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.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 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
|
|
|
|
self.script_path = path
|
|
|
|
self.drop_area.set_loading(Path(path).name)
|
|
self.status_label.setText(f" 状态: 正在解析 {Path(path).name} ...")
|
|
self.btn_main.setEnabled(False)
|
|
|
|
if self.analysis_thread and self.analysis_thread.isRunning():
|
|
self.analysis_thread.disconnect()
|
|
self.analysis_thread.terminate()
|
|
self.analysis_thread.wait()
|
|
|
|
self.analysis_thread = ScriptAnalysisThread(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():
|
|
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)
|
|
|
|
status_suffix = ",已配置为【保留控制台】模式" if not has_gui else ",已配置为GUI模式"
|
|
self.status_label.setText(f" 状态: 已锁定源文件 {Path(path).name}{status_suffix}")
|
|
|
|
if not self.log_container.isVisible(): self.toggle_log()
|
|
self.log.clear()
|
|
self.append_log(f"[System] 源文件绑定成功: {path}")
|
|
if not has_gui:
|
|
self.append_log("[System] 检测到该脚本不包含常见 GUI 图形框架。已为您保留控制台。")
|
|
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.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:
|
|
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])
|
|
|
|
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','{sp.ver_comp.text()}'),StringStruct('FileDescription','{sp.ver_desc.text()}'),StringStruct('FileVersion','{v_str}'),StringStruct('ProductVersion','{v_str}'),StringStruct('OriginalFilename','{app_name}.exe')])])])'''
|
|
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)
|
|
if icon_path.suffix.lower() == '.svg':
|
|
try:
|
|
renderer = QSvgRenderer()
|
|
if renderer.load(icon_path.as_posix()):
|
|
pixmap = QPixmap(256, 256)
|
|
pixmap.fill(Qt.GlobalColor.transparent)
|
|
painter = QPainter(pixmap)
|
|
renderer.render(painter)
|
|
painter.end()
|
|
|
|
temp_ico = Path(tempfile.gettempdir()) / f"qpypack_temp_icon_{int(time.time())}.ico"
|
|
if pixmap.save(temp_ico.as_posix(), "ICO"):
|
|
icon_path_str = temp_ico.as_posix()
|
|
temp_icon_file = temp_ico.as_posix()
|
|
else:
|
|
temp_png = Path(tempfile.gettempdir()) / f"qpypack_temp_icon_{int(time.time())}.png"
|
|
if pixmap.save(temp_png.as_posix(), "PNG"):
|
|
icon_path_str = temp_png.as_posix()
|
|
temp_icon_file = temp_png.as_posix()
|
|
except Exception as e:
|
|
self.append_log(f"[Warn] SVG 图标解析异常: {e}")
|
|
|
|
if not temp_icon_file:
|
|
icon_path_str = None
|
|
self.append_log("[Warn] SVG 桌面图标转换失败,已自动降级为无图标模式构建以确保成功。")
|
|
|
|
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(),
|
|
'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,
|
|
'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': 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.ensureCursorVisible()
|
|
|
|
|
|
def main():
|
|
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() |