#!/usr/bin/env python3# -*- coding: utf-8 -*-"""灵感江Python打包exe工具 v1.0 - 精简美化版支持:PyInstaller 和 Nuitka 双引擎打包功能:UPX压缩、文件夹打包、多格式图标支持、居中进度弹窗、自动依赖扫描"""import osimport sysimport subprocessimport shutilimport threadingimport queueimport reimport tempfilefrom pathlib import Pathfrom typing import Listfrom dataclasses import dataclass, field, asdict# 设置控制台编码if sys.platform == 'win32': os.environ['PYTHONIOENCODING'] = 'utf-8'try: import tkinter as tk from tkinter import ttk, filedialog, messagebox GUI_AVAILABLE = Trueexcept ImportError: GUI_AVAILABLE = False print("错误: tkinter未安装") sys.exit(1)# 尝试导入PIL用于图标转换try: from PIL import Image PIL_AVAILABLE = Trueexcept ImportError: PIL_AVAILABLE = False# ============== 主题配置 ==============class Theme: LIGHT = { 'name': 'light', 'bg_primary': '#f8fafc', 'bg_secondary': '#ffffff', 'bg_tertiary': '#f1f5f9', 'bg_card': '#ffffff', 'bg_input': '#ffffff', 'accent': '#6366f1', 'accent_hover': '#818cf8', 'accent_light': '#eef2ff', 'text_primary': '#1e293b', 'text_secondary': '#64748b', 'text_muted': '#94a3b8', 'text_success': '#22c55e', 'text_error': '#ef4444', 'text_warning': '#f59e0b', 'border': '#e2e8f0', 'btn_secondary': '#f1f5f9', 'btn_danger': '#ef4444', 'progress_bg': '#e2e8f0', 'progress_fg': '#6366f1', 'log_bg': '#1e293b', 'log_fg': '#94a3b8', 'log_success': '#4ade80', 'log_error': '#f87171', 'log_warning': '#fbbf24', 'log_info': '#60a5fa', 'overlay_bg': '#4a5568', } DARK = { 'name': 'dark', 'bg_primary': '#0f172a', 'bg_secondary': '#1e293b', 'bg_tertiary': '#334155', 'bg_card': '#1e293b', 'bg_input': '#0f172a', 'accent': '#818cf8', 'accent_hover': '#a5b4fc', 'accent_light': '#312e81', 'text_primary': '#f1f5f9', 'text_secondary': '#cbd5e1', 'text_muted': '#64748b', 'text_success': '#4ade80', 'text_error': '#f87171', 'text_warning': '#fbbf24', 'border': '#334155', 'btn_secondary': '#334155', 'btn_danger': '#ef4444', 'progress_bg': '#334155', 'progress_fg': '#818cf8', 'log_bg': '#0f172a', 'log_fg': '#94a3b8', 'log_success': '#4ade80', 'log_error': '#f87171', 'log_warning': '#fbbf24', 'log_info': '#60a5fa', 'overlay_bg': '#1a202c', }# ============== 配置数据类 ==============@dataclassclass PackConfig: python_path: str = "" main_file: str = "" output_name: str = "" icon_path: str = "" output_path: str = "" packer: str = "pyinstaller" one_file: bool = False no_console: bool = True speed_mode: bool = True enable_upx: bool = False upx_path: str = "" add_folders: List[str] = field(default_factory=list)# ============== 依赖扫描器 ==============class DependencyScanner: """自动扫描Python文件中的依赖库""" # Python标准库模块列表(常见的一部分) STDLIB_MODULES = { # 内置模块 'abc', 'aifc', 'argparse', 'array', 'ast', 'asynchat', 'asyncio', 'asyncore', 'atexit', 'audioop', 'base64', 'bdb', 'binascii', 'binhex', 'bisect', 'builtins', 'bz2', 'calendar', 'cgi', 'cgitb', 'chunk', 'cmath', 'cmd', 'code', 'codecs', 'codeop', 'collections', 'colorsys', 'compileall', 'concurrent', 'configparser', 'contextlib', 'contextvars', 'copy', 'copyreg', 'cProfile', 'crypt', 'csv', 'ctypes', 'curses', 'dataclasses', 'datetime', 'dbm', 'decimal', 'difflib', 'dis', 'distutils', 'doctest', 'email', 'encodings', 'enum', 'errno', 'faulthandler', 'fcntl', 'filecmp', 'fileinput', 'fnmatch', 'fractions', 'ftplib', 'functools', 'gc', 'getopt', 'getpass', 'gettext', 'glob', 'graphlib', 'grp', 'gzip', 'hashlib', 'heapq', 'hmac', 'html', 'http', 'imaplib', 'imghdr', 'imp', 'importlib', 'inspect', 'io', 'ipaddress', 'itertools', 'json', 'keyword', 'lib2to3', 'linecache', 'locale', 'logging', 'lzma', 'mailbox', 'mailcap', 'marshal', 'math', 'mimetypes', 'mmap', 'modulefinder', 'multiprocessing', 'netrc', 'nis', 'nntplib', 'numbers', 'operator', 'optparse', 'os', 'ossaudiodev', 'pathlib', 'pdb', 'pickle', 'pickletools', 'pipes', 'pkgutil', 'platform', 'plistlib', 'poplib', 'posix', 'posixpath', 'pprint', 'profile', 'pstats', 'pty', 'pwd', 'py_compile', 'pyclbr', 'pydoc', 'queue', 'quopri', 'random', 're', 'readline', 'reprlib', 'resource', 'rlcompleter', 'runpy', 'sched', 'secrets', 'select', 'selectors', 'shelve', 'shlex', 'shutil', 'signal', 'site', 'smtpd', 'smtplib', 'sndhdr', 'socket', 'socketserver', 'spwd', 'sqlite3', 'ssl', 'stat', 'statistics', 'string', 'stringprep', 'struct', 'subprocess', 'sunau', 'symtable', 'sys', 'sysconfig', 'syslog', 'tabnanny', 'tarfile', 'telnetlib', 'tempfile', 'termios', 'test', 'textwrap', 'threading', 'time', 'timeit', 'tkinter', 'token', 'tokenize', 'trace', 'traceback', 'tracemalloc', 'tty', 'turtle', 'turtledemo', 'types', 'typing', 'unicodedata', 'unittest', 'urllib', 'uu', 'uuid', 'venv', 'warnings', 'wave', 'weakref', 'webbrowser', 'winreg', 'winsound', 'wsgiref', 'xdrlib', 'xml', 'xmlrpc', 'zipapp', 'zipfile', 'zipimport', 'zlib', # 常见的标准库子模块 'tkinter.ttk', 'tkinter.filedialog', 'tkinter.messagebox', 'tkinter.colorchooser', 'tkinter.font', 'tkinter.scrolledtext', 'tkinter.simpledialog', 'http.client', 'http.server', 'http.cookies', 'http.cookiejar', 'urllib.request', 'urllib.parse', 'urllib.error', 'urllib.robotparser', 'email.mime', 'email.mime.text', 'email.mime.multipart', 'email.mime.base', 'xml.etree', 'xml.etree.ElementTree', 'xml.dom', 'xml.sax', 'json.decoder', 'json.encoder', 'collections.abc', 'collections.abc', 'concurrent.futures', 'concurrent.futures.thread', 'concurrent.futures.process', 'multiprocessing.pool', 'multiprocessing.process', 'multiprocessing.Queue', 'asyncio.coroutines', 'asyncio.events', 'asyncio.tasks', 'asyncio.queues', 'logging.handlers', 'logging.config', } # 需要特殊处理的第三方库映射(包名 -> 导入名) PACKAGE_IMPORT_MAP = { 'cv2': 'cv2', 'opencv-python': 'cv2', 'opencv-contrib-python': 'cv2', 'PIL': 'PIL', 'pillow': 'PIL', 'sklearn': 'sklearn', 'scikit-learn': 'sklearn', 'skimage': 'skimage', 'scikit-image': 'skimage', 'bs4': 'bs4', 'beautifulsoup4': 'bs4', 'dateutil': 'dateutil', 'python-dateutil': 'dateutil', 'yaml': 'yaml', 'pyyaml': 'yaml', 'Crypto': 'Crypto', 'pycryptodome': 'Crypto', 'usb': 'usb', 'pyusb': 'usb', 'serial': 'serial', 'pyserial': 'serial', 'Image': 'PIL', 'sqlite3': None, # 标准库 } # 常见需要隐藏导入的库(自动添加) COMMON_HIDDEN_IMPORTS = { 'cv2': ['cv2'], 'numpy': ['numpy', 'numpy.core', 'numpy.core._methods', 'numpy.lib', 'numpy.lib.format'], 'pandas': ['pandas', 'pandas._libs', 'pandas._libs.tslibs'], 'PIL': ['PIL', 'PIL._imagingtk', 'PIL._imaging'], 'sklearn': ['sklearn', 'sklearn.utils', 'sklearn.utils._cython_blas', 'sklearn.neighbors.typedefs'], 'torch': ['torch', 'torch._C'], 'tensorflow': ['tensorflow', 'tensorflow.python'], 'matplotlib': ['matplotlib', 'matplotlib.backends', 'matplotlib.backends.backend_tkagg'], 'requests': ['requests', 'urllib3'], 'flask': ['flask', 'jinja2', 'werkzeug', 'click'], 'django': ['django', 'django.core'], 'PyQt5': ['PyQt5', 'PyQt5.QtCore', 'PyQt5.QtGui', 'PyQt5.QtWidgets'], 'PyQt6': ['PyQt6', 'PyQt6.QtCore', 'PyQt6.QtGui', 'PyQt6.QtWidgets'], 'PySide2': ['PySide2', 'PySide2.QtCore', 'PySide2.QtGui', 'PySide2.QtWidgets'], 'PySide6': ['PySide6', 'PySide6.QtCore', 'PySide6.QtGui', 'PySide6.QtWidgets'], 'pytz': ['pytz'], 'dateutil': ['dateutil', 'dateutil.relativedelta'], 'lxml': ['lxml', 'lxml.etree', 'lxml._elementpath'], 'bs4': ['bs4', 'bs4.builder', 'bs4.builder.html_parser'], 'selenium': ['selenium', 'selenium.webdriver'], 'serial': ['serial', 'serial.tools', 'serial.tools.list_ports'], 'usb': ['usb', 'usb.core', 'usb.util'], 'pynput': ['pynput', 'pynput.keyboard', 'pynput.mouse'], 'pygame': ['pygame', 'pygame.base', 'pygame.locals'], 'sounddevice': ['sounddevice'], 'soundfile': ['soundfile'], 'pyaudio': ['pyaudio', '_portaudio'], 'librosa': ['librosa', 'librosa.core', 'librosa.util'], 'scipy': ['scipy', 'scipy.sparse', 'scipy.sparse.csgraph', 'scipy.special', 'scipy.spatial'], } @classmethod def scan_file(cls, file_path: str) -> set: """扫描单个Python文件,提取所有导入的模块""" imports = set() try: with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: content = f.read() # 使用正则表达式匹配 import 语句 # 匹配: import xxx, import xxx as yyy, import xxx, yyy, zzz import_pattern = r'^\s*import\s+([\w\s,\.]+)(?:\s+as\s+\w+)?\s*$' # 匹配: from xxx import yyy from_pattern = r'^\s*from\s+([\w\.]+)\s+import' for line in content.split('\n'): line = line.strip() # 跳过注释 if line.startswith('#'): continue # 匹配 from xxx import yyy match = re.match(from_pattern, line) if match: module = match.group(1).split('.')[0] # 取顶层模块 imports.add(module) continue # 匹配 import xxx match = re.match(import_pattern, line) if match: modules = match.group(1) for mod in modules.split(','): mod = mod.strip().split('.')[0] # 取顶层模块 if mod and not mod.startswith('as'): imports.add(mod) except Exception as e: print(f"扫描文件失败 {file_path}: {e}") return imports @classmethod def scan_project(cls, main_file: str) -> set: """扫描整个项目目录下的所有Python文件""" imports = set() project_dir = Path(main_file).parent # 扫描主文件 imports.update(cls.scan_file(main_file)) # 扫描项目目录下的所有 .py 文件 for py_file in project_dir.rglob('*.py'): try: imports.update(cls.scan_file(str(py_file))) except BaseException: pass # 扫描附加文件夹 # 这里可以扩展,暂时只扫描主文件所在目录 return imports @classmethod def filter_third_party(cls, imports: set) -> list: """过滤出第三方库(非标准库)""" third_party = [] for mod in imports: # 跳过标准库 if mod in cls.STDLIB_MODULES: continue # 跳过相对导入 if mod.startswith('.'): continue # 跳过本地模块(检查是否存在对应的 .py 文件) # 这部分可能需要更智能的处理 third_party.append(mod) return third_party @classmethod def get_hidden_imports(cls, main_file: str) -> list: """获取需要隐藏导入的模块列表""" imports = cls.scan_project(main_file) third_party = cls.filter_third_party(imports) hidden_imports = [] for mod in third_party: # 检查是否有预定义的隐藏导入 if mod in cls.COMMON_HIDDEN_IMPORTS: hidden_imports.extend(cls.COMMON_HIDDEN_IMPORTS[mod]) else: # 添加基础模块名 hidden_imports.append(mod) # 去重 hidden_imports = list(set(hidden_imports)) return hidden_importsclass IconConverter: @staticmethod def convert_to_ico(image_path: str) -> str: """将PNG/JPG/WEBP等格式转换为ICO临时文件""" if not PIL_AVAILABLE: return None try: img = Image.open(image_path) # 转换为RGBA模式 if img.mode != 'RGBA': img = img.convert('RGBA') # 调整大小为标准图标尺寸 sizes = [(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)] icons = [] for size in sizes: resized = img.resize(size, Image.Resampling.LANCZOS) icons.append(resized) # 创建临时ICO文件 temp_dir = tempfile.gettempdir() temp_ico = os.path.join(temp_dir, f"packager_icon_{os.getpid()}.ico") icons[0].save(temp_ico, format='ICO', sizes=[(i.width, i.height) for i in icons]) return temp_ico except Exception as e: print(f"图标转换失败: {e}") return None# ============== 打包器基类 ==============class BasePacker: def __init__(self, config, log_callback=None, progress_callback=None, status_callback=None): self.config = config self.log_callback = log_callback self.progress_callback = progress_callback self.status_callback = status_callback self.process = None self.cancelled = False def log(self, message, level="INFO"): if self.log_callback: self.log_callback(message, level) def update_progress(self, value, status=""): if self.progress_callback: self.progress_callback(value) if self.status_callback and status: self.status_callback(status) def cancel(self): self.cancelled = True if self.process: self.process.terminate() def cleanup(self, project_dir): for d in ['build', '__pycache__']: p = Path(project_dir) / d if p.exists(): try: shutil.rmtree(p) except BaseException: pass for f in Path(project_dir).glob("*.spec"): try: f.unlink() except BaseException: pass# ============== PyInstaller 打包器 ==============class PyInstallerPacker(BasePacker): def check_install(self): try: import PyInstaller self.log(f"✓ PyInstaller {PyInstaller.__version__}", "SUCCESS") return True except ImportError: self.log("安装 PyInstaller...", "WARNING") try: python_exe = self.config.python_path or sys.executable subprocess.run([python_exe, "-m", "pip", "install", "pyinstaller", "-q"], check=True, capture_output=True, timeout=180) self.log("✓ PyInstaller 安装成功", "SUCCESS") return True except Exception as e: self.log(f"✗ 安装失败: {e}", "ERROR") return False def build_args(self): args = ['-y'] if self.config.one_file: args.append('--onefile') else: args.append('--onedir') if self.config.no_console: args.append('--noconsole') if self.config.output_name: args.extend(['--name', self.config.output_name]) if self.config.icon_path: args.extend(['--icon', self.config.icon_path]) if self.config.output_path: args.extend(['--distpath', self.config.output_path]) if self.config.enable_upx and self.config.upx_path: args.extend(['--upx-dir', self.config.upx_path]) for folder in self.config.add_folders: if folder and Path(folder).exists(): folder_name = Path(folder).name sep = ';' if sys.platform == 'win32' else ':' args.extend(['--add-data', f"{folder}{sep}{folder_name}"]) if self.config.speed_mode: args.extend(['--noupx', '-s']) # 排除一些不必要的模块(但不排除tkinter,因为用户可能需要) for mod in ['pytest', 'unittest', 'IPython', 'jupyter', 'numpy.f2py']: args.extend(['--exclude-module', mod]) # 确保tkinter相关模块被包含 args.extend(['--hidden-import', 'tkinter']) args.extend(['--hidden-import', 'tkinter.filedialog']) args.extend(['--hidden-import', 'tkinter.messagebox']) args.extend(['--hidden-import', 'tkinter.ttk']) # 自动扫描并添加依赖库的隐藏导入 if self.config.main_file: try: self.log("📦 扫描项目依赖...", "INFO") hidden_imports = DependencyScanner.get_hidden_imports(self.config.main_file) if hidden_imports: self.log(f"✓ 发现依赖: {', '.join(hidden_imports[:10])}{'...'iflen(hidden_imports) > 10else''}", "SUCCESS") for mod in hidden_imports: args.extend(['--hidden-import', mod]) else: self.log("✓ 未发现额外依赖", "INFO") except Exception as e: self.log(f"⚠ 依赖扫描失败: {e}", "WARNING") return args def pack(self): self.log("🚀 PyInstaller 打包模式", "INFO") if not self.config.main_file: self.log("✗ 未选择主文件", "ERROR") return False main_path = Path(self.config.main_file) if not main_path.exists(): self.log(f"✗ 文件不存在", "ERROR") return False self.update_progress(5, "检查环境...") if not self.check_install(): return False project_dir = str(main_path.parent) self.update_progress(10, "准备打包...") python_exe = self.config.python_path or sys.executable args = self.build_args() cmd = [python_exe, "-m", "PyInstaller"] + args + [str(main_path)] return self._run_command(cmd, project_dir) def _run_command(self, cmd, cwd): self.update_progress(20, "打包中...") try: env = os.environ.copy() env['PYTHONIOENCODING'] = 'utf-8' env['PYTHONUTF8'] = '1' self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=cwd, env=env) stages = [("Analyzing", "分析", 30), ("Processing", "处理", 45), ("Collecting", "收集", 60), ("Building", "构建", 75), ("completed successfully", "完成", 95)] current_stage = 0 while True: if self.cancelled: self.process.terminate() return False try: line_bytes = self.process.stdout.readline() if not line_bytes: if self.process.poll() is not None: break continue try: line = line_bytes.decode('utf-8', errors='replace').rstrip() except BaseException: line = line_bytes.decode('gbk', errors='replace').rstrip() if line: for kw, desc, prog in stages: if kw.lower() in line.lower() and prog > current_stage: current_stage = prog self.update_progress(prog, f"{desc}...") break lvl = "INFO" ll = line.lower() if "error" in ll or "failed" in ll: lvl = "ERROR" elif "warning" in ll: lvl = "WARNING" elif "completed" in ll or "successfully" in ll: lvl = "SUCCESS" self.log(line, lvl) except BaseException: break self.process.wait() if self.process.returncode == 0: self.update_progress(100, "完成!") if self.config.one_file: self.cleanup(cwd) self.log("✓ 打包成功!", "SUCCESS") main_path = Path(self.config.main_file) output_dir = Path(self.config.output_path or main_path.parent) / 'dist' output_name = self.config.output_name or main_path.stem output_file = output_dir / f"{output_name}.exe" if self.config.one_file else output_dir / output_name / f"{output_name}.exe" if output_file.exists(): size = output_file.stat().st_size / (1024 * 1024) self.log(f"输出: {output_file} ({size:.1f}MB)", "SUCCESS") return True else: self.log(f"✗ 打包失败", "ERROR") return False except Exception as e: self.log(f"✗ 出错: {e}", "ERROR") return False# ============== Nuitka 打包器 ==============class NuitkaPacker(BasePacker): def check_install(self): try: result = subprocess.run([sys.executable, "-m", "nuitka", "--version"], capture_output=True, text=True, timeout=10) if result.returncode == 0: self.log("✓ Nuitka 已安装", "SUCCESS") return True except BaseException: pass self.log("安装 Nuitka...", "WARNING") try: python_exe = self.config.python_path or sys.executable subprocess.run([python_exe, "-m", "pip", "install", "nuitka", "-q"], check=True, capture_output=True, timeout=300) self.log("✓ Nuitka 安装成功", "SUCCESS") return True except Exception as e: self.log(f"✗ 安装失败: {e}", "ERROR") return False def build_args(self): args = ['--follow-imports', '--assume-yes-for-downloads'] if self.config.one_file: args.append('--onefile') if self.config.no_console: args.append('--disable-console') if self.config.output_name: args.extend(['-o', f"{self.config.output_name}.exe"]) if self.config.icon_path: args.extend(['--windows-icon-from-ico', self.config.icon_path]) if self.config.output_path: args.extend(['--output-dir', self.config.output_path]) if self.config.enable_upx and self.config.upx_path: args.extend(['--upx-dir', self.config.upx_path]) for folder in self.config.add_folders: if folder and Path(folder).exists(): args.extend(['--include-package-data', folder]) if self.config.speed_mode: args.append('--lto=no') return args def pack(self): self.log("🔥 Nuitka 编译模式", "INFO") if not self.config.main_file: self.log("✗ 未选择主文件", "ERROR") return False main_path = Path(self.config.main_file) if not main_path.exists(): self.log(f"✗ 文件不存在", "ERROR") return False self.update_progress(5, "检查环境...") if not self.check_install(): return False project_dir = str(main_path.parent) self.update_progress(10, "准备编译...") python_exe = self.config.python_path or sys.executable args = self.build_args() cmd = [python_exe, "-m", "nuitka"] + args + [str(main_path)] return self._run_command(cmd, project_dir) def _run_command(self, cmd, cwd): self.update_progress(15, "初始化编译环境...") self.log("⚠ Nuitka编译可能需要10-30分钟,请耐心等待...", "WARNING") self.log(f"命令: {' '.join(cmd[:5])}...", "INFO") try: env = os.environ.copy() env['PYTHONIOENCODING'] = 'utf-8' self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=cwd, env=env) progress = 15 line_count = 0 while True: if self.cancelled: self.process.terminate() return False try: line_bytes = self.process.stdout.readline() if not line_bytes: if self.process.poll() is not None: break continue try: line = line_bytes.decode('utf-8', errors='replace').rstrip() except BaseException: line = line_bytes.decode('gbk', errors='replace').rstrip() line_count += 1 # 更频繁地更新进度 if line: ll = line.lower() # 根据输出内容更新进度 if "compiling" in ll: progress = min(progress + 1, 70) self.update_progress(progress, "编译Python代码...") elif "linking" in ll: progress = max(progress, 80) self.update_progress(progress, "链接可执行文件...") elif "optimizing" in ll: progress = max(progress, 75) self.update_progress(progress, "优化代码...") elif "generating" in ll: progress = max(progress, 50) self.update_progress(progress, "生成中间代码...") elif "downloading" in ll: self.update_progress(20, "下载依赖组件...") elif "success" in ll or "completed" in ll: progress = max(progress, 95) self.update_progress(progress, "编译完成!") elif "error" in ll: self.log(line, "ERROR") continue elif line_count % 50 == 0: # 每50行更新一次进度,给用户反馈 progress = min(progress + 0.5, 85) self.update_progress(progress, f"编译进行中... ({int(progress)}%)") # 确定日志级别 lvl = "INFO" if "error" in ll: lvl = "ERROR" elif "warning" in ll: lvl = "WARNING" elif "success" in ll or "completed" in ll: lvl = "SUCCESS" # 只输出重要信息 if any(kw in ll for kw in ["error", "warning", "success", "completed", "compiling", "linking"]): self.log(line, lvl) except BaseException: break self.process.wait() if self.process.returncode == 0: self.update_progress(100, "完成!") self.log("✓ 编译成功!", "SUCCESS") # 查找输出文件 main_path = Path(self.config.main_file) output_dir = Path(self.config.output_path or main_path.parent) output_name = self.config.output_name or main_path.stem # Nuitka默认输出到当前目录的output目录 possible_outputs = [ output_dir / f"{output_name}.exe", output_dir / f"{output_name}.onefile-build" / f"{output_name}.exe", Path(cwd) / f"{output_name}.exe", Path(cwd) / "output" / f"{output_name}.exe", ] for output_file in possible_outputs: if output_file.exists(): size = output_file.stat().st_size / (1024 * 1024) self.log(f"输出: {output_file} ({size:.1f}MB)", "SUCCESS") break return True else: self.log("✗ 编译失败,请检查错误信息", "ERROR") return False except Exception as e: self.log(f"✗ 出错: {e}", "ERROR") return False# ============== 检测器 ==============class PythonDetector: @staticmethod def find_python_interpreters(): interpreters = [] current = sys.executable version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" interpreters.append({'path': current, 'version': version, 'name': f"Python {version} (当前)", 'current': True}) if sys.platform == 'win32': import glob for pattern in [os.path.expandvars(r'%LOCALAPPDATA%\Programs\Python\Python*\python.exe'), os.path.expandvars(r'%PROGRAMFILES%\Python*\python.exe')]: for path in glob.glob(pattern): if path != current and os.path.exists(path): try: result = subprocess.run([path, '--version'], capture_output=True, text=True, timeout=5) match = re.search(r'Python (\d+\.\d+\.\d+)', result.stdout + result.stderr) if match: interpreters.append( {'path': path, 'version': match.group(1), 'name': f"Python {match.group(1)}", 'current': False}) except BaseException: pass seen = set() return [i for i in interpreters if i['path'] not in seen and not seen.add(i['path'])]class UPXDetector: @staticmethod def find_upx(): search_paths = [Path(__file__).parent if '__file__' in dir() else Path.cwd(), Path.cwd(), Path(sys.argv[0]).parent if sys.argv else Path.cwd()] for base_path in search_paths: if not base_path.exists(): continue for pattern in ['upx-*', 'upx', 'UPX*']: for upx_dir in base_path.glob(pattern): if upx_dir.is_dir(): upx_exe = upx_dir / ('upx.exe' if sys.platform == 'win32' else 'upx') if upx_exe.exists(): return str(upx_dir), upx_dir.name upx_exe = base_path / ('upx.exe' if sys.platform == 'win32' else 'upx') if upx_exe.exists(): return str(base_path), base_path.name return None, None# ============== GUI界面 ==============class PackagerGUI: def __init__(self): self.root = tk.Tk() self.root.title("灵感江Python打包exe工具 v1.0") # 固定窗口大小,确保所有内容完整显示 self.win_width = 580 self.win_height = 780 # 获取屏幕尺寸用于居中 screen_width = self.root.winfo_screenwidth() screen_height = self.root.winfo_screenheight() # 居中显示 x = (screen_width - self.win_width) // 2 y = (screen_height - self.win_height) // 2 self.root.geometry(f"{self.win_width}x{self.win_height}+{x}+{y}") # 设置最小尺寸等于当前尺寸,禁止缩小 self.root.minsize(self.win_width, self.win_height) self.root.maxsize(self.win_width, self.win_height) self.theme_name = 'light' self.theme = Theme.LIGHT self.config = PackConfig() self.packer = None self.log_queue = queue.Queue() self.python_interpreters = [] self.upx_path = None self.is_packing = False self.converted_icon_path = None # 转换后的图标路径 self.progress_var = tk.DoubleVar(value=0) self.status_var = tk.StringVar(value="就绪") self.root.configure(bg=self.theme['bg_primary']) self._create_ui() self._process_log_queue() self.root.after(100, self._auto_detect_python) self.root.after(150, self._auto_detect_upx) def _create_ui(self): # 主框架 - 使用Canvas和Scrollbar实现滚动 self.main_container = tk.Frame(self.root, bg=self.theme['bg_primary']) self.main_container.pack(fill=tk.BOTH, expand=True) # 主内容框架 self.main_frame = tk.Frame(self.main_container, bg=self.theme['bg_primary']) self.main_frame.pack(fill=tk.BOTH, expand=True, padx=25, pady=20) # 标题区域 self._create_header() # 设置卡片(单列布局) self._create_settings_card() # 底部按钮区域 self._create_bottom_panel() # 打包进度弹窗(居中小方块) self._create_progress_popup() def _create_header(self): header = tk.Frame(self.main_frame, bg=self.theme['bg_primary']) header.pack(fill=tk.X, pady=(0, 15)) # Logo和标题 left = tk.Frame(header, bg=self.theme['bg_primary']) left.pack(side=tk.LEFT) # Logo圆形 logo_canvas = tk.Canvas(left, width=42, height=42, bg=self.theme['bg_primary'], highlightthickness=0) logo_canvas.pack(side=tk.LEFT, padx=(0, 12)) logo_canvas.create_oval(4, 4, 38, 38, fill=self.theme['accent'], outline='') logo_canvas.create_text(21, 21, text="Py", font=('Segoe UI', 12, 'bold'), fill='white') # 标题文字 title_frame = tk.Frame(left, bg=self.theme['bg_primary']) title_frame.pack(side=tk.LEFT) self.title_label = tk.Label(title_frame, text="灵感江Python打包exe工具", font=('Microsoft YaHei UI', 15, 'bold'), bg=self.theme['bg_primary'], fg=self.theme['text_primary']) self.title_label.pack(anchor='w') self.subtitle_label = tk.Label(title_frame, text="v1.0 精简美化版", font=('Microsoft YaHei UI', 9), bg=self.theme['bg_primary'], fg=self.theme['text_muted']) self.subtitle_label.pack(anchor='w') # 主题切换按钮 self.theme_btn = tk.Button(header, text="🌙", font=('Segoe UI', 14), bg=self.theme['bg_tertiary'], fg=self.theme['text_primary'], relief='flat', width=3, cursor='hand2', command=self._toggle_theme) self.theme_btn.pack(side=tk.RIGHT) def _create_settings_card(self): # 主卡片容器 card = tk.Frame(self.main_frame, bg=self.theme['bg_card'], highlightthickness=1, highlightbackground=self.theme['border']) card.pack(fill=tk.BOTH, expand=True, pady=(0, 12)) # 内边距容器 content = tk.Frame(card, bg=self.theme['bg_card']) content.pack(fill=tk.BOTH, expand=True, padx=18, pady=15) # ===== 打包引擎 ===== row = tk.Frame(content, bg=self.theme['bg_card']) row.pack(fill=tk.X, pady=5) tk.Label(row, text="打包引擎", font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=self.theme['text_secondary'], width=10, anchor='w').pack(side=tk.LEFT) engine_frame = tk.Frame(row, bg=self.theme['bg_card']) engine_frame.pack(side=tk.LEFT, fill=tk.X, expand=True) self.packer_var = tk.StringVar(value="pyinstaller") pyinstaller_rb = tk.Radiobutton(engine_frame, text="PyInstaller", variable=self.packer_var, value="pyinstaller", font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=self.theme['text_primary'], selectcolor=self.theme['bg_tertiary'], activebackground=self.theme['bg_card']) pyinstaller_rb.pack(side=tk.LEFT) nuitka_rb = tk.Radiobutton(engine_frame, text="Nuitka", variable=self.packer_var, value="nuitka", font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=self.theme['text_primary'], selectcolor=self.theme['bg_tertiary'], activebackground=self.theme['bg_card']) nuitka_rb.pack(side=tk.LEFT, padx=(20, 0)) # ===== Python环境 ===== row = tk.Frame(content, bg=self.theme['bg_card']) row.pack(fill=tk.X, pady=5) tk.Label(row, text="Python", font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=self.theme['text_secondary'], width=10, anchor='w').pack(side=tk.LEFT) self.python_combo = ttk.Combobox(row, font=('Microsoft YaHei UI', 10)) self.python_combo.pack(side=tk.LEFT, fill=tk.X, expand=True) # 选择Python解释器按钮 tk.Button(row, text="选择", font=('Microsoft YaHei UI', 9), bg=self.theme['bg_tertiary'], fg=self.theme['text_primary'], relief='flat', padx=10, cursor='hand2', command=self._browse_python).pack(side=tk.RIGHT, padx=(8, 0)) # 刷新自动检测按钮 tk.Button(row, text="🔄", font=('Segoe UI', 9), bg=self.theme['bg_tertiary'], fg=self.theme['text_primary'], relief='flat', padx=8, cursor='hand2', command=self._auto_detect_python).pack(side=tk.RIGHT, padx=(8, 0)) # ===== 主文件 ===== self.main_file_var = tk.StringVar() self._create_input_row(content, "*主文件", self.main_file_var, "选择", lambda: self._browse_file(self.main_file_var, [("Python文件", "*.py")]), True) # ===== 输出名称 ===== self.output_name_var = tk.StringVar() self._create_input_row(content, "输出名称", self.output_name_var, None, False) # ===== 应用图标(支持多格式)===== self.icon_path_var = tk.StringVar() self.icon_entry = self._create_input_row(content, "应用图标", self.icon_path_var, "选择", lambda: self._browse_icon(), False, "可选可不选,不选自动默认图标") # 图标格式提示 icon_hint = tk.Label(content, text="支持 .ico / .png / .jpg / .webp 格式", font=('Microsoft YaHei UI', 8), bg=self.theme['bg_card'], fg=self.theme['text_muted']) icon_hint.pack(anchor='w', padx=(10, 0), pady=(0, 3)) # ===== 输出路径 ===== self.output_path_var = tk.StringVar() self._create_input_row(content, "输出路径", self.output_path_var, "选择", lambda: self._browse_folder(self.output_path_var), False) # ===== 选项 ===== options_frame = tk.Frame(content, bg=self.theme['bg_card']) options_frame.pack(fill=tk.X, pady=(8, 5)) self.one_file_var = tk.BooleanVar(value=False) self.no_console_var = tk.BooleanVar(value=True) self.speed_mode_var = tk.BooleanVar(value=True) tk.Checkbutton(options_frame, text="单文件", variable=self.one_file_var, font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=self.theme['text_primary'], selectcolor=self.theme['bg_tertiary'], activebackground=self.theme['bg_card'], highlightthickness=0).pack(side=tk.LEFT) tk.Checkbutton(options_frame, text="隐藏控制台", variable=self.no_console_var, font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=self.theme['text_primary'], selectcolor=self.theme['bg_tertiary'], activebackground=self.theme['bg_card'], highlightthickness=0).pack(side=tk.LEFT, padx=(15, 0)) tk.Checkbutton(options_frame, text="极速模式", variable=self.speed_mode_var, font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=self.theme['text_primary'], selectcolor=self.theme['bg_tertiary'], activebackground=self.theme['bg_card'], highlightthickness=0).pack(side=tk.LEFT, padx=(15, 0)) # ===== 分隔线 ===== tk.Frame(content, bg=self.theme['border'], height=1).pack(fill=tk.X, pady=12) # ===== UPX压缩 ===== upx_row = tk.Frame(content, bg=self.theme['bg_card']) upx_row.pack(fill=tk.X, pady=5) self.enable_upx_var = tk.BooleanVar(value=False) tk.Checkbutton(upx_row, text="UPX压缩", variable=self.enable_upx_var, font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=self.theme['text_primary'], selectcolor=self.theme['bg_tertiary'], activebackground=self.theme['bg_card'], highlightthickness=0).pack(side=tk.LEFT) self.upx_status_label = tk.Label(upx_row, text="检测中...", font=('Microsoft YaHei UI', 9), bg=self.theme['bg_card'], fg=self.theme['text_muted']) self.upx_status_label.pack(side=tk.LEFT, padx=(10, 0)) # ===== 附加文件夹 ===== folder_label = tk.Label(content, text="附加文件夹", font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=self.theme['text_secondary']) folder_label.pack(anchor='w', pady=(8, 3)) folder_row = tk.Frame(content, bg=self.theme['bg_card']) folder_row.pack(fill=tk.X, pady=3) self.folder_path_var = tk.StringVar() tk.Entry(folder_row, textvariable=self.folder_path_var, font=('Microsoft YaHei UI', 10), bg=self.theme['bg_input'], fg=self.theme['text_primary'], relief='flat', highlightthickness=1, highlightbackground=self.theme['border']).pack( side=tk.LEFT, fill=tk.X, expand=True) tk.Button(folder_row, text="+", font=('Segoe UI', 12, 'bold'), bg=self.theme['accent'], fg='white', relief='flat', padx=12, cursor='hand2', command=self._add_folder).pack(side=tk.RIGHT, padx=(8, 0)) tk.Button(folder_row, text="📁", font=('Segoe UI', 10), bg=self.theme['bg_tertiary'], fg=self.theme['text_primary'], relief='flat', padx=10, cursor='hand2', command=lambda: self._browse_folder(self.folder_path_var)).pack(side=tk.RIGHT, padx=(8, 0)) # 文件夹列表 list_frame = tk.Frame(content, bg=self.theme['bg_card']) list_frame.pack(fill=tk.BOTH, expand=True, pady=5) self.folder_listbox = tk.Listbox(list_frame, font=('Microsoft YaHei UI', 9), height=3, bg=self.theme['bg_input'], fg=self.theme['text_primary'], selectbackground=self.theme['accent_light'], relief='flat', highlightthickness=1, highlightbackground=self.theme['border']) self.folder_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) tk.Button(content, text="删除选中", font=('Microsoft YaHei UI', 9), bg=self.theme['btn_danger'], fg='white', relief='flat', padx=12, cursor='hand2', command=self._remove_folder).pack(anchor='w', pady=(5, 0)) def _create_input_row(self, parent, label, var, btn_text, btn_cmd, required=False, placeholder=None): row = tk.Frame(parent, bg=self.theme['bg_card']) row.pack(fill=tk.X, pady=5) fg = self.theme['text_error'] if required else self.theme['text_secondary'] tk.Label(row, text=label, font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=fg, width=10, anchor='w').pack(side=tk.LEFT) entry = tk.Entry(row, textvariable=var, font=('Microsoft YaHei UI', 10), bg=self.theme['bg_input'], fg=self.theme['text_primary'], relief='flat', highlightthickness=1, highlightbackground=self.theme['border']) entry.pack(side=tk.LEFT, fill=tk.X, expand=True) # 显示占位符 if placeholder: entry.insert(0, placeholder) entry.config(fg=self.theme['text_muted']) def on_focus_in(event): if entry.get() == placeholder: entry.delete(0, tk.END) entry.config(fg=self.theme['text_primary']) def on_focus_out(event): if not entry.get(): entry.insert(0, placeholder) entry.config(fg=self.theme['text_muted']) entry.bind('<FocusIn>', on_focus_in) entry.bind('<FocusOut>', on_focus_out) if btn_text: tk.Button(row, text=btn_text, font=('Microsoft YaHei UI', 9), bg=self.theme['bg_tertiary'], fg=self.theme['text_primary'], relief='flat', padx=12, cursor='hand2', command=btn_cmd).pack(side=tk.RIGHT, padx=(8, 0)) return entry def _create_bottom_panel(self): # 按钮区域 btn_frame = tk.Frame(self.main_frame, bg=self.theme['bg_primary']) btn_frame.pack(pady=(8, 0)) self.start_btn = tk.Button(btn_frame, text="🚀 开始打包", font=('Microsoft YaHei UI', 12, 'bold'), bg=self.theme['bg_tertiary'], fg=self.theme['text_muted'], relief='flat', padx=40, pady=12, cursor='arrow', state=tk.DISABLED, command=self._start_pack) self.start_btn.pack(side=tk.LEFT, padx=8) self.cancel_btn = tk.Button(btn_frame, text="取消打包", font=('Microsoft YaHei UI', 11), bg=self.theme['bg_tertiary'], fg=self.theme['text_muted'], relief='flat', padx=25, pady=10, cursor='arrow', state=tk.DISABLED, command=self._cancel_pack) self.cancel_btn.pack(side=tk.LEFT, padx=8) # 状态栏 self.status_bar = tk.Frame(self.main_frame, bg=self.theme['bg_tertiary'], height=30) self.status_bar.pack(fill=tk.X, pady=(12, 0)) self.status_bar.pack_propagate(False) self.footer = tk.Label(self.status_bar, text="⚠ 请先选择主文件", font=('Microsoft YaHei UI', 9), bg=self.theme['bg_tertiary'], fg=self.theme['text_warning']) self.footer.pack(side=tk.LEFT, padx=12, pady=5) tk.Label(self.status_bar, text="v1.0", font=('Microsoft YaHei UI', 9), bg=self.theme['bg_tertiary'], fg=self.theme['text_muted']).pack(side=tk.RIGHT, padx=12, pady=5) # 初始化按钮状态 self._update_btn_state() def _update_btn_state(self): """更新按钮状态""" main_file = self.main_file_var.get().strip() has_main_file = bool(main_file and not main_file.startswith('点击选择')) if self.is_packing: # 打包进行中 self.start_btn.config(state=tk.DISABLED, bg=self.theme['bg_tertiary'], fg=self.theme['text_muted'], cursor='arrow') self.cancel_btn.config(state=tk.NORMAL, bg=self.theme['btn_danger'], fg='white', cursor='hand2') else: # 非打包状态 self.cancel_btn.config(state=tk.DISABLED, bg=self.theme['bg_tertiary'], fg=self.theme['text_muted'], cursor='arrow') if has_main_file: # 有主文件,可以打包 self.start_btn.config(state=tk.NORMAL, bg=self.theme['accent'], fg='white', cursor='hand2') self.footer.config(text="✅ 就绪", fg=self.theme['text_secondary']) else: # 没有主文件,不能打包 self.start_btn.config(state=tk.DISABLED, bg=self.theme['bg_tertiary'], fg=self.theme['text_muted'], cursor='arrow') self.footer.config(text="⚠ 请先选择主文件", fg=self.theme['text_warning']) def _create_progress_popup(self): """创建居中的小方块进度弹窗""" # 半透明背景遮罩 self.overlay = tk.Frame(self.root, bg=self.theme['overlay_bg']) # 居中的小方块容器 self.popup_frame = tk.Frame(self.overlay, bg=self.theme['bg_card'], highlightthickness=1, highlightbackground=self.theme['border']) # 弹窗标题 self.popup_header = tk.Frame(self.popup_frame, bg=self.theme['bg_card']) self.popup_header.pack(fill=tk.X, padx=18, pady=(15, 10)) self.popup_title = tk.Label(self.popup_header, text="正在打包...", font=('Microsoft YaHei UI', 12, 'bold'), bg=self.theme['bg_card'], fg=self.theme['text_primary']) self.popup_title.pack(side=tk.LEFT) self.popup_percent = tk.Label(self.popup_header, text="0%", font=('Microsoft YaHei UI', 12, 'bold'), bg=self.theme['bg_card'], fg=self.theme['accent']) self.popup_percent.pack(side=tk.RIGHT) # 进度条 self.progress_container = tk.Frame(self.popup_frame, bg=self.theme['border'], height=10) self.progress_container.pack(fill=tk.X, padx=18, pady=6) self.progress_container.pack_propagate(False) self.popup_progress = tk.Canvas(self.progress_container, height=10, bg=self.theme['progress_bg'], highlightthickness=0) self.popup_progress.pack(fill=tk.BOTH, expand=True, padx=1, pady=1) # 状态文本 self.popup_status = tk.Label(self.popup_frame, text="准备中...", font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=self.theme['text_secondary']) self.popup_status.pack(pady=(2, 10)) # 日志区域(小尺寸) self.log_frame = tk.Frame(self.popup_frame, bg=self.theme['log_bg']) self.log_frame.pack(fill=tk.BOTH, expand=True, padx=18, pady=(0, 15)) self.popup_log = tk.Text(self.log_frame, font=('Consolas', 9), bg=self.theme['log_bg'], fg=self.theme['log_fg'], relief='flat', state=tk.DISABLED, wrap=tk.WORD, padx=10, pady=8, height=8, width=45) self.popup_log.pack(fill=tk.BOTH, expand=True) self.popup_log.tag_configure('INFO', foreground=self.theme['log_info']) self.popup_log.tag_configure('SUCCESS', foreground=self.theme['log_success']) self.popup_log.tag_configure('ERROR', foreground=self.theme['log_error']) self.popup_log.tag_configure('WARNING', foreground=self.theme['log_warning']) # 结果显示区域(初始隐藏) self.result_container = tk.Frame(self.popup_frame, bg=self.theme['bg_card']) self.result_icon = tk.Label(self.result_container, text="", font=('Segoe UI', 36), bg=self.theme['bg_card']) self.result_icon.pack(pady=12) self.result_text = tk.Label(self.result_container, text="", font=('Microsoft YaHei UI', 13, 'bold'), bg=self.theme['bg_card'], fg=self.theme['text_primary']) self.result_text.pack(pady=6) self.result_detail = tk.Label(self.result_container, text="", font=('Microsoft YaHei UI', 10), bg=self.theme['bg_card'], fg=self.theme['text_secondary'], wraplength=300, justify='center') self.result_detail.pack(pady=6) def _show_progress_popup(self): """显示进度弹窗""" # 显示遮罩层 self.overlay.place(x=0, y=0, relwidth=1, relheight=1) # 使用after确保遮罩层已经渲染 self.root.after(10, self._center_popup) # 禁用开始按钮,启用取消按钮 self.start_btn.config(state=tk.DISABLED, bg=self.theme['bg_tertiary']) self.cancel_btn.config(state=tk.NORMAL, bg=self.theme['btn_danger'], fg='white') def _center_popup(self): """将弹窗居中显示""" self.root.update_idletasks() # 获取窗口大小 overlay_width = self.overlay.winfo_width() overlay_height = self.overlay.winfo_height() # 设置弹窗大小 popup_width = 420 popup_height = 350 # 计算居中位置 x = (overlay_width - popup_width) // 2 y = (overlay_height - popup_height) // 2 # 放置弹窗 self.popup_frame.place(x=x, y=y, width=popup_width, height=popup_height) def _hide_progress_popup(self): """隐藏进度弹窗""" self.popup_frame.place_forget() self.overlay.place_forget() # 重置按钮状态 self.start_btn.config(state=tk.NORMAL, bg=self.theme['accent']) self.cancel_btn.config(state=tk.DISABLED, bg=self.theme['bg_tertiary'], fg=self.theme['text_muted']) # 隐藏结果显示 self.result_container.pack_forget() # 恢复进度元素显示 self.popup_header.pack(fill=tk.X, padx=18, pady=(15, 10)) self.popup_title.pack(side=tk.LEFT) self.popup_percent.pack(side=tk.RIGHT) self.progress_container.pack(fill=tk.X, padx=18, pady=6) self.popup_status.pack(pady=(2, 10)) self.log_frame.pack(fill=tk.BOTH, expand=True, padx=18, pady=(0, 15)) def _show_result_in_popup(self, success, message, detail=""): """在弹窗中显示结果""" # 隐藏进度相关元素 self.popup_header.pack_forget() self.progress_container.pack_forget() self.popup_status.pack_forget() self.log_frame.pack_forget() # 显示结果 if success: self.result_icon.config(text="✅", fg=self.theme['text_success']) self.result_text.config(text="打包成功!", fg=self.theme['text_success']) else: self.result_icon.config(text="❌", fg=self.theme['text_error']) self.result_text.config(text="打包失败", fg=self.theme['text_error']) self.result_detail.config(text=detail) self.result_container.pack(fill=tk.BOTH, expand=True, padx=18, pady=12) # 禁用取消按钮 self.cancel_btn.config(state=tk.DISABLED, bg=self.theme['bg_tertiary'], fg=self.theme['text_muted']) # 2秒后自动隐藏 self.root.after(2000, self._hide_progress_popup) def _update_progress_ui(self, value): self.progress_var.set(value) self._draw_popup_progress() def _update_status_ui(self, status): self.status_var.set(status) self.popup_status.config(text=status) self.popup_percent.config(text=f"{int(self.progress_var.get())}%") def _draw_popup_progress(self, event=None): self.popup_progress.delete('all') width = self.popup_progress.winfo_width() height = self.popup_progress.winfo_height() progress = self.progress_var.get() fill_width = int(width * progress / 100) self.popup_percent.config(text=f"{int(progress)}%") if fill_width > 0: self.popup_progress.create_rectangle(0, 0, fill_width, height, fill=self.theme['progress_fg'], outline='') def _log_callback(self, message, level='INFO'): self.log_queue.put((message, level)) def _process_log_queue(self): try: while True: message, level = self.log_queue.get_nowait() self.popup_log.config(state=tk.NORMAL) self.popup_log.insert(tk.END, message + '\n', level) self.popup_log.see(tk.END) self.popup_log.config(state=tk.DISABLED) except queue.Empty: pass self.root.after(100, self._process_log_queue) # ============== 其他方法 ============== def _auto_detect_python(self): self.python_interpreters = PythonDetector.find_python_interpreters() values = [f"{p['name']} - {p['path']}" for p in self.python_interpreters] self.python_combo['values'] = values if values: for i, p in enumerate(self.python_interpreters): if p.get('current'): self.python_combo.current(i) break else: self.python_combo.current(0) def _auto_detect_upx(self): upx_path, upx_name = UPXDetector.find_upx() if upx_path: self.upx_path = upx_path self.upx_status_label.config(text=f"✅ {upx_name}", fg=self.theme['text_success']) else: self.upx_path = None self.upx_status_label.config(text="未检测到", fg=self.theme['text_muted']) def _toggle_theme(self): if self.theme_name == 'light': self.theme_name = 'dark' self.theme = Theme.DARK self.theme_btn.config(text="☀️") else: self.theme_name = 'light' self.theme = Theme.LIGHT self.theme_btn.config(text="🌙") self._apply_theme() def _apply_theme(self): t = self.theme self.root.configure(bg=t['bg_primary']) self.main_frame.configure(bg=t['bg_primary']) self.title_label.configure(bg=t['bg_primary'], fg=t['text_primary']) self.subtitle_label.configure(bg=t['bg_primary'], fg=t['text_muted']) self.theme_btn.configure(bg=t['bg_tertiary'], fg=t['text_primary']) self.footer.configure(bg=t['bg_tertiary'], fg=t['text_secondary']) self.status_bar.configure(bg=t['bg_tertiary']) self.overlay.configure(bg=t['overlay_bg']) self.popup_frame.configure(bg=t['bg_card'], highlightbackground=t['border']) self.popup_header.configure(bg=t['bg_card']) self.popup_title.configure(bg=t['bg_card'], fg=t['text_primary']) self.popup_percent.configure(bg=t['bg_card'], fg=t['accent']) self.progress_container.configure(bg=t['border']) self.popup_progress.configure(bg=t['progress_bg']) self.popup_status.configure(bg=t['bg_card'], fg=t['text_secondary']) self.log_frame.configure(bg=t['log_bg']) self.popup_log.configure(bg=t['log_bg'], fg=t['log_fg']) self.result_container.configure(bg=t['bg_card']) self.result_icon.configure(bg=t['bg_card']) self.result_text.configure(bg=t['bg_card']) self.result_detail.configure(bg=t['bg_card'], fg=t['text_secondary']) def _browse_file(self, var, filetypes): f = filedialog.askopenfilename(filetypes=filetypes) if f: var.set(f) # 如果是主文件选择,更新按钮状态 if var == self.main_file_var: self._update_btn_state() def _browse_folder(self, var): f = filedialog.askdirectory() if f: var.set(f) def _browse_icon(self): """选择图标文件,支持多种格式""" filetypes = [ ("图标文件", "*.ico *.png *.jpg *.jpeg *.webp *.bmp *.gif"), ("ICO 文件", "*.ico"), ("PNG 文件", "*.png"), ("JPG 文件", "*.jpg *.jpeg"), ("WEBP 文件", "*.webp"), ("所有文件", "*.*") ] f = filedialog.askopenfilename(filetypes=filetypes) if f: self.icon_path_var.set(f) def _browse_python(self): """手动选择Python解释器""" if sys.platform == 'win32': filetypes = [("Python解释器", "python.exe"), ("所有文件", "*.*")] else: filetypes = [("Python解释器", "python python3"), ("所有文件", "*.*")] f = filedialog.askopenfilename(filetypes=filetypes, title="选择Python解释器") if f: # 获取Python版本 try: result = subprocess.run([f, '--version'], capture_output=True, text=True, timeout=5) match = re.search(r'Python (\d+\.\d+\.\d+)', result.stdout + result.stderr) if match: version = match.group(1) display_text = f"Python {version} - {f}" else: display_text = f except BaseException: display_text = f # 更新下拉框 current_values = list(self.python_combo['values']) if display_text not in current_values: current_values.insert(0, display_text) self.python_combo['values'] = current_values self.python_combo.set(display_text) def _add_folder(self): folder = self.folder_path_var.get().strip() if folder and Path(folder).exists() and folder not in self.folder_listbox.get(0, tk.END): self.folder_listbox.insert(tk.END, folder) self.folder_path_var.set('') def _remove_folder(self): sel = self.folder_listbox.curselection() if sel: self.folder_listbox.delete(sel[0]) def _get_config_from_ui(self): py_text = self.python_combo.get() python_path = py_text.split(' - ')[-1] if ' - ' in py_text else '' # 处理图标转换 icon_path = self.icon_path_var.get() # 检查是否是占位符文本 placeholder_texts = ['可选', '默认', '不选', '自动', '选择图标'] is_placeholder = any(txt in icon_path for txt in placeholder_texts) if icon_path else False if is_placeholder or not icon_path: icon_path = '' elif icon_path: ext = Path(icon_path).suffix.lower() if ext != '.ico': # 需要转换 if PIL_AVAILABLE: converted_icon = IconConverter.convert_to_ico(icon_path) if converted_icon: self.converted_icon_path = converted_icon icon_path = converted_icon else: # PIL不可用,提示用户 messagebox.showwarning('提示', '选择的是非ICO格式图标,但PIL库未安装。\n图标将不会被应用到打包结果中。') return PackConfig( python_path=python_path, main_file=self.main_file_var.get(), output_name=self.output_name_var.get(), icon_path=icon_path, output_path=self.output_path_var.get(), packer=self.packer_var.get(), one_file=self.one_file_var.get(), no_console=self.no_console_var.get(), speed_mode=self.speed_mode_var.get(), enable_upx=self.enable_upx_var.get() and self.upx_path is not None, upx_path=self.upx_path or "", add_folders=list(self.folder_listbox.get(0, tk.END)), ) def _start_pack(self): config = self._get_config_from_ui() if not config.main_file: messagebox.showerror('错误', '请选择主Python文件') return # 设置打包状态 self.is_packing = True self._update_btn_state() # 重置进度弹窗 self.result_container.pack_forget() self.popup_log.config(state=tk.NORMAL) self.popup_log.delete(1.0, tk.END) self.popup_log.config(state=tk.DISABLED) self.progress_var.set(0) self.popup_percent.config(text="0%") # 显示进度弹窗 self._show_progress_popup() self.footer.config(text='🔄 打包中...') # 选择打包器 if config.packer == 'nuitka': self.packer = NuitkaPacker(config, self._log_callback, lambda v: self.root.after(0, lambda: self._update_progress_ui(v)), lambda s: self.root.after(0, lambda: self._update_status_ui(s))) else: self.packer = PyInstallerPacker(config, self._log_callback, lambda v: self.root.after(0, lambda: self._update_progress_ui(v)), lambda s: self.root.after(0, lambda: self._update_status_ui(s))) def pack_thread(): try: success = self.packer.pack() self.root.after(0, lambda: self.progress_var.set(100)) self.root.after(0, self._draw_popup_progress) if success: main_path = Path(config.main_file) output_dir = Path(config.output_path or main_path.parent) / 'dist' output_name = config.output_name or main_path.stem output_file = output_dir / f"{output_name}.exe" self.root.after(0, lambda: self.footer.config(text='✅ 打包完成')) self.root.after(0, lambda: self._show_result_in_popup( True, "打包成功!", f"输出: {output_file}")) else: self.root.after(0, lambda: self.footer.config(text='❌ 打包失败')) self.root.after(0, lambda: self._show_result_in_popup( False, "打包失败", "请查看日志了解详情")) except Exception as e: self.root.after(0, lambda: self.footer.config(text='❌ 打包失败')) self.root.after(0, lambda: self._show_result_in_popup( False, "打包失败", str(e))) finally: self.is_packing = False self.root.after(0, self._update_btn_state) self.packer = None # 清理临时图标文件 if self.converted_icon_path and os.path.exists(self.converted_icon_path): try: os.remove(self.converted_icon_path) except BaseException: pass self.converted_icon_path = None threading.Thread(target=pack_thread, daemon=True).start() def _cancel_pack(self): if self.packer: self.packer.cancel() self.is_packing = False self._update_btn_state() self._show_result_in_popup(False, "已取消", "打包已被用户取消") self.footer.config(text='⏹️ 已取消') def run(self): self.root.mainloop()if __name__ == '__main__': app = PackagerGUI() app.run()