diff --git a/qpypack/main.py b/qpypack/main.py index 4f9f8e8..b557512 100644 --- a/qpypack/main.py +++ b/qpypack/main.py @@ -9,6 +9,8 @@ import stat import json import locale import math +import wave +import struct import threading import configparser from pathlib import Path @@ -21,7 +23,7 @@ if os.name == 'nt': else: winreg = None -os.environ["QT_LOGGING_RULES"] = "qt.text.font.db=false" +os.environ["QT_LOGGING_RULES"] = "qt.text.font.db=false;qt.multimedia*=false" from PySide6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QPushButton, QLabel, QLineEdit, QFileDialog, QCheckBox, @@ -29,16 +31,23 @@ from PySide6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayo QGraphicsOpacityEffect, QGridLayout, QTabWidget, QMessageBox, QInputDialog, QFileIconProvider, QSizePolicy, QScrollArea, QGraphicsDropShadowEffect, QSpinBox, QListWidget, QListWidgetItem, - QListView, QStyledItemDelegate, QMenu) + QListView, QStyledItemDelegate, QMenu, QTableWidget, QTableWidgetItem, + QHeaderView) from PySide6.QtCore import (Qt, QThread, Signal, QPropertyAnimation, QEasingCurve, QParallelAnimationGroup, QFileInfo, QVariantAnimation, - QTimer, QPointF, QRectF, QRect, QSize) + QTimer, QPointF, QRectF, QRect, QSize, QUrl) from PySide6.QtGui import (QFont, QDragEnterEvent, QDropEvent, QIcon, QPixmap, QPainter, QColor, QPen, QImage, QImageWriter) from PySide6.QtSvg import QSvgRenderer +try: + from PySide6.QtMultimedia import QSoundEffect + HAS_QT_AUDIO = True +except ImportError: + HAS_QT_AUDIO = False + __app_name__ = "QPyPack" -__version__ = "2.5.7" +__version__ = "2.5.8" __author__ = "QwejayHuang" __company__ = "QwejayHuang" __description__ = "基于 PyInstaller 与 Nuitka 的跨平台 Python 应用打包构建工具" @@ -75,10 +84,60 @@ PYPI_MIRRORS = [ ] 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' + 'acoustid': 'pyacoustid', + 'cv2': 'opencv-python', + 'PIL': 'pillow', + 'Pillow': 'pillow', + 'fitz': 'pymupdf', + 'skimage': 'scikit-image', + 'vlc': 'python-vlc', + 'pyzbar': 'pyzbar', + + 'docx': 'python-docx', + 'pptx': 'python-pptx', + 'bs4': 'beautifulsoup4', + 'barcode': 'python-barcode', + 'pdfplumber': 'pdfplumber', + + 'win32com': 'pywin32', 'win32api': 'pywin32', 'win32con': 'pywin32', + 'win32gui': 'pywin32', 'win32clipboard': 'pywin32', 'win32print': 'pywin32', + 'win32file': 'pywin32', 'win32security': 'pywin32', 'win32process': 'pywin32', + 'win32evtlog': 'pywin32', 'win32service': 'pywin32', 'win32pipe': 'pywin32', + 'win32net': 'pywin32', 'win32crypt': 'pywin32', 'pythoncom': 'pywin32', + 'pywintypes': 'pywin32', + 'serial': 'pyserial', + 'usb': 'pyusb', + 'bluetooth': 'pybluez', + + 'sklearn': 'scikit-learn', + 'yaml': 'pyyaml', + 'dateutil': 'python-dateutil', + 'jwt': 'PyJWT', + 'Crypto': 'pycryptodome', + 'crypto': 'pycryptodome', + 'OpenGL': 'PyOpenGL', + 'dns': 'dnspython', + + 'wx': 'wxPython', + 'desktop_notifier': 'desktop-notifier', + + 'dotenv': 'python-dotenv', + 'telegram': 'python-telegram-bot', + 'websocket': 'websocket-client', + 'git': 'GitPython', + 'github': 'PyGithub', + 'gitlab': 'python-gitlab', + 'discord': 'discord.py', + 'paho': 'paho-mqtt', + 'socketio': 'python-socketio', + 'engineio': 'python-engineio', + 'kafka': 'kafka-python', + + 'OpenSSL': 'pyOpenSSL', + 'ldap': 'python-ldap', + 'magic': 'python-magic', + 'slugify': 'python-slugify', + 'snappy': 'python-snappy' } def load_config(): @@ -105,7 +164,18 @@ def load_config(): except: pass else: config.read(CONFIG_FILE, encoding='utf-8') - if 'Mappings' not in config: config['Mappings'] = DEFAULT_MAPPINGS + if 'Mappings' not in config: + config['Mappings'] = DEFAULT_MAPPINGS + else: + updated_map = False + for k, v in DEFAULT_MAPPINGS.items(): + if k not in config['Mappings']: + config['Mappings'][k] = v + updated_map = True + if updated_map: + try: save_config(config) + except: pass + if 'Settings' not in config: config['Settings'] = {} default_updates = { 'pip_index': 'https://pypi.tuna.tsinghua.edu.cn/simple', @@ -142,6 +212,92 @@ def save_config(config): config.write(f) except: pass +def create_pleasant_audio_files(): + success_wav = _CONFIG_DIR / "sound_success.wav" + failure_wav = _CONFIG_DIR / "sound_failure.wav" + sample_rate = 44100 + + if not success_wav.exists(): + try: + notes = [(523.25, 0.0, 0.35), (659.25, 0.09, 0.35), (784.00, 0.18, 0.35), (1046.50, 0.27, 0.55)] + total_duration = 0.75 + n_samples = int(sample_rate * total_duration) + samples = [0.0] * n_samples + + for freq, start, dur in notes: + start_idx = int(start * sample_rate) + dur_samples = int(dur * sample_rate) + for i in range(dur_samples): + idx = start_idx + i + if idx >= n_samples: break + t = i / sample_rate + env = math.sin(math.pi * min(1.0, t / 0.025)) * math.exp(-4.2 * (t / dur)) + val = (math.sin(2 * math.pi * freq * t) * 0.75 + + math.sin(2 * math.pi * freq * 2 * t) * 0.2 + + math.sin(2 * math.pi * freq * 3 * t) * 0.05) * env + samples[idx] += val * 0.28 + + with wave.open(success_wav.as_posix(), 'w') as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + packed = b''.join(struct.pack('= n_samples: break + t = i / sample_rate + env = math.sin(math.pi * min(1.0, t / 0.02)) * math.exp(-3.2 * (t / dur)) + val = (math.sin(2 * math.pi * freq * t) * 0.8 + + math.sin(2 * math.pi * freq * 2 * t) * 0.2) * env + samples[idx] += val * 0.32 + + with wave.open(failure_wav.as_posix(), 'w') as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + packed = b''.join(struct.pack('PyInstaller:构建速度快、兼容性强,但单文件体积偏大。适合快速迭代与大多数常规应用。" + ) + else: + self.engine_desc_lbl.setText( + "Nuitka:编译为原生 C/C++ 二进制,体积更小且性能更好、支持源码防反编译;但构建耗时较长。适合商业级项目发布。" + ) + if getattr(self, 'upx_check', None) is not None and getattr(self, 'upx_path_container', None) is not None: - engine = self.engine_combo.currentText() is_pyi = (engine == "PyInstaller") self.upx_check.setVisible(is_pyi) self.upx_path_container.setVisible(is_pyi and self.upx_check.isChecked()) @@ -1904,6 +2229,14 @@ class SettingsPanel(QWidget): except Exception as e: QMessageBox.warning(self, "分析异常", f"AST 语法树解析过程中发生异常: {e}") + def on_resources_dropped(self, paths): + for p_str in paths: + p = Path(p_str).resolve() + if p.is_file(): + self._add_resource_item('file', p.as_posix(), ".") + elif p.is_dir(): + self._add_resource_item('dir', p.as_posix(), p.name) + def add_resource_files(self): files, _ = QFileDialog.getOpenFileNames(self, "选择附加文件", "", "All Files (*)") for f in files: @@ -1993,7 +2326,7 @@ class ScriptAnalysisThread(QThread): class PackingThread(QThread): progress = Signal(str) - finished = Signal(bool, str) + finished = Signal(bool, str, list) def __init__(self, params): super().__init__() @@ -2204,6 +2537,7 @@ class PackingThread(QThread): is_temp = False build_script_path = None ext = ".exe" if os.name == "nt" else "" + failed_packages = [] try: self.progress.emit("[INFO] 正在初始化隔离构建环境...") @@ -2212,7 +2546,7 @@ class PackingThread(QThread): 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}") + 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() @@ -2238,7 +2572,7 @@ class PackingThread(QThread): self.progress.emit("[INFO] 正在创建虚拟环境...") self.venv_dir = Path(tempfile.mkdtemp(prefix="qpypack_env_")).resolve() if not self.run_cmd([system_python_exe, "-m", "venv", self.venv_dir.as_posix()]): - return self.finished.emit(False, "[ERROR] 虚拟环境 (virtualenv) 创建失败。当前 Python 环境可能缺失必要模块或权限受限。") + return self.finished.emit(False, "[ERROR] 虚拟环境 (virtualenv) 创建失败。当前 Python 环境可能缺失必要模块或权限受限。", []) python_exe = (self.venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")).as_posix() self.progress.emit("[INFO] 正在同步并升级 pip 包管理器...") @@ -2253,12 +2587,13 @@ class PackingThread(QThread): elif engine == "PyInstaller" and self.params.get('pyi_version'): engine_pkg = f"pyinstaller=={self.params['pyi_version']}" - self.progress.emit(f"[INFO] 正在安装编译引擎 [{engine_pkg}] 及核心编译依赖...") + self.progress.emit(f"[INFO] 正在安装构建引擎 [{engine_pkg}] 及核心编译依赖...") core_pkgs = [engine_pkg] if engine == "PyInstaller": core_pkgs.append("pillow") elif engine == "Nuitka": core_pkgs.append("zstandard") + self.progress.emit("[INFO] Nuitka 提示:若首次构建提示下载 GCC/MinGW 编译器,请保持网络正常。") self.run_pip_install(python_exe, ["-q"] + core_pkgs) if self.params.get('use_reqs'): @@ -2330,22 +2665,32 @@ class PackingThread(QThread): for k, v in config['Mappings'].items(): known_mappings[k] = v - local_files = {p.stem.lower() for p in script_dir.glob("*")} + known_mappings_lower = {k.lower(): v for k, v in known_mappings.items()} + + local_modules = set() + for p in script_dir.rglob("*"): + if any(part.startswith('.') or part.lower() in ('venv', 'env', 'site-packages', 'node_modules', '__pycache__') for part in p.parts): + continue + if p.is_file() and p.suffix.lower() == '.py': + local_modules.add(p.stem.lower()) + elif p.is_dir(): + local_modules.add(p.name.lower()) + ast_pkgs = [ - known_mappings.get(m, m) + known_mappings_lower.get(m.lower(), m) for m in script_imports - if m not in STD_LIBS and m.lower() not in local_files + if m not in STD_LIBS and m.lower() not in local_modules ] if ast_pkgs: self.progress.emit(f"[INFO] 依赖安装 [3/3]: 正在通过 AST 静态扫描提取隐式依赖...") self.progress.emit(f"[INFO] 正在解析并安装隐式导入依赖: {', '.join(ast_pkgs)}") - if not self.run_pip_install(python_exe, ["-q"] + ast_pkgs): - self.progress.emit("[WARN] 批量依赖同步发生冲突,正在转换为顺序安全安装机制...") - for pkg in ast_pkgs: - self.run_pip_install(python_exe, ["-q", pkg]) + for pkg in ast_pkgs: + if not self.run_pip_install(python_exe, ["-q", pkg]): + failed_packages.append(pkg) + self.progress.emit(f"[ERROR] ⚠️ 警告:依赖库 [{pkg}] 安装失败!可能导致打包后的软件运行时崩溃。") - if self._is_cancelled: return self.finished.emit(False, "[INFO] 构建已被终止。") + if self._is_cancelled: return self.finished.emit(False, "[INFO] 构建已被终止。", failed_packages) self.progress.emit(f"[INFO] 正在启动 {engine} 引擎,开始编译二进制文件...") cmd = [] @@ -2503,7 +2848,7 @@ class PackingThread(QThread): cmd.append(script_posix) success = self.run_cmd(cmd, cwd=script_dir.as_posix()) - if self._is_cancelled: return self.finished.emit(False, "[INFO] 构建已被终止。") + if self._is_cancelled: return self.finished.emit(False, "[INFO] 构建已被终止。", failed_packages) self.progress.emit("[INFO] 编译核心完成,正在提取并归档编译文件...") @@ -2558,7 +2903,7 @@ class PackingThread(QThread): log_file.write_text('\n'.join(self.all_raw_logs), encoding='utf-8') self.progress.emit(f"[INFO] 编译日志已导出至: {log_file.as_posix()}") except: pass - self.finished.emit(True, f"[SUCCESS] 编译已完成,输出路径: {final_out.resolve().as_posix()}") + self.finished.emit(True, f"[SUCCESS] 编译已完成,输出路径: {final_out.resolve().as_posix()}", failed_packages) else: err_info = self.detect_python_syntax_errors() if err_info["is_code_error"]: @@ -2575,13 +2920,13 @@ class PackingThread(QThread): self.progress.emit("\n" + "!"*10 + " [诊断回溯: 以下为最后段编译运行日志汇总] " + "!"*10) self.progress.emit('\n'.join(self.all_raw_logs[-100:])) msg = "[FAILED] 编译异常中断,请参阅上方日志以定位构建异常。" - self.finished.emit(False, msg) + self.finished.emit(False, msg, failed_packages) except Exception as e: if self.params.get('concise_log', True) and self.all_raw_logs: self.progress.emit("\n" + "!"*10 + " [系统诊断: 原始日志] " + "!"*10) self.progress.emit('\n'.join(self.all_raw_logs[-100:])) - self.finished.emit(False, f"[ERROR] 构建流程发生致命异常: {str(e)}") + self.finished.emit(False, f"[ERROR] 构建流程发生致命异常: {str(e)}", failed_packages) finally: if is_temp and build_script_path and build_script_path.exists(): try: @@ -2632,7 +2977,7 @@ class MainWindow(QMainWindow): def init_style(self): self.setWindowTitle(f"{__app_name__} {__version__}") - self.setMinimumSize(700, 600) + self.setMinimumSize(700, 670) self.resize(740, 640) icon_path = get_resource_path("icon.ico") @@ -2688,10 +3033,8 @@ class MainWindow(QMainWindow): self.log = QTextEdit() self.log.setReadOnly(True) self.log.setFixedHeight(120) - self.log.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.log.customContextMenuRequested.connect(self.show_log_context_menu) - log_lay.addWidget(self.log) self.log_container.hide() layout.addWidget(self.log_container) @@ -3035,12 +3378,11 @@ class MainWindow(QMainWindow): self.update_ui_state("building") self.drop_area.start_build_anim() - def on_pack_finished(self, success, msg): + def on_pack_finished(self, success, msg, failed_pkgs=None): self.append_log("\n" + "━"*50 + "\n" + msg) self.drop_area.stop_build_anim() - if self.settings_panel.sound_notify_check.isChecked(): - play_alert(success) + play_alert(success) if success: icon_path = self.settings_panel.icon_edit.text().strip() @@ -3052,6 +3394,16 @@ class MainWindow(QMainWindow): self.set_status(" 状态: 构建失败") self.update_ui_state("failed") + if failed_pkgs: + pkgs_str = ", ".join(failed_pkgs) + QMessageBox.warning( + self, + "依赖缺失隐患警告", + f"构建任务已结束,但在预构建阶段以下第三方依赖库安装失败:\n\n 👉 {pkgs_str}\n\n" + f"⚠️ 提示:生成的可执行文件可能因缺少依赖,在运行时发生 ModuleNotFoundError 崩溃!\n" + f"建议检查是否写错 import 名称,或在设置中补充正确的包名映射后重新构建。" + ) + def open_dist(self): if self.settings_panel.out_mode_combo.currentIndex() == 1 and self.settings_panel.out_dir_edit.text().strip(): target = Path(self.settings_panel.out_dir_edit.text().strip()) @@ -3099,28 +3451,6 @@ class MainWindow(QMainWindow): self.drop_area.label.setText(clean_text) self.drop_area.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }") - def append_log(self, msg): - self.log.append(msg) - self.log.ensureCursorVisible() - - for line in msg.split('\n'): - line = line.strip() - if not line: - continue - - if line.startswith(("[INFO]", "[WARN]", "[SUCCESS]", "[FAILED]", "[ERROR]")): - clean_text = line - for prefix in ("[INFO]", "[WARN]", "[SUCCESS]", "[FAILED]", "[ERROR]"): - if clean_text.startswith(prefix): - clean_text = clean_text[len(prefix):].strip() - break - - if self.current_state == "building" and clean_text: - if len(clean_text) > 35: - clean_text = clean_text[:32] + "..." - self.drop_area.label.setText(clean_text) - self.drop_area.label.setStyleSheet("QLabel { background: transparent; color: #1A73E8; font-size: 16px; font-weight: bold; border: none; }") - def show_log_context_menu(self, pos): menu = QMenu(self) menu.setStyleSheet("""