""" First GUI — PySide6 版 Author: Spade-sec | https://github.com/Spade-sec/First """ import asyncio import json import multiprocessing import os import queue import subprocess import sys import threading from PySide6.QtCore import ( Qt, QTimer, QPropertyAnimation, QEasingCurve, Property, QRect, Signal, QPoint, QUrl, ) from PySide6.QtGui import QPainter, QColor, QFont, QIcon, QPixmap, QDesktopServices from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QFrame, QPushButton, QScrollArea, QTextEdit, QTreeWidget, QTreeWidgetItem, QProgressBar, QStackedWidget, QMenu, QHeaderView, QAbstractItemView, QFileDialog, QInputDialog, QTabWidget, QTableWidget, QTableWidgetItem, QDialog, QSizePolicy, QCheckBox, ) from src.cli import CliOptions, CDP_PORT from src.logger import Logger from src.engine import DebugEngine from src.navigator import MiniProgramNavigator from src.cloud_audit import CloudAuditor # ══════════════════════════════════════════ # 配色 # ══════════════════════════════════════════ _D = dict( bg="#1c1c24", card="#262632", input="#181820", sidebar="#111118", sb_hover="#1c1c28", sb_active="#222232", border="#303040", border2="#3a3a4c", text1="#e8e8f0", text2="#8888a0", text3="#5c5c6c", text4="#3c3c4c", accent="#4ade80", accent2="#22c55e", success="#4ade80", error="#f87171", warning="#fbbf24", ) _L = dict( bg="#f2f2f6", card="#ffffff", input="#eeeef2", sidebar="#ffffff", sb_hover="#f2f2f6", sb_active="#e6e6ea", border="#d8d8dc", border2="#c8c8cc", text1="#1a1a22", text2="#6e6e78", text3="#9e9ea8", text4="#c0c0c8", accent="#16a34a", accent2="#15803d", success="#16a34a", error="#dc2626", warning="#ca8a04", ) _TH = {"dark": _D, "light": _L} _FN = "Microsoft YaHei UI" _FM = "Consolas" _MENU = [ ("control", "◉", "控制台"), ("navigator", "⬡", "路由导航"), ("hook", "◈", "Hook"), ("targets", "◎", "服务目标"), ("cloud", "☁", "云扫描"), ("extract", "◆", "敏感信息提取"), ("vconsole", "◇", "调试开关"), ("logs", "≡", "运行日志"), ] # ══════════════════════════════════════════ # 配置持久化 # ══════════════════════════════════════════ _BASE_DIR = os.path.dirname(os.path.abspath(__file__)) if getattr(sys, 'frozen', False): _BASE_DIR = os.path.dirname(sys.executable) _CFG_FILE = os.path.join(_BASE_DIR, "gui_config.json") os.makedirs(os.path.join(_BASE_DIR, "hook_scripts"), exist_ok=True) def _load_cfg(): try: with open(_CFG_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: return {} def _save_cfg(data): try: with open(_CFG_FILE, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) except Exception: pass # ══════════════════════════════════════════ # QSS 主题 # ══════════════════════════════════════════ def build_qss(tn): c = _TH[tn] sel_bg = "#1e3a2a" if tn == "dark" else "#d4edda" sel_fg = "#a0f0c0" if tn == "dark" else "#155724" hdr_bg = "#222230" if tn == "dark" else "#e8e8ec" row_bg = c["input"] return f""" /* ── 全局 ── */ QMainWindow, QWidget#central {{ background: {c['bg']}; }} /* ── 侧栏 ── */ QFrame#sidebar {{ background: {c['sidebar']}; }} QFrame#sidebar QLabel {{ background: transparent; }} QFrame#sb_head {{ background: {c['sidebar']}; }} QLabel#sb_logo {{ color: {c['text1']}; font-size: 13px; font-weight: bold; background: transparent; }} QFrame#sb_hline {{ background: {c['border']}; max-height: 1px; min-height: 1px; }} QLabel#sb_theme {{ color: {c['text3']}; background: transparent; padding: 4px 12px; }} QLabel#sb_theme:hover {{ color: {c['text1']}; }} /* ── 菜单项 ── */ QFrame.sb_item {{ background: {c['sidebar']}; border-radius: 8px; padding: 8px 10px; }} QFrame.sb_item:hover {{ background: {c['sb_hover']}; }} QFrame.sb_item_active {{ background: {c['sb_active']}; border-radius: 8px; padding: 8px 10px; }} QFrame.sb_item QLabel.sb_icon {{ color: {c['text3']}; background: transparent; }} QFrame.sb_item QLabel.sb_name {{ color: {c['text2']}; background: transparent; }} QFrame.sb_item_active QLabel.sb_icon {{ color: {c['accent']}; background: transparent; }} QFrame.sb_item_active QLabel.sb_name {{ color: {c['text1']}; background: transparent; }} /* ── 分割线 ── */ QFrame#vline {{ background: {c['border']}; max-width: 1px; min-width: 1px; }} QFrame#hdr_line {{ background: {c['border']}; max-height: 1px; min-height: 1px; }} /* ── 标题 ── */ QLabel#page_title {{ color: {c['text1']}; font-size: 17px; font-weight: bold; padding-left: 24px; background: transparent; }} /* ── 圆角卡片 ── */ QFrame.card {{ background: {c['card']}; border-radius: 12px; border: none; }} QFrame.card QLabel {{ background: transparent; }} QFrame.card QLabel.title {{ color: {c['text1']}; font-weight: bold; font-size: 11px; }} QFrame.card QLabel.subtitle {{ color: {c['text2']}; font-size: 9px; }} /* ── 通用 Label ── */ QLabel {{ color: {c['text2']}; background: transparent; }} QLabel.bold {{ color: {c['text1']}; font-weight: bold; }} QLabel.muted {{ color: {c['text3']}; }} QLabel.accent {{ color: {c['accent']}; }} /* ── 按钮 ── */ QPushButton {{ background: {c['accent']}; color: #111118; border: none; border-radius: 8px; padding: 5px 16px; font-size: 10px; }} QPushButton:hover {{ background: {c['accent2']}; }} QPushButton:disabled {{ background: {"#1a3a2a" if tn == "dark" else "#b0dfc0"}; color: {"#3a6a4a" if tn == "dark" else "#5a8a6a"}; }} /* 表格内按钮 — 清除全局样式,由 inline setStyleSheet 控制 */ QTableWidget QPushButton {{ background: transparent; color: {c['text2']}; border: none; border-radius: 6px; padding: 4px 12px; font-size: 12px; }} QTableWidget QPushButton:hover {{ background: transparent; }} /* ── 输入框 ── */ QLineEdit {{ background: {c['input']}; color: {c['text1']}; border: none; border-radius: 10px; padding: 6px 12px; font-size: 10px; selection-background-color: {c['accent']}; selection-color: #111118; }} QLineEdit:focus {{ border: 1px solid {c['accent']}; }} /* ── 文本框 ── */ QTextEdit {{ background: {c['input']}; color: {c['accent']}; border: none; border-radius: 8px; padding: 10px 14px; font-family: {_FM}; font-size: 10px; selection-background-color: {c['accent']}; selection-color: #111118; }} /* ── 树形控件 ── */ QTreeWidget {{ background: {c['card']}; color: {c['text2']}; border: none; font-size: 10px; outline: 0; }} QTreeWidget::item {{ padding: 4px 8px; border: none; text-align: left; }} QTreeWidget::item:selected {{ background: {sel_bg}; color: {sel_fg}; }} QTreeWidget::item:hover {{ background: {c['sb_hover']}; }} QHeaderView::section {{ background: {hdr_bg}; color: {c['text1']}; border: none; padding: 4px 8px; font-weight: bold; font-size: 10px; text-align: left; }} /* ── 进度条 ── */ QProgressBar {{ background: {c['border']}; border: none; border-radius: 4px; height: 6px; text-align: center; }} QProgressBar::chunk {{ background: {c['accent']}; border-radius: 4px; }} /* ── 滚动条 ── */ QScrollBar:vertical {{ background: transparent; width: 6px; margin: 0; }} QScrollBar::handle:vertical {{ background: {"#3a6a4a" if tn == "dark" else "#8fc4a0"}; border-radius: 3px; min-height: 20px; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }} QScrollBar:horizontal {{ background: transparent; height: 6px; }} QScrollBar::handle:horizontal {{ background: {"#3a6a4a" if tn == "dark" else "#8fc4a0"}; border-radius: 3px; min-width: 20px; }} QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }} QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{ background: transparent; }} /* ── 表格控件 ── */ QTableWidget {{ background: {c['card']}; color: {c['text2']}; border: none; font-size: 12px; outline: 0; gridline-color: {c['border']}; }} QTableWidget::item {{ padding: 6px 10px; border: none; background: {c['card']}; color: {c['text2']}; }} QTableWidget::item:selected {{ background: {sel_bg}; color: {sel_fg}; }} QTableWidget QHeaderView::section {{ background: {hdr_bg}; color: {c['text1']}; border: none; padding: 6px 10px; font-weight: bold; font-size: 12px; }} /* ── 滚动区域 ── */ QScrollArea {{ background: transparent; border: none; }} QScrollArea > QWidget > QWidget {{ background: transparent; }} /* ── 右键菜单 ── */ QMenu {{ background: {c['card']}; color: {c['text1']}; border: 1px solid {c['border']}; border-radius: 6px; padding: 4px; }} QMenu::item {{ padding: 6px 20px; border-radius: 4px; }} QMenu::item:selected {{ background: {c['accent']}; color: #ffffff; }} QMenu::separator {{ height: 1px; background: {c['border']}; margin: 4px 8px; }} /* ── QCheckBox ── */ QCheckBox {{ color: {c['text1']}; background: transparent; spacing: 5px; }} QCheckBox::indicator {{ width: 14px; height: 14px; border-radius: 3px; border: 1px solid {c['border2']}; background: {c['input']}; }} QCheckBox::indicator:checked {{ background: {c['accent']}; border-color: {c['accent']}; image: none; }} QCheckBox::indicator:hover {{ border-color: {c['accent']}; }} /* ── Hook 行 ── */ QFrame.hook_row {{ background: {row_bg}; border-radius: 8px; }} QFrame.hook_row QLabel {{ background: transparent; }} QLabel.js_badge {{ background: {c['accent']}; color: {"#ffffff" if tn == "dark" else "#111118"}; font-weight: bold; font-size: 9px; padding: 2px 6px; border-radius: 4px; }} /* ── Completer popup ── */ QListView {{ background: {c['input']}; color: {c['text1']}; border: 1px solid {c['border']}; border-radius: 6px; outline: 0; }} QListView::item:selected {{ background: {c['accent']}; color: #111118; }} """ # ══════════════════════════════════════════ # 自定义控件 # ══════════════════════════════════════════ class ToggleSwitch(QWidget): toggled = Signal(bool) def __init__(self, checked=False, parent=None): super().__init__(parent) self._checked = checked self._thumb_pos = 1.0 if checked else 0.0 self._on_color = QColor("#4ade80") self._off_color = QColor("#3c3c4c") self._thumb_color = QColor("#ffffff") self.setFixedSize(44, 24) self.setCursor(Qt.PointingHandCursor) self._anim = QPropertyAnimation(self, b"thumbPos") self._anim.setDuration(150) self._anim.setEasingCurve(QEasingCurve.OutCubic) def isChecked(self): return self._checked def setChecked(self, v): if self._checked == v: return self._checked = v self._anim.stop() self._anim.setStartValue(self._thumb_pos) self._anim.setEndValue(1.0 if v else 0.0) self._anim.start() self.toggled.emit(v) def _get_thumb_pos(self): return self._thumb_pos def _set_thumb_pos(self, v): self._thumb_pos = v self.update() thumbPos = Property(float, _get_thumb_pos, _set_thumb_pos) def mousePressEvent(self, e): self.setChecked(not self._checked) def paintEvent(self, e): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) w, h = self.width(), self.height() r = h / 2 # track track_color = QColor(self._on_color) if self._checked else QColor(self._off_color) p.setPen(Qt.NoPen) p.setBrush(track_color) p.drawRoundedRect(0, 0, w, h, r, r) # thumb tr = r - 3 cx = r + self._thumb_pos * (w - 2 * r) p.setBrush(self._thumb_color) p.drawEllipse(QPoint(int(cx), int(r)), int(tr), int(tr)) def set_colors(self, on, off): self._on_color = QColor(on) self._off_color = QColor(off) self.update() class AnimatedStackedWidget(QStackedWidget): """Page switch with a lightweight vertical slide animation. Uses QPropertyAnimation on widget geometry instead of QGraphicsOpacityEffect, which forces expensive off-screen compositing of the entire subtree (causing visible lag on heavy pages like the cloud-scan QTreeWidget). """ def __init__(self, parent=None): super().__init__(parent) self._anim = None def setCurrentIndexAnimated(self, idx): if idx == self.currentIndex(): return old_idx = self.currentIndex() old_widget = self.currentWidget() new_widget = self.widget(idx) if new_widget is None: self.setCurrentIndex(idx) return # Determine slide direction: down when going forward, up when back h = self.height() offset = h // 4 # slide only a quarter of the height for subtlety start_y = offset if idx > old_idx else -offset # Immediately switch the page (no off-screen compositing) self.setCurrentIndex(idx) # Animate just the position of the new page final_rect = new_widget.geometry() start_rect = QRect(final_rect) start_rect.moveTop(final_rect.top() + start_y) anim = QPropertyAnimation(new_widget, b"geometry") anim.setDuration(150) anim.setStartValue(start_rect) anim.setEndValue(final_rect) anim.setEasingCurve(QEasingCurve.OutCubic) self._anim = anim # prevent GC anim.start() class StatusDot(QWidget): def __init__(self, parent=None): super().__init__(parent) self.setFixedSize(10, 10) self._color = QColor("#3c3c4c") def set_color(self, color): self._color = QColor(color) self.update() def paintEvent(self, e): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) p.setPen(Qt.NoPen) p.setBrush(self._color) p.drawEllipse(1, 1, 8, 8) # ══════════════════════════════════════════ # 辅助函数 # ══════════════════════════════════════════ def _make_card(): f = QFrame() f.setProperty("class", "card") return f def _make_label(text, bold=False, muted=False, mono=False): l = QLabel(text) if bold: l.setProperty("class", "bold") elif muted: l.setProperty("class", "muted") if mono: l.setFont(QFont(_FM, 10)) return l def _make_btn(text, callback=None): b = QPushButton(text) if callback: b.clicked.connect(callback) return b def _make_entry(placeholder="", width=None): e = QLineEdit() e.setPlaceholderText(placeholder) if width: e.setFixedWidth(width) return e # ══════════════════════════════════════════ # 主窗口 # ══════════════════════════════════════════ class App(QMainWindow): def __init__(self): super().__init__() self._os_tag = "macOS" if sys.platform == "darwin" else "Windows" self.setWindowTitle(f"First-{self._os_tag}") _ico = os.path.join(_BASE_DIR, "icon.png") if os.path.exists(_ico): self.setWindowIcon(QIcon(_ico)) self.resize(960, 620) self.setMinimumSize(780, 500) self._cfg = _load_cfg() self._tn = self._cfg.get("theme", "dark") self._pg = "control" self._running = False self._loop = self._loop_th = self._engine = self._navigator = self._auditor = None self._cloud_call_history = {} self._cloud_all_items = [] self._cloud_row_results = {} self._cancel_ev = None self._route_poll_id = None self._all_routes = [] self._flat_routes = [] # tree visual order for prev/next self._cloud_scan_active = False self._cloud_scan_poll_timer = None self._redirect_guard_on = False self._hook_injected = set() self._global_hook_scripts = set(self._cfg.get("global_hook_scripts", [])) self._global_inject_gen = 0 self._blocked_seen = 0 self._miniapp_connected = False self._sb_fetch_gen = 0 self._vc_stable_gen = 0 self._log_q = queue.Queue() self._sts_q = queue.Queue() self._rte_q = queue.Queue() self._cld_q = queue.Queue() self._tgt_q = queue.Queue() self._nav_route_idx = -1 self._sb_items = {} self._page_map = {} # 敏感信息提取 状态 self._ext_proc = None self._ext_thread = None self._ext_q = queue.Queue() self._ext_custom_patterns = dict(self._cfg.get("extract_custom_patterns", {})) self._ext_app_states = {} # {appid: {"decompiled": bool, "scanned": bool, ...}} self._ext_app_widgets = {} # {appid: row widget ref} self._ext_current_op = None # ("decompile"/"scan", appid) or None self._build() self.setStyleSheet(build_qss(self._tn)) self._show("control") self._tick_timer = QTimer() self._tick_timer.timeout.connect(self._tick) self._tick_timer.start(80) # ────────────────────────────────── # 布局 # ────────────────────────────────── def _build(self): central = QWidget() central.setObjectName("central") self.setCentralWidget(central) root_h = QHBoxLayout(central) root_h.setContentsMargins(0, 0, 0, 0) root_h.setSpacing(0) # ── 侧栏 ── self._sb = QFrame() self._sb.setObjectName("sidebar") self._sb.setFixedWidth(180) sb_lay = QVBoxLayout(self._sb) sb_lay.setContentsMargins(0, 0, 0, 0) sb_lay.setSpacing(0) sb_head = QFrame() sb_head.setObjectName("sb_head") sb_head.setFixedHeight(90) sb_head_lay = QVBoxLayout(sb_head) sb_head_lay.addStretch() logo_row = QHBoxLayout() logo_row.setContentsMargins(0, 0, 0, 0) logo_row.setSpacing(6) logo_row.addStretch() _ico_path = os.path.join(_BASE_DIR, "icon.png") if os.path.exists(_ico_path): _ico_lbl = QLabel() _pix = QPixmap(_ico_path).scaled(20, 20, Qt.KeepAspectRatio, Qt.SmoothTransformation) _ico_lbl.setPixmap(_pix) _ico_lbl.setFixedSize(20, 20) logo_row.addWidget(_ico_lbl) self._sb_logo = QLabel(f"First-{self._os_tag}") self._sb_logo.setObjectName("sb_logo") logo_row.addWidget(self._sb_logo) logo_row.addStretch() sb_head_lay.addLayout(logo_row) sb_head_lay.addStretch() sb_lay.addWidget(sb_head) hline = QFrame() hline.setObjectName("sb_hline") hline.setFixedHeight(1) sb_lay.addWidget(hline, 0, Qt.AlignTop) sb_nav = QWidget() sb_nav_lay = QVBoxLayout(sb_nav) sb_nav_lay.setContentsMargins(8, 10, 8, 10) sb_nav_lay.setSpacing(2) for pid, icon, name in _MENU: row = QFrame() row.setCursor(Qt.PointingHandCursor) row.setProperty("class", "sb_item") row_lay = QHBoxLayout(row) row_lay.setContentsMargins(10, 0, 8, 0) row_lay.setSpacing(6) ic = QLabel(icon) ic.setProperty("class", "sb_icon") ic.setFont(QFont(_FN, 13)) nm = QLabel(name) nm.setProperty("class", "sb_name") nm.setFont(QFont(_FN, 10)) row_lay.addWidget(ic) row_lay.addWidget(nm, 1) sb_nav_lay.addWidget(row) row.mousePressEvent = lambda e, p=pid: self._show(p) self._sb_items[pid] = (row, ic, nm) sb_nav_lay.addStretch() sb_lay.addWidget(sb_nav, 1) # 侧栏小程序信息卡片 sb_app_card = QFrame() sb_app_card.setStyleSheet( "QFrame { background: rgba(128,128,128,0.08); border-radius: 8px; }" "QLabel { background: transparent; }") sb_app_card_lay = QVBoxLayout(sb_app_card) sb_app_card_lay.setContentsMargins(8, 6, 8, 6) sb_app_card_lay.setSpacing(1) self._sb_app_name = QLabel("未连接") self._sb_app_name.setAlignment(Qt.AlignCenter) self._sb_app_name.setFont(QFont(_FN, 8)) self._sb_app_name.setStyleSheet("color: #5c5c6c;") self._sb_app_name.setWordWrap(True) sb_app_card_lay.addWidget(self._sb_app_name) self._sb_app_id = QLabel("") self._sb_app_id.setAlignment(Qt.AlignCenter) self._sb_app_id.setFont(QFont(_FN, 8)) self._sb_app_id.setStyleSheet("color: #9e9ea8;") self._sb_app_id.setVisible(False) self._sb_app_id.setWordWrap(True) sb_app_card_lay.addWidget(self._sb_app_id) sb_lay.addWidget(sb_app_card) sb_lay.addSpacing(4) self._sb_theme = QLabel() self._sb_theme.setObjectName("sb_theme") self._sb_theme.setAlignment(Qt.AlignCenter) self._sb_theme.setCursor(Qt.PointingHandCursor) self._sb_theme.setFont(QFont(_FN, 9)) self._sb_theme.mousePressEvent = lambda e: self._toggle_theme() sb_lay.addWidget(self._sb_theme) sb_author = QLabel("by vs-olitus") sb_author.setObjectName("sb_theme") sb_author.setAlignment(Qt.AlignCenter) sb_author.setFont(QFont(_FN, 8)) sb_lay.addWidget(sb_author) sb_gh = QLabel("github.com/Spade-sec/First") sb_gh.setObjectName("sb_theme") sb_gh.setAlignment(Qt.AlignCenter) sb_gh.setFont(QFont(_FN, 7)) sb_gh.setCursor(Qt.PointingHandCursor) sb_gh.mousePressEvent = lambda e: ( QDesktopServices.openUrl(QUrl("https://github.com/Spade-sec/First")), self._log_add("info", "[gui] 已打开 GitHub 页面")) sb_lay.addWidget(sb_gh) sb_ver = QLabel("v1.0.7") sb_ver.setObjectName("sb_theme") sb_ver.setAlignment(Qt.AlignCenter) sb_ver.setFont(QFont(_FN, 7)) sb_lay.addWidget(sb_ver) sb_lay.addSpacing(12) self._update_theme_label() root_h.addWidget(self._sb) vline = QFrame() vline.setObjectName("vline") vline.setFixedWidth(1) root_h.addWidget(vline) # ── 右侧 ── right = QWidget() right_lay = QVBoxLayout(right) right_lay.setContentsMargins(0, 0, 0, 0) right_lay.setSpacing(0) hdr_frame = QWidget() hdr_frame.setFixedHeight(60) hdr_lay = QHBoxLayout(hdr_frame) hdr_lay.setContentsMargins(0, 0, 0, 0) self._hdr_title = QLabel("") self._hdr_title.setObjectName("page_title") hdr_lay.addWidget(self._hdr_title) hdr_lay.addStretch() right_lay.addWidget(hdr_frame) hdr_line = QFrame() hdr_line.setObjectName("hdr_line") hdr_line.setFixedHeight(1) right_lay.addWidget(hdr_line) self._stack = AnimatedStackedWidget() right_lay.addWidget(self._stack, 1) root_h.addWidget(right, 1) self._build_control() self._build_navigator() self._build_hook() self._build_targets() self._build_cloud() self._build_extract() self._build_vconsole() self._build_logs() # ── 控制台 ── def _build_control(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 8, 24, 8) lay.setSpacing(6) lay.setAlignment(Qt.AlignTop) # Card 1: 连接设置 c1 = _make_card() c1_lay = QVBoxLayout(c1) c1_lay.setContentsMargins(16, 10, 16, 10) c1_lay.setSpacing(6) c1_lay.addWidget(_make_label("连接设置", bold=True)) row1 = QHBoxLayout() row1.addWidget(QLabel("CDP 端口")) self._cp_ent = _make_entry(width=100) self._cp_ent.setText(str(self._cfg.get("cdp_port", CDP_PORT))) self._cp_ent.textChanged.connect(lambda: self._auto_save()) row1.addWidget(self._cp_ent) row1.addStretch() c1_lay.addLayout(row1) lay.addWidget(c1) # Action row ar = QHBoxLayout() self._btn_start = _make_btn("▶ 启动调试", self._do_start) self._btn_start.setFont(QFont(_FN, 10, QFont.Bold)) ar.addWidget(self._btn_start) self._btn_stop = _make_btn("■ 停止", self._do_stop) self._btn_stop.setFont(QFont(_FN, 10, QFont.Bold)) self._btn_stop.setEnabled(False) ar.addWidget(self._btn_stop) ar.addStretch() lay.addLayout(ar) # DevTools URL dt_row = QHBoxLayout() self._devtools_lbl = QLabel("") self._devtools_lbl.setProperty("class", "accent") self._devtools_lbl.setFont(QFont(_FM, 8)) self._devtools_lbl.setCursor(Qt.PointingHandCursor) self._devtools_lbl.mousePressEvent = lambda e: self._copy_devtools_url() dt_row.addWidget(self._devtools_lbl) self._devtools_copy_hint = QLabel("") self._devtools_copy_hint.setProperty("class", "muted") self._devtools_copy_hint.setFont(QFont(_FN, 8)) dt_row.addWidget(self._devtools_copy_hint) dt_row.addStretch() lay.addLayout(dt_row) # Card 3: 运行状态 c3 = _make_card() c3_lay = QVBoxLayout(c3) c3_lay.setContentsMargins(16, 10, 16, 10) c3_lay.setSpacing(2) c3_lay.addWidget(_make_label("运行状态", bold=True)) self._dots = {} for key, name in [("frida", "Frida"), ("miniapp", "小程序"), ("devtools", "DevTools")]: dr = QHBoxLayout() dot = StatusDot() dr.addWidget(dot) lb = QLabel(f"{name}: 未连接") dr.addWidget(lb) dr.addStretch() c3_lay.addLayout(dr) self._dots[key] = (dot, lb, name) self._app_lbl = QLabel("应用: --") self._app_lbl.setProperty("class", "muted") c3_lay.addWidget(self._app_lbl) self._appname_lbl = QLabel("") self._appname_lbl.setProperty("class", "muted") self._appname_lbl.setVisible(False) c3_lay.addWidget(self._appname_lbl) lay.addWidget(c3) self._stack.addWidget(page) self._page_map["control"] = self._stack.count() - 1 # Card 4: 常见问题解决方案 c4 = _make_card() c4_lay = QVBoxLayout(c4) c4_lay.setContentsMargins(16, 10, 16, 10) c4_lay.setSpacing(8) c4_lay.addWidget(_make_label("常见问题解决方案", bold=True)) faq_items = [ ("Frida 连接失败", "请确认当前版本是否在WMPF版本区间内,如无法解决建议安装建议版本。"), ("DevTools 打开内容为空", "点击启动调试前请勿打开小程序, 启动调试打开后再次启动小程序即可。"), (r"Frida 已显示连接,但小程序端显示未连接或步骤确认没问题且无法断点", r"若操作顺序无误,建议先彻底卸载微信并重启电脑-·如有重要聊天记录请提前备份·-。删除路径C:\Users\用户名\AppData\Roaming\Tencent\xwechat\XPlugin\Plugins\RadiumWMPF下所有以数字命名的文件夹,再次重启电脑后,安装微信 4.1.0.30 版本。安装完成后检查上述路径,确认文件夹编号为 16389。"), ] for title, solution in faq_items: item_lbl = QLabel(f"• {title}\n {solution}") item_lbl.setWordWrap(True) item_lbl.setStyleSheet("color: #FFC107;") c4_lay.addWidget(item_lbl) c4_lay.addStretch() lay.addWidget(c4) # ── 路由导航 ── def _build_navigator(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 12, 24, 16) lay.setSpacing(10) # 搜索栏 sf = QHBoxLayout() sf.addWidget(QLabel("搜索")) self._srch_ent = _make_entry("输入路由关键字搜索...") self._srch_ent.textChanged.connect(self._do_filter) sf.addWidget(self._srch_ent, 1) lay.addLayout(sf) # 路由树 tc = _make_card() tc_lay = QVBoxLayout(tc) tc_lay.setContentsMargins(0, 0, 0, 0) self._tree = QTreeWidget() self._tree.setHeaderHidden(True) self._tree.setSelectionMode(QAbstractItemView.SingleSelection) self._tree.setContextMenuPolicy(Qt.CustomContextMenu) self._tree.customContextMenuRequested.connect(self._nav_context_menu) tc_lay.addWidget(self._tree) lay.addWidget(tc, 1) # 手动输入跳转 mi = QHBoxLayout() mi.addWidget(QLabel("手动跳转")) self._nav_input = _make_entry("输入路由路径,回车跳转...") self._nav_input.returnPressed.connect(self._do_manual_go) mi.addWidget(self._nav_input, 1) self._btn_manual_go = _make_btn("跳转", self._do_manual_go) mi.addWidget(self._btn_manual_go) self._btn_copy_route = _make_btn("复制路由", self._do_copy_route) self._btn_copy_route.setEnabled(False) mi.addWidget(self._btn_copy_route) lay.addLayout(mi) # 导航按钮行 1 b1 = QHBoxLayout() self._btn_go = _make_btn("跳转", self._do_go) self._btn_go.setEnabled(False) b1.addWidget(self._btn_go) self._btn_relaunch = _make_btn("重启到页面", self._do_relaunch) self._btn_relaunch.setEnabled(False) b1.addWidget(self._btn_relaunch) self._btn_back = _make_btn("返回上页", self._do_back) self._btn_back.setEnabled(False) b1.addWidget(self._btn_back) self._btn_refresh = _make_btn("刷新页面", self._do_refresh) self._btn_refresh.setEnabled(False) b1.addWidget(self._btn_refresh) b1.addStretch() self._btn_fetch = _make_btn("获取路由", self._do_fetch) self._btn_fetch.setEnabled(False) b1.addWidget(self._btn_fetch) lay.addLayout(b1) # 导航按钮行 2: 上一个/下一个 + 遍历 + 防跳转 b2 = QHBoxLayout() self._btn_prev = _make_btn("◀ 上一个", self._do_prev) self._btn_prev.setEnabled(False) b2.addWidget(self._btn_prev) self._btn_next = _make_btn("下一个 ▶", self._do_next) self._btn_next.setEnabled(False) b2.addWidget(self._btn_next) b2.addSpacing(12) self._btn_auto = _make_btn("自动遍历", self._do_autovis) self._btn_auto.setEnabled(False) b2.addWidget(self._btn_auto) self._btn_autostop = _make_btn("停止遍历", self._do_autostop) self._btn_autostop.setEnabled(False) b2.addWidget(self._btn_autostop) b2.addSpacing(12) self._guard_switch = ToggleSwitch(False) self._guard_switch.setFixedSize(36, 18) self._guard_switch.setEnabled(False) self._guard_switch.toggled.connect(self._do_toggle_guard_switch) b2.addWidget(self._guard_switch) self._guard_label = QLabel("防跳转: 关闭") b2.addWidget(self._guard_label) b2.addStretch() lay.addLayout(b2) self._prog = QProgressBar() self._prog.setMaximum(100) self._prog.setValue(0) self._prog.setTextVisible(False) self._prog.setFixedHeight(6) lay.addWidget(self._prog) self._route_lbl = QLabel("当前路由: --") self._route_lbl.setFixedHeight(22) self._route_lbl.setProperty("class", "bold") lay.addWidget(self._route_lbl) self._stack.addWidget(page) self._page_map["navigator"] = self._stack.count() - 1 # ── Hook 页面 ── def _build_hook(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 12, 24, 16) lay.setSpacing(10) tip_row = QHBoxLayout() self._hook_tip = QLabel("将 .js 文件放入 hook_scripts/ 目录,点击「注入」即时执行") self._hook_tip.setProperty("class", "muted") tip_row.addWidget(self._hook_tip) tip_row.addStretch() self._btn_hook_refresh = _make_btn("刷新列表", self._hook_refresh) tip_row.addWidget(self._btn_hook_refresh) lay.addLayout(tip_row) c1 = _make_card() c1_lay = QVBoxLayout(c1) c1_lay.setContentsMargins(12, 12, 12, 12) c1_lay.setSpacing(6) scroll = QScrollArea() scroll.setWidgetResizable(True) self._hook_inner = QWidget() self._hook_inner_lay = QVBoxLayout(self._hook_inner) self._hook_inner_lay.setContentsMargins(0, 0, 0, 0) self._hook_inner_lay.setSpacing(6) self._hook_inner_lay.addStretch() scroll.setWidget(self._hook_inner) c1_lay.addWidget(scroll) lay.addWidget(c1, 1) self._hook_status_lbls = {} self._hook_refresh() self._stack.addWidget(page) self._page_map["hook"] = self._stack.count() - 1 def _hook_refresh(self): while self._hook_inner_lay.count() > 1: item = self._hook_inner_lay.takeAt(0) w = item.widget() if w: w.deleteLater() self._hook_status_lbls = {} hook_dir = os.path.join(_BASE_DIR, "hook_scripts") js_files = sorted(f for f in os.listdir(hook_dir) if f.endswith(".js")) if os.path.isdir(hook_dir) else [] if not js_files: lbl = QLabel("hook_scripts/ 目录下无 .js 文件") lbl.setAlignment(Qt.AlignCenter) self._hook_inner_lay.insertWidget(0, lbl) return for fn in js_files: row = QFrame() row.setProperty("class", "hook_row") row.setFixedHeight(52) row_lay = QHBoxLayout(row) row_lay.setContentsMargins(12, 0, 12, 0) row_lay.setSpacing(8) icon_lbl = QLabel("JS") icon_lbl.setProperty("class", "js_badge") icon_lbl.setFont(QFont(_FM, 8, QFont.Bold)) icon_lbl.setFixedWidth(30) icon_lbl.setAlignment(Qt.AlignCenter) row_lay.addWidget(icon_lbl) name_lbl = QLabel(fn) name_lbl.setFont(QFont(_FN, 10)) row_lay.addWidget(name_lbl, 1) is_global = fn in self._global_hook_scripts injected = fn in self._hook_injected if is_global and injected: status_text = "全局 ● 已注入" elif is_global: status_text = "全局 ○ 待注入" elif injected: status_text = "● 已注入" else: status_text = "○ 未注入" status_lbl = QLabel(status_text) c = _TH[self._tn] status_lbl.setStyleSheet(f"color: {c['success'] if injected else c['text3']};") row_lay.addWidget(status_lbl) self._hook_status_lbls[fn] = status_lbl global_cb = QCheckBox("全局") global_cb.setChecked(is_global) global_cb.toggled.connect(lambda checked, f=fn: self._hook_global_toggle(f, checked)) row_lay.addWidget(global_cb) inject_btn = _make_btn("注入", lambda checked=False, f=fn: self._hook_inject(f)) row_lay.addWidget(inject_btn) clear_btn = _make_btn("清除", lambda checked=False, f=fn: self._hook_clear(f)) row_lay.addWidget(clear_btn) self._hook_inner_lay.insertWidget(self._hook_inner_lay.count() - 1, row) def _hook_inject(self, filename): if not self._engine or not self._loop or not self._loop.is_running(): self._log_add("error", "[Hook] 请先启动调试") return hook_dir = os.path.join(_BASE_DIR, "hook_scripts") filepath = os.path.join(hook_dir, filename) try: with open(filepath, "r", encoding="utf-8") as f: source = f.read() except Exception as e: self._log_add("error", f"[Hook] 读取文件失败: {e}") return asyncio.run_coroutine_threadsafe( self._ahook_inject(filename, source), self._loop) async def _ahook_inject(self, filename, source): try: await self._engine.evaluate_js(source, timeout=5.0) self._hook_injected.add(filename) self._log_q.put(("info", f"[Hook] 已注入: {filename}")) self._log_q.put(("__hook_status__", filename, True)) except Exception as e: self._log_q.put(("error", f"[Hook] 注入失败 {filename}: {e}")) def _hook_clear(self, filename): self._hook_injected.discard(filename) self._hook_update_status(filename, False) self._log_add("info", f"[Hook] 已清除标记: {filename}(注意: JS 注入后无法真正撤销,需刷新页面)") def _hook_update_status(self, filename, injected): c = _TH[self._tn] lbl = self._hook_status_lbls.get(filename) if lbl: is_global = filename in self._global_hook_scripts if is_global and injected: lbl.setText("全局 ● 已注入") lbl.setStyleSheet(f"color: {c['success']};") elif is_global: lbl.setText("全局 ○ 待注入") lbl.setStyleSheet(f"color: {c['text3']};") elif injected: lbl.setText("● 已注入") lbl.setStyleSheet(f"color: {c['success']};") else: lbl.setText("○ 未注入") lbl.setStyleSheet(f"color: {c['text3']};") def _hook_global_toggle(self, filename, checked): if checked: self._global_hook_scripts.add(filename) else: self._global_hook_scripts.discard(filename) injected = filename in self._hook_injected self._hook_update_status(filename, injected) self._auto_save() def _hook_auto_inject_globals(self): """Auto-inject all global hook scripts (called when miniapp stabilizes).""" if not self._engine or not self._loop or not self._loop.is_running(): return hook_dir = os.path.join(_BASE_DIR, "hook_scripts") for fn in list(self._global_hook_scripts): if fn in self._hook_injected: continue filepath = os.path.join(hook_dir, fn) if not os.path.isfile(filepath): continue try: with open(filepath, "r", encoding="utf-8") as f: source = f.read() except Exception: continue asyncio.run_coroutine_threadsafe( self._ahook_inject(fn, source), self._loop) def _do_global_inject(self, gen): """独立的全局注入定时回调,只在小程序连接变化时触发,不受 CDP 等影响。""" if gen != self._global_inject_gen: return if not self._miniapp_connected: return if self._global_hook_scripts: self._hook_auto_inject_globals() self._log_add("info", "[Hook] 自动注入全局脚本") # ── 页面目标 ── def _build_targets(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 12, 24, 16) lay.setSpacing(10) tip_row = QHBoxLayout() tip = QLabel("列出当前调试会话可见的微信内置浏览器 / 小程序 / webview 服务") tip.setProperty("class", "muted") tip_row.addWidget(tip) tip_row.addStretch() self._btn_tgt_refresh = _make_btn("刷新目标", self._do_targets_refresh) self._btn_tgt_refresh.setEnabled(False) tip_row.addWidget(self._btn_tgt_refresh) self._btn_tgt_attach = _make_btn("附加到选中目标", self._do_targets_attach) self._btn_tgt_attach.setEnabled(False) tip_row.addWidget(self._btn_tgt_attach) self._btn_tgt_copy = _make_btn("复制 TargetId", self._do_targets_copy) self._btn_tgt_copy.setEnabled(False) tip_row.addWidget(self._btn_tgt_copy) lay.addLayout(tip_row) tc = _make_card() tc_lay = QVBoxLayout(tc) tc_lay.setContentsMargins(0, 0, 0, 0) self._targets_tree = QTreeWidget() self._targets_tree.setRootIsDecorated(False) self._targets_tree.setIndentation(0) self._targets_tree.setHeaderLabels(["类型", "标题", "URL", "TargetId"]) self._targets_tree.setSelectionMode(QAbstractItemView.SingleSelection) self._targets_tree.itemSelectionChanged.connect(self._targets_on_select) self._targets_tree.itemDoubleClicked.connect(lambda *_: self._do_targets_attach()) hdr = self._targets_tree.header() hdr.setSectionResizeMode(0, QHeaderView.ResizeToContents) hdr.setSectionResizeMode(1, QHeaderView.ResizeToContents) hdr.setSectionResizeMode(2, QHeaderView.Stretch) hdr.setSectionResizeMode(3, QHeaderView.ResizeToContents) tc_lay.addWidget(self._targets_tree) lay.addWidget(tc, 1) self._targets_status_lbl = QLabel("状态: 未连接小程序") self._targets_status_lbl.setProperty("class", "muted") lay.addWidget(self._targets_status_lbl) self._stack.addWidget(page) self._page_map["targets"] = self._stack.count() - 1 def _targets_btns(self, on): if not hasattr(self, "_btn_tgt_refresh"): return self._btn_tgt_refresh.setEnabled(on) has_sel = bool(on and self._targets_tree.selectedItems()) self._btn_tgt_attach.setEnabled(has_sel) self._btn_tgt_copy.setEnabled(has_sel) def _targets_on_select(self): self._targets_btns(bool(self._engine and self._miniapp_connected)) def _selected_target(self): items = self._targets_tree.selectedItems() if not items: self._log_add("error", "[页面目标] 请先选择一个目标") return None return items[0].data(0, Qt.UserRole) or {} def _do_targets_refresh(self): if not self._engine or not self._loop or not self._loop.is_running(): self._log_add("error", "[页面目标] 请先启动调试并连接小程序") return self._btn_tgt_refresh.setEnabled(False) self._targets_status_lbl.setText("状态: 正在刷新...") asyncio.run_coroutine_threadsafe(self._atargets_refresh(), self._loop) async def _atargets_refresh(self): try: resp = await self._engine.send_cdp_command("Target.getTargets", timeout=8.0) infos = resp.get("result", {}).get("targetInfos", []) self._tgt_q.put(("targets", infos)) except Exception as e: self._tgt_q.put(("error", f"刷新失败: {e}")) def _do_targets_attach(self): target = self._selected_target() if not target or not self._engine or not self._loop or not self._loop.is_running(): return target_id = target.get("targetId", "") if not target_id: self._log_add("error", "[页面目标] 选中项没有 TargetId") return self._btn_tgt_attach.setEnabled(False) self._targets_status_lbl.setText("状态: 正在附加...") asyncio.run_coroutine_threadsafe(self._atargets_attach(target_id), self._loop) async def _atargets_attach(self, target_id): try: resp = await self._engine.send_cdp_command( "Target.attachToTarget", {"targetId": target_id}, timeout=8.0) self._tgt_q.put(("attached", target_id, resp)) except Exception as e: self._tgt_q.put(("error", f"附加失败: {e}")) def _do_targets_copy(self): target = self._selected_target() if not target: return target_id = target.get("targetId", "") if target_id: QApplication.clipboard().setText(target_id) self._targets_status_lbl.setText("状态: TargetId 已复制") self._log_add("info", f"[页面目标] 已复制 TargetId: {target_id}") def _handle_tgt(self, item): kind = item[0] c = _TH[self._tn] if kind == "targets": targets = item[1] self._targets_tree.clear() for target in targets: target_id = target.get("targetId", "") title = target.get("title", "") or "--" url = target.get("url", "") or "--" typ = target.get("type", "") or "--" row = QTreeWidgetItem([typ, title, url, target_id]) row.setData(0, Qt.UserRole, target) if target.get("attached"): for col in range(4): row.setForeground(col, QColor(c["success"])) self._targets_tree.addTopLevelItem(row) self._targets_status_lbl.setText(f"状态: 发现 {len(targets)} 个目标") self._targets_status_lbl.setStyleSheet(f"color: {c['success']};") self._log_add("info", f"[页面目标] 发现 {len(targets)} 个可选目标") self._targets_btns(bool(self._miniapp_connected)) elif kind == "attached": _, target_id, resp = item session_id = resp.get("result", {}).get("sessionId", "") suffix = f", sessionId={session_id}" if session_id else "" self._targets_status_lbl.setText(f"状态: 已附加 {target_id}{suffix}") self._targets_status_lbl.setStyleSheet(f"color: {c['success']};") self._log_add("info", f"[页面目标] 已附加到目标: {target_id}{suffix}") self._targets_btns(bool(self._miniapp_connected)) elif kind == "error": msg = item[1] self._targets_status_lbl.setText(f"状态: {msg}") self._targets_status_lbl.setStyleSheet(f"color: {c['error']};") self._log_add("error", f"[页面目标] {msg}") self._targets_btns(bool(self._miniapp_connected)) # ── 云扫描 ── def _build_cloud(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 12, 24, 16) lay.setSpacing(10) ctrl = QHBoxLayout() self._btn_cloud_toggle = _make_btn("停止捕获", self._cloud_do_toggle) ctrl.addWidget(self._btn_cloud_toggle) self._btn_cloud_static = _make_btn("静态扫描", self._cloud_do_static_scan) ctrl.addWidget(self._btn_cloud_static) self._btn_cloud_clear = _make_btn("清空记录", self._cloud_do_clear) ctrl.addWidget(self._btn_cloud_clear) self._cloud_scan_lbl = QLabel("") ctrl.addWidget(self._cloud_scan_lbl) ctrl.addStretch() self._btn_cloud_export = _make_btn("导出报告", self._cloud_do_export) ctrl.addWidget(self._btn_cloud_export) lay.addLayout(ctrl) tc = _make_card() tc_lay = QVBoxLayout(tc) tc_lay.setContentsMargins(12, 8, 12, 8) tc_lay.setSpacing(4) title_row = QHBoxLayout() title_row.addWidget(_make_label("云函数捕获记录", bold=True)) self._cloud_env_lbl = QLabel("全局捕获(默认开启)") title_row.addWidget(self._cloud_env_lbl) title_row.addStretch() title_row.addWidget(QLabel("搜索")) self._cloud_search_ent = _make_entry(width=180) self._cloud_search_ent.textChanged.connect(self._cloud_filter) title_row.addWidget(self._cloud_search_ent) tc_lay.addLayout(title_row) self._cloud_tree = QTreeWidget() self._cloud_tree.setRootIsDecorated(False) self._cloud_tree.setIndentation(0) self._cloud_tree.setHeaderLabels(["AppID", "类型", "名称", "参数", "状态", "时间"]) header = self._cloud_tree.header() header.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) header.setStretchLastSection(False) header.setSectionResizeMode(0, QHeaderView.Interactive) header.setSectionResizeMode(1, QHeaderView.Interactive) header.setSectionResizeMode(2, QHeaderView.Interactive) header.setSectionResizeMode(3, QHeaderView.Stretch) header.setSectionResizeMode(4, QHeaderView.Interactive) header.setSectionResizeMode(5, QHeaderView.Interactive) self._cloud_tree.setColumnWidth(0, 100) self._cloud_tree.setColumnWidth(1, 70) self._cloud_tree.setColumnWidth(2, 140) self._cloud_tree.setColumnWidth(4, 50) self._cloud_tree.setColumnWidth(5, 70) self._cloud_tree.setSelectionMode(QAbstractItemView.SingleSelection) self._cloud_tree.itemClicked.connect(self._cloud_on_select) self._cloud_tree.setContextMenuPolicy(Qt.CustomContextMenu) self._cloud_tree.customContextMenuRequested.connect(self._cloud_tree_context_menu) tc_lay.addWidget(self._cloud_tree) lay.addWidget(tc, 1) call_row = QHBoxLayout() call_row.addWidget(QLabel("手动调用")) self._cloud_name_ent = _make_entry(width=140) call_row.addWidget(self._cloud_name_ent) call_row.addWidget(QLabel("参数")) self._cloud_data_ent = _make_entry() self._cloud_data_ent.setText("{}") call_row.addWidget(self._cloud_data_ent, 1) self._btn_cloud_call = _make_btn("调用", self._cloud_do_call) call_row.addWidget(self._btn_cloud_call) lay.addLayout(call_row) self._cloud_result = QTextEdit() self._cloud_result.setReadOnly(True) self._cloud_result.setFixedHeight(120) self._cloud_result.setFont(QFont(_FM, 9)) lay.addWidget(self._cloud_result) bot = QHBoxLayout() self._cloud_status_lbl = QLabel("捕获: 0 条") bot.addWidget(self._cloud_status_lbl) bot.addStretch() lay.addLayout(bot) self._stack.addWidget(page) self._page_map["cloud"] = self._stack.count() - 1 # ── 敏感信息提取 ── def _build_extract(self): # 外层使用 QStackedWidget 实现子页面切换 self._ext_stack = QStackedWidget() # =================== 主页面 =================== main_page = QWidget() main_lay = QVBoxLayout(main_page) main_lay.setContentsMargins(24, 8, 24, 8) main_lay.setSpacing(8) # --- Row 1: Applet目录 --- c1 = _make_card() c1_lay = QVBoxLayout(c1) c1_lay.setContentsMargins(16, 10, 16, 10) c1_lay.setSpacing(6) path_row = QHBoxLayout() path_row.addWidget(_make_label("Applet目录", bold=True)) self._ext_path_ent = _make_entry("wxapkg 包目录路径...") # 自动检测默认路径 from src.wxapkg import get_default_packages_dir default_pkg = get_default_packages_dir() or "" saved_path = self._cfg.get("extract_packages_dir", "") if saved_path: self._ext_path_ent.setText(saved_path) elif default_pkg: self._ext_path_ent.setText(default_pkg) # 先连接信号,然后手动刷新一次(setText 不会重复触发因为信号在之后连接) # 注意: setText 在信号连接前调用,所以不会触发重复刷新 self._ext_path_ent.textChanged.connect(self._ext_on_path_changed) path_row.addWidget(self._ext_path_ent, 1) btn_auto = _make_btn("自动选择", self._ext_auto_detect) path_row.addWidget(btn_auto) btn_browse = _make_btn("选择", self._ext_browse) path_row.addWidget(btn_browse) c1_lay.addLayout(path_row) main_lay.addWidget(c1) # --- Row 2: 功能区 --- func_row = QHBoxLayout() self._btn_ext_regex = _make_btn("正则配置", self._ext_goto_regex) func_row.addWidget(self._btn_ext_regex) self._btn_ext_clear_decompiled = _make_btn("清空解包文件", self._ext_clear_decompiled) func_row.addWidget(self._btn_ext_clear_decompiled) self._btn_ext_clear_applet = _make_btn("清空Applet目录", self._ext_clear_applet) func_row.addWidget(self._btn_ext_clear_applet) func_row.addStretch() # 自动反编译 & 自动提取 开关 func_row.addWidget(_make_label("自动反编译")) self._tog_auto_dec = ToggleSwitch(self._cfg.get("auto_decompile", False)) self._tog_auto_dec.toggled.connect(lambda v: self._auto_save()) func_row.addWidget(self._tog_auto_dec) func_row.addWidget(_make_label("自动提取")) self._tog_auto_scan = ToggleSwitch(self._cfg.get("auto_scan", False)) self._tog_auto_scan.toggled.connect(lambda v: self._auto_save()) func_row.addWidget(self._tog_auto_scan) main_lay.addLayout(func_row) # --- 进度 + 状态 --- self._ext_prog = QProgressBar() self._ext_prog.setMaximum(100) self._ext_prog.setValue(0) self._ext_prog.setTextVisible(False) self._ext_prog.setFixedHeight(6) main_lay.addWidget(self._ext_prog) self._ext_status_lbl = QLabel("就绪") self._ext_status_lbl.setProperty("class", "muted") main_lay.addWidget(self._ext_status_lbl) # --- Row 3: 小程序列表 --- list_card = _make_card() list_lay = QVBoxLayout(list_card) list_lay.setContentsMargins(0, 6, 0, 6) list_lay.setSpacing(0) # 表头 hdr = QHBoxLayout() hdr.setContentsMargins(16, 4, 16, 4) hdr_appid = _make_label("AppID", bold=True) hdr_appid.setFixedWidth(180) hdr.addWidget(hdr_appid) hdr_name = _make_label("名称", bold=True) hdr_name.setMinimumWidth(100) hdr.addWidget(hdr_name, 1) hdr_ops = _make_label("操作", bold=True) hdr.addWidget(hdr_ops) list_lay.addLayout(hdr) # 分割线 sep = QFrame() sep.setFixedHeight(1) sep.setStyleSheet("background: rgba(128,128,128,0.2);") list_lay.addWidget(sep) # 滚动区域 scroll = QScrollArea() scroll.setWidgetResizable(True) self._ext_list_inner = QWidget() self._ext_list_layout = QVBoxLayout(self._ext_list_inner) self._ext_list_layout.setContentsMargins(8, 4, 8, 4) self._ext_list_layout.setSpacing(4) self._ext_list_layout.addStretch() scroll.setWidget(self._ext_list_inner) list_lay.addWidget(scroll, 1) main_lay.addWidget(list_card, 1) # 日志区 self._ext_logbox = QTextEdit() self._ext_logbox.setReadOnly(True) self._ext_logbox.setFont(QFont(_FM, 9)) self._ext_logbox.setMaximumHeight(120) main_lay.addWidget(self._ext_logbox) self._ext_stack.addWidget(main_page) # index 0 # =================== 正则配置子页面 =================== regex_page = QWidget() regex_lay = QVBoxLayout(regex_page) regex_lay.setContentsMargins(24, 8, 24, 8) regex_lay.setSpacing(8) # 返回按钮行 regex_top = QHBoxLayout() btn_back_regex = _make_btn("← 返回", lambda: self._ext_stack.setCurrentIndex(0)) regex_top.addWidget(btn_back_regex) regex_top.addWidget(_make_label("正则配置", bold=True)) regex_top.addStretch() regex_lay.addLayout(regex_top) # 自定义正则卡片 custom_card = _make_card() cc_lay = QVBoxLayout(custom_card) cc_lay.setContentsMargins(16, 10, 16, 10) cc_lay.setSpacing(6) cc_hdr = QHBoxLayout() cc_hdr.addWidget(_make_label("自定义正则", bold=True)) cc_hdr.addWidget(_make_label("(提取时会与内置规则合并使用)", muted=True)) cc_hdr.addStretch() btn_add = _make_btn("新建", self._ext_add_pattern) cc_hdr.addWidget(btn_add) cc_lay.addLayout(cc_hdr) # 表头行 cc_hdr_row = QHBoxLayout() cc_hdr_row.setContentsMargins(12, 4, 12, 4) h1 = _make_label("栏目", bold=True); h1.setFixedWidth(120) cc_hdr_row.addWidget(h1) h2 = _make_label("正则表达式", bold=True) cc_hdr_row.addWidget(h2, 1) h3 = _make_label("状态", bold=True); h3.setFixedWidth(50) cc_hdr_row.addWidget(h3) h4 = _make_label("操作", bold=True); h4.setFixedWidth(180) cc_hdr_row.addWidget(h4) cc_lay.addLayout(cc_hdr_row) cc_sep = QFrame(); cc_sep.setFixedHeight(1) cc_sep.setStyleSheet("background: rgba(128,128,128,0.2);") cc_lay.addWidget(cc_sep) # 滚动区域放行列表 self._ext_pat_scroll = QScrollArea() self._ext_pat_scroll.setWidgetResizable(True) self._ext_pat_scroll.setStyleSheet("QScrollArea { border: none; }") self._ext_pat_inner = QWidget() self._ext_pat_layout = QVBoxLayout(self._ext_pat_inner) self._ext_pat_layout.setContentsMargins(0, 0, 0, 0) self._ext_pat_layout.setSpacing(4) self._ext_pat_layout.addStretch() self._ext_pat_scroll.setWidget(self._ext_pat_inner) cc_lay.addWidget(self._ext_pat_scroll) regex_lay.addWidget(custom_card, 1) # stretch=1,自定义区域占大空间 # 内置正则卡片(紧凑) builtin_card = _make_card() bc_lay = QVBoxLayout(builtin_card) bc_lay.setContentsMargins(16, 10, 16, 10) bc_lay.setSpacing(6) bc_lay.addWidget(_make_label("内置正则规则 (只读)", bold=True)) self._ext_builtin_tree = QTreeWidget() self._ext_builtin_tree.setHeaderLabels(["分类", "正则/说明"]) bh = self._ext_builtin_tree.header() bh.setStretchLastSection(True) bh.setSectionResizeMode(0, QHeaderView.Interactive) self._ext_builtin_tree.setColumnWidth(0, 200) self._ext_builtin_tree.setRootIsDecorated(False) self._ext_builtin_tree.setMaximumHeight(200) bc_lay.addWidget(self._ext_builtin_tree) regex_lay.addWidget(builtin_card) # 无stretch,内置区域紧凑 self._ext_stack.addWidget(regex_page) # index 1 # =================== 查看敏感信息子页面 =================== view_page = QWidget() view_lay = QVBoxLayout(view_page) view_lay.setContentsMargins(16, 8, 16, 8) view_lay.setSpacing(6) view_top = QHBoxLayout() btn_back_view = _make_btn("← 返回", lambda: self._ext_stack.setCurrentIndex(0)) view_top.addWidget(btn_back_view) self._ext_view_title = _make_label("查看敏感信息", bold=True) view_top.addWidget(self._ext_view_title) view_top.addStretch() self._btn_ext_open_html = _make_btn("网页访问", self._ext_open_html) view_top.addWidget(self._btn_ext_open_html) view_lay.addLayout(view_top) # 结果展示区 (滚动) self._ext_view_scroll = QScrollArea() self._ext_view_scroll.setWidgetResizable(True) self._ext_view_scroll.setStyleSheet("QScrollArea { border: none; }") self._ext_view_inner = QWidget() self._ext_view_top_layout = QVBoxLayout(self._ext_view_inner) self._ext_view_top_layout.setContentsMargins(0, 0, 0, 0) self._ext_view_top_layout.setSpacing(0) self._ext_view_scroll.setWidget(self._ext_view_inner) view_lay.addWidget(self._ext_view_scroll, 1) self._ext_stack.addWidget(view_page) # index 2 # 注册到主 stack self._stack.addWidget(self._ext_stack) self._page_map["extract"] = self._stack.count() - 1 # 填充内置正则 self._ext_fill_builtin_patterns() self._ext_refresh_custom_patterns() # 延迟加载小程序列表 QTimer.singleShot(500, self._ext_refresh_apps) # 定时监控目录变化 (每5秒) self._ext_watch_timer = QTimer() self._ext_watch_timer.timeout.connect(self._ext_check_dir_changes) self._ext_watch_timer.start(5000) self._ext_last_appids = set() # 上一次扫描到的 appids # 存储当前查看的 html path self._ext_current_html = "" self._ext_current_json = "" # ============================================ # 提取页 - 辅助方法 # ============================================ def _ext_browse(self): d = QFileDialog.getExistingDirectory(self, "选择小程序包目录", self._ext_path_ent.text()) if d: self._ext_path_ent.setText(d) def _ext_on_path_changed(self): """路径变化时自动保存和刷新列表""" self._auto_save() self._ext_refresh_apps() def _ext_check_dir_changes(self): """定时检查目录是否有新的小程序包""" pkg_dir = self._ext_path_ent.text().strip() if not pkg_dir or not os.path.isdir(pkg_dir): return # 快速扫描子目录名称 try: current_dirs = set() for entry in os.listdir(pkg_dir): if os.path.isdir(os.path.join(pkg_dir, entry)): current_dirs.add(entry) except Exception: return if current_dirs != self._ext_last_appids: new_ids = current_dirs - self._ext_last_appids self._ext_last_appids = current_dirs if new_ids: self._ext_refresh_apps() for nid in new_ids: self._ext_log(f"检测到新小程序: {nid}") # 自动处理新增的小程序 self._ext_auto_process_new(new_ids) def _ext_auto_detect(self): """自动检测默认路径""" from src.wxapkg import get_default_packages_dir default_pkg = get_default_packages_dir() if default_pkg: if self._ext_path_ent.text().strip() == default_pkg: self._ext_refresh_apps() else: self._ext_path_ent.setText(default_pkg) self._ext_log(f"已自动检测到 Applet 目录: {default_pkg}") else: hint = ("~/Library/Containers/com.tencent.xinWeChat/Data/Documents/" "app_data/radium/users/<微信用户ID>/applet/packages" if sys.platform == "darwin" else "未找到默认 Applet 目录") self._ext_log(f"未自动检测到目录,请手动选择。macOS 参考路径: {hint}" if sys.platform == "darwin" else f"{hint},请手动选择") def _ext_auto_process_new(self, new_ids): """对新增的小程序自动执行反编译/扫描(按开关状态)""" if not self._tog_auto_dec.isChecked() and not self._tog_auto_scan.isChecked(): return # 如果当前已有任务在运行,排队延迟处理 if self._ext_current_op is not None: QTimer.singleShot(3000, lambda ids=set(new_ids): self._ext_auto_process_new(ids)) return for appid in sorted(new_ids): state = self._ext_app_states.get(appid, {}) if self._tog_auto_dec.isChecked() and not state.get("decompiled", False): self._ext_log(f"[自动] 开始反编译: {appid}") self._ext_do_decompile(appid) return # 一次处理一个,完成后 _handle_ext 会触发下一步 if self._tog_auto_scan.isChecked() and state.get("decompiled", False) and not state.get("scanned", False): self._ext_log(f"[自动] 开始提取: {appid}") self._ext_do_scan(appid) return def _ext_auto_process_pending(self): """遍历所有已知app,对未处理的自动执行反编译/扫描""" if self._ext_current_op is not None: return for appid, state in self._ext_app_states.items(): if self._tog_auto_dec.isChecked() and not state.get("decompiled", False): self._ext_log(f"[自动] 开始反编译: {appid}") self._ext_do_decompile(appid) return if self._tog_auto_scan.isChecked() and state.get("decompiled", False) and not state.get("scanned", False): self._ext_log(f"[自动] 开始提取: {appid}") self._ext_do_scan(appid) return def _ext_log(self, msg): c = _TH[self._tn] self._ext_logbox.append(f'{msg}') sb = self._ext_logbox.verticalScrollBar() sb.setValue(sb.maximum()) def _ext_get_app_name(self, appid, pkgs, output_base): """尝试从已解包文件中读取小程序名称""" app_output = os.path.join(output_base, appid) decompile_dir = os.path.join(app_output, "decompiled") # 尝试从 app-config.json 读取 for fname in ("app-config.json", "app.json"): cfg_path = os.path.join(decompile_dir, fname) if os.path.isfile(cfg_path): try: with open(cfg_path, "r", encoding="utf-8") as f: cfg = json.load(f) # 多种可能的字段 name = (cfg.get("window", {}).get("navigationBarTitleText", "") or cfg.get("appname", "") or cfg.get("entryPagePath", "")) if name: return f"{name} ({len(pkgs)}pkg)" except Exception: pass return f"{len(pkgs)} pkg" def _ext_refresh_apps(self): """刷新小程序列表""" pkg_dir = self._ext_path_ent.text().strip() if not pkg_dir or not os.path.isdir(pkg_dir): return # 清除旧列表 while self._ext_list_layout.count() > 1: # 保留 stretch item = self._ext_list_layout.takeAt(0) w = item.widget() if w: w.deleteLater() self._ext_app_widgets.clear() # 扫描目录 try: from src.wxapkg import find_wxapkg_files all_pkgs = find_wxapkg_files(pkg_dir) except Exception as e: self._ext_log(f"扫描目录失败: {e}") return # 按 appid 分组 appid_groups = {} for pkg in all_pkgs: appid_groups.setdefault(pkg["appid"], []).append(pkg) if not appid_groups: self._ext_status_lbl.setText("未找到小程序") return c = _TH[self._tn] output_base = os.path.join(_BASE_DIR, "output") # 按最新包文件的修改时间排序,最旧的先插入,最新的最后插入到位置0因此显示在最上面 def _appid_mtime(item): _, pkgs = item return max((os.path.getmtime(p["path"]) for p in pkgs if os.path.exists(p["path"])), default=0) for appid, pkgs in sorted(appid_groups.items(), key=_appid_mtime): row = QFrame() row.setStyleSheet( f"QFrame {{ background: {c['input']}; border-radius: 8px; }}" f"QFrame QLabel {{ background: transparent; }}") row_lay = QHBoxLayout(row) row_lay.setContentsMargins(12, 6, 12, 6) row_lay.setSpacing(6) lbl_id = QLabel(appid) lbl_id.setFixedWidth(180) lbl_id.setFont(QFont(_FM, 9)) lbl_id.setStyleSheet(f"color: {c['text1']};") row_lay.addWidget(lbl_id) # 检查是否已解包 app_output = os.path.join(output_base, appid) decompile_dir = os.path.join(app_output, "decompiled") result_dir = os.path.join(app_output, "result") is_decompiled = os.path.isdir(decompile_dir) and any( f.endswith(('.js', '.html', '.htm')) for _, _, files in os.walk(decompile_dir) for f in files ) if os.path.isdir(decompile_dir) else False is_scanned = os.path.isfile(os.path.join(result_dir, "report.json")) # 尝试读取小程序名称 app_name = self._ext_get_app_name(appid, pkgs, output_base) if is_decompiled else f"{len(pkgs)} pkg (未反编译)" lbl_name = QLabel(app_name) lbl_name.setMinimumWidth(100) lbl_name.setFont(QFont(_FN, 9)) lbl_name.setStyleSheet(f"color: {c['text2']};") row_lay.addWidget(lbl_name, 1) self._ext_app_states[appid] = { "decompiled": is_decompiled, "scanned": is_scanned, "packages": pkgs, "decompile_dir": decompile_dir, "result_dir": result_dir, } # 按钮 btn_dec = QPushButton("反编译") btn_dec.setFixedWidth(60) btn_dec.clicked.connect(lambda _, a=appid: self._ext_do_decompile(a)) if is_decompiled: btn_dec.setStyleSheet( f"QPushButton {{ background: {c['success']}; color: #111; border-radius: 6px; padding: 4px 8px; font-size: 9px; }}") row_lay.addWidget(btn_dec) btn_scan = QPushButton("提取") btn_scan.setFixedWidth(50) btn_scan.setEnabled(is_decompiled) btn_scan.clicked.connect(lambda _, a=appid: self._ext_do_scan(a)) row_lay.addWidget(btn_scan) btn_view = QPushButton("查看") btn_view.setFixedWidth(50) btn_view.setEnabled(is_scanned) btn_view.clicked.connect(lambda _, a=appid: self._ext_view_results(a)) row_lay.addWidget(btn_view) btn_del = QPushButton("删除") btn_del.setFixedWidth(50) btn_del.clicked.connect(lambda _, a=appid: self._ext_delete_app(a)) btn_del.setStyleSheet( f"QPushButton {{ background: {c['error']}; color: #fff; border-radius: 6px; padding: 4px 8px; font-size: 9px; }}" f"QPushButton:hover {{ background: #dc2626; }}") row_lay.addWidget(btn_del) self._ext_app_widgets[appid] = { "row": row, "btn_dec": btn_dec, "btn_scan": btn_scan, "btn_view": btn_view, "btn_del": btn_del, "lbl_name": lbl_name, } # 插入在最前面(最新的在上面) self._ext_list_layout.insertWidget(0, row) self._ext_status_lbl.setText(f"发现 {len(appid_groups)} 个小程序") # 记录当前已知 appid 集合 (供目录监控用) self._ext_last_appids = set(appid_groups.keys()) def _ext_update_app_buttons(self, appid): """更新指定 app 的按钮状态""" state = self._ext_app_states.get(appid, {}) widgets = self._ext_app_widgets.get(appid, {}) if not widgets: return c = _TH[self._tn] is_dec = state.get("decompiled", False) is_scanned = state.get("scanned", False) btn_dec = widgets["btn_dec"] if is_dec: btn_dec.setStyleSheet( f"QPushButton {{ background: {c['success']}; color: #111; border-radius: 6px; padding: 4px 8px; font-size: 9px; }}") else: btn_dec.setStyleSheet("") widgets["btn_scan"].setEnabled(is_dec) widgets["btn_view"].setEnabled(is_scanned) # ============================================ # 提取页 - 反编译 # ============================================ def _ext_do_decompile(self, appid): if self._ext_proc: self._ext_log("有任务正在运行,请等待完成") return output_base = os.path.join(_BASE_DIR, "output") app_output = os.path.join(output_base, appid) os.makedirs(app_output, exist_ok=True) pkg_dir = self._ext_path_ent.text().strip() worker_path = os.path.join(_BASE_DIR, "src", "extract_worker.py") cmd = [ sys.executable, worker_path, "decompile", "--packages-dir", pkg_dir, "--appid", appid, "--output-dir", app_output, ] self._ext_log(f"开始反编译: {appid}") self._ext_prog.setValue(0) self._ext_status_lbl.setText(f"正在反编译 {appid}...") self._ext_current_op = ("decompile", appid) try: self._ext_proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, ) except Exception as e: self._ext_log(f"启动失败: {e}") return self._ext_thread = threading.Thread(target=self._ext_reader, daemon=True) self._ext_thread.start() # ============================================ # 提取页 - 敏感信息扫描 # ============================================ def _ext_do_scan(self, appid): if self._ext_proc: self._ext_log("有任务正在运行,请等待完成") return state = self._ext_app_states.get(appid, {}) if not state.get("decompiled"): self._ext_log(f"请先反编译 {appid}") return output_base = os.path.join(_BASE_DIR, "output") app_output = os.path.join(output_base, appid) decompile_dir = state.get("decompile_dir", os.path.join(app_output, "decompiled")) result_dir = os.path.join(app_output, "result") os.makedirs(result_dir, exist_ok=True) # 保存自定义正则 custom_file = "" if self._ext_custom_patterns: custom_file = os.path.join(_BASE_DIR, ".extract_custom_patterns.json") with open(custom_file, "w", encoding="utf-8") as f: json.dump(self._ext_custom_patterns, f, ensure_ascii=False) worker_path = os.path.join(_BASE_DIR, "src", "extract_worker.py") cmd = [ sys.executable, worker_path, "scan", "--scan-dir", decompile_dir, "--output-dir", result_dir, ] if custom_file: cmd += ["--custom-patterns", custom_file] self._ext_log(f"开始提取敏感信息: {appid}") self._ext_prog.setValue(0) self._ext_status_lbl.setText(f"正在扫描 {appid}...") self._ext_current_op = ("scan", appid) try: self._ext_proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, ) except Exception as e: self._ext_log(f"启动失败: {e}") return self._ext_thread = threading.Thread(target=self._ext_reader, daemon=True) self._ext_thread.start() # ============================================ # 提取页 - 子进程通信 # ============================================ def _ext_reader(self): """后台线程读取子进程 stdout""" proc = self._ext_proc if not proc or not proc.stdout: return try: for line in proc.stdout: line = line.strip() if not line: continue try: obj = json.loads(line) self._ext_q.put(obj) except json.JSONDecodeError: self._ext_q.put({"type": "log", "msg": line}) except Exception: pass finally: proc.wait() stderr_out = "" if proc.stderr: try: stderr_out = proc.stderr.read() except Exception: pass if stderr_out: self._ext_q.put({"type": "log", "msg": f"[stderr] {stderr_out[:500]}"}) self._ext_q.put({"type": "__done__", "returncode": proc.returncode}) # ============================================ # 提取页 - 查看结果 # ============================================ def _ext_view_results(self, appid): """查看敏感信息子页面 — 双列布局,仿 HTML 报告样式""" state = self._ext_app_states.get(appid, {}) result_dir = state.get("result_dir", "") json_path = os.path.join(result_dir, "report.json") html_path = os.path.join(result_dir, "report.html") if not os.path.isfile(json_path): self._ext_log(f"未找到 {appid} 的扫描结果") return self._ext_current_html = html_path self._ext_current_json = json_path self._ext_view_title.setText(f"查看敏感信息 - {appid}") # 清除旧内容 layout = self._ext_view_top_layout while layout.count(): item = layout.takeAt(0) w = item.widget() if w: w.deleteLater() elif item.layout(): # 递归删除子布局 sub = item.layout() while sub.count(): si = sub.takeAt(0) sw = si.widget() if sw: sw.deleteLater() # 加载 JSON 数据 try: with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) except Exception as e: self._ext_log(f"加载结果失败: {e}") return c = _TH[self._tn] accent = c["accent"] # 绿色 cat_labels = { 'ip': 'IP', 'ip_port': 'IP:PORT', 'domain': '域名', 'sfz': '身份证', 'mobile': '手机号', 'mail': '邮箱', 'jwt': 'JWT', 'algorithm': '加密算法', 'secret': 'Secret/密钥', 'path': 'Path', 'incomplete_path': 'IncompletePath', 'url': 'URL', 'static': 'StaticUrl' } # 反向映射: 中文标签 → 内置key label_to_key = {v: k for k, v in cat_labels.items()} left_cats = ['ip', 'ip_port', 'domain', 'sfz', 'mobile', 'mail', 'jwt', 'algorithm', 'secret'] right_cats = ['path', 'incomplete_path', 'url', 'static'] all_builtin = set(left_cats + right_cats) # 合并自定义结果到同名内置分类,去重 merged_data = {} custom_only_keys = [] for k, v in data.items(): if k == "_meta": continue items = v if isinstance(v, list) else [] if k in all_builtin: merged_data.setdefault(k, []).extend(items) elif k in label_to_key: # 自定义名称和内置中文标签相同,合并到内置分类 builtin_key = label_to_key[k] merged_data.setdefault(builtin_key, []).extend(items) else: merged_data.setdefault(k, []).extend(items) if k not in all_builtin: custom_only_keys.append(k) # 去重 for k in merged_data: seen = set() deduped = [] for item in merged_data[k]: s = str(item) if s not in seen: seen.add(s) deduped.append(item) merged_data[k] = deduped def _build_cat_widget(key, label, items): """构建单个分类的 widget — 支持展开/折叠""" w = QWidget() lay = QVBoxLayout(w) lay.setContentsMargins(0, 8, 0, 4) lay.setSpacing(2) # 标题行: ▶ 分类名 (数量) [复制] title_row = QHBoxLayout() title_row.setContentsMargins(0, 0, 0, 0) # 展开/折叠按钮 btn_fold = QPushButton("▼") btn_fold.setFixedSize(20, 20) btn_fold.setCursor(Qt.CursorShape.PointingHandCursor) btn_fold.setStyleSheet( f"QPushButton {{ background: transparent; color: {c['text3']}; border: none;" f"font-size: 10px; padding: 0; }}" f"QPushButton:hover {{ color: {c['text1']}; }}") title_row.addWidget(btn_fold) title_lbl = QLabel(f"{label} ({len(items)})") title_lbl.setFont(QFont(_FN, 11, QFont.Weight.Bold)) title_lbl.setStyleSheet( f"color: {c['text1']}; border-left: 4px solid {accent}; padding-left: 8px;" f"background: transparent;") title_row.addWidget(title_lbl) title_row.addStretch() btn_copy = QPushButton("复制") btn_copy.setFixedSize(48, 26) btn_copy.setCursor(Qt.CursorShape.PointingHandCursor) btn_copy.setStyleSheet( f"QPushButton {{ background: {c['input']}; color: {accent}; border: 1px solid {accent};" f"border-radius: 4px; font-size: 11px; padding: 2px 8px; }}" f"QPushButton:hover {{ background: {accent}; color: #111; }}") copy_text = "\n".join(str(i) for i in items) btn_copy.clicked.connect(lambda _, t=copy_text, b=btn_copy: self._ext_copy_cat(t, b)) title_row.addWidget(btn_copy) lay.addLayout(title_row) # 内容区域(可折叠) content_widget = QWidget() content_lay = QVBoxLayout(content_widget) content_lay.setContentsMargins(0, 0, 0, 0) content_lay.setSpacing(0) if items: content_lbl = QLabel() content_lbl.setFont(QFont(_FM, 9)) content_lbl.setWordWrap(True) content_lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) content_lbl.setStyleSheet( f"color: {c['text2']}; padding-left: 14px; background: transparent;") display_items = items[:200] lines = [str(i) for i in display_items] if len(items) > 200: lines.append(f"... 共 {len(items)} 条,仅显示前 200 条") content_lbl.setText("\n".join(lines)) content_lay.addWidget(content_lbl) lay.addWidget(content_widget) # 展开/折叠逻辑 — 默认展开 def toggle_fold(): visible = content_widget.isVisible() content_widget.setVisible(not visible) btn_fold.setText("▶" if visible else "▼") btn_fold.clicked.connect(toggle_fold) return w # === 双列布局 === cols_widget = QWidget() cols_layout = QHBoxLayout(cols_widget) cols_layout.setContentsMargins(0, 0, 0, 0) cols_layout.setSpacing(16) # 左列 left_col = QWidget() left_lay = QVBoxLayout(left_col) left_lay.setContentsMargins(0, 0, 0, 0) left_lay.setSpacing(0) for cat in left_cats: label = cat_labels.get(cat, cat) items = merged_data.get(cat, []) left_lay.addWidget(_build_cat_widget(cat, label, items)) # 自定义规则(非同名)放左列底部 for key in custom_only_keys: items = merged_data.get(key, []) left_lay.addWidget(_build_cat_widget(key, key, items)) left_lay.addStretch() cols_layout.addWidget(left_col, 1) # 分隔线 sep = QFrame() sep.setFrameShape(QFrame.Shape.VLine) sep.setStyleSheet("color: rgba(128,128,128,0.2);") cols_layout.addWidget(sep) # 右列 right_col = QWidget() right_lay = QVBoxLayout(right_col) right_lay.setContentsMargins(0, 0, 0, 0) right_lay.setSpacing(0) for cat in right_cats: label = cat_labels.get(cat, cat) items = merged_data.get(cat, []) right_lay.addWidget(_build_cat_widget(cat, label, items)) right_lay.addStretch() cols_layout.addWidget(right_col, 1) layout.addWidget(cols_widget) layout.addStretch() # 切换到查看页 self._ext_stack.setCurrentIndex(2) def _ext_copy_cat(self, text, btn): """复制分类内容并显示反馈""" QApplication.clipboard().setText(text) old = btn.text() btn.setText("✓") QTimer.singleShot(1200, lambda: btn.setText(old)) def _ext_open_html(self): """浏览器打开 HTML 报告""" if self._ext_current_html and os.path.isfile(self._ext_current_html): QDesktopServices.openUrl(QUrl.fromLocalFile(self._ext_current_html)) else: self._ext_log("HTML 报告不存在") # ============================================ # 提取页 - 删除 # ============================================ def _ext_delete_app(self, appid): """删除指定小程序的解包和结果""" import shutil output_base = os.path.join(_BASE_DIR, "output") app_output = os.path.join(output_base, appid) if os.path.isdir(app_output): try: shutil.rmtree(app_output) self._ext_log(f"已删除 {appid} 的数据") except Exception as e: self._ext_log(f"删除失败: {e}") # 更新状态 if appid in self._ext_app_states: self._ext_app_states[appid]["decompiled"] = False self._ext_app_states[appid]["scanned"] = False self._ext_update_app_buttons(appid) # 如果开启了自动反编译,重新处理 QTimer.singleShot(500, self._ext_auto_process_pending) def _ext_clear_decompiled(self): """清空所有解包文件""" import shutil output_base = os.path.join(_BASE_DIR, "output") if not os.path.isdir(output_base): return count = 0 for appid in os.listdir(output_base): dec_dir = os.path.join(output_base, appid, "decompiled") if os.path.isdir(dec_dir): try: shutil.rmtree(dec_dir) count += 1 except Exception: pass self._ext_log(f"已清空 {count} 个小程序的解包文件") # 刷新状态(不触发自动处理,用户主动清空不应自动重跑) for appid in self._ext_app_states: self._ext_app_states[appid]["decompiled"] = False self._ext_app_states[appid]["scanned"] = False self._ext_update_app_buttons(appid) def _ext_clear_applet(self): """清空 Applet 目录""" import shutil pkg_dir = self._ext_path_ent.text().strip() if not pkg_dir or not os.path.isdir(pkg_dir): self._ext_log("Applet 目录不存在") return try: for entry in os.listdir(pkg_dir): full = os.path.join(pkg_dir, entry) if os.path.isdir(full): shutil.rmtree(full) self._ext_log("已清空 Applet 目录") # 清空后重置已知 appid 集合,防止旧 appid 触发自动反编译 self._ext_app_states.clear() self._ext_last_appids = set() self._ext_refresh_apps() except Exception as e: self._ext_log(f"清空失败: {e}") # ============================================ # 提取页 - 正则管理 # ============================================ def _ext_goto_regex(self): self._ext_stack.setCurrentIndex(1) def _ext_fill_builtin_patterns(self): """填充内置正则到树""" from src.extractor import Extractor builtin = Extractor.get_all_builtin_patterns() self._ext_builtin_tree.clear() for name, pat in builtin.items(): display = pat if len(pat) < 200 else pat[:200] + "..." item = QTreeWidgetItem([name, display]) self._ext_builtin_tree.addTopLevelItem(item) def _ext_refresh_custom_patterns(self): """刷新自定义正则行列表 — 用 QFrame 行代替 QTableWidget""" lay = self._ext_pat_layout # 清除旧行(保留最后一个 stretch) while lay.count() > 1: item = lay.takeAt(0) w = item.widget() if w: w.deleteLater() c = _TH[self._tn] for name, info in self._ext_custom_patterns.items(): if isinstance(info, str): pat = info; enabled = True else: pat = info.get("regex", ""); enabled = info.get("enabled", True) row = QFrame() row.setObjectName("_ext_pat_row") row.setStyleSheet( f"QFrame#_ext_pat_row {{ background: {c['input']}; border-radius: 8px; }}" f"QFrame#_ext_pat_row QLabel {{ background: transparent; border: none; }}") row.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) row.customContextMenuRequested.connect(lambda pos, n=name: self._ext_pattern_ctx(pos, n)) rl = QHBoxLayout(row) rl.setContentsMargins(12, 6, 12, 6) rl.setSpacing(6) lbl_n = QLabel(name) lbl_n.setFixedWidth(120) lbl_n.setFont(QFont(_FM, 9)) lbl_n.setStyleSheet(f"color: {c['text1']};") rl.addWidget(lbl_n) lbl_p = QLabel(pat if len(pat) < 60 else pat[:60] + "...") lbl_p.setFont(QFont(_FM, 9)) lbl_p.setStyleSheet(f"color: {c['text2']};") lbl_p.setToolTip(pat) lbl_p.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) rl.addWidget(lbl_p, 1) lbl_s = QLabel("启用" if enabled else "禁用") lbl_s.setFixedWidth(50) lbl_s.setStyleSheet(f"color: {c['success'] if enabled else c['error']};") rl.addWidget(lbl_s) btn_edit = QPushButton("修改") btn_edit.setFixedWidth(50) btn_edit.clicked.connect(lambda _, n=name: self._ext_edit_pattern(n)) rl.addWidget(btn_edit) btn_toggle = QPushButton("禁用" if enabled else "启用") btn_toggle.setFixedWidth(50) if enabled: btn_toggle.setStyleSheet( f"QPushButton {{ background: {c['warning']}; color: #111; border-radius: 6px; padding: 4px 8px; font-size: 9px; }}" f"QPushButton:hover {{ background: #ca8a04; }}") else: btn_toggle.setStyleSheet( f"QPushButton {{ background: {c['success']}; color: #111; border-radius: 6px; padding: 4px 8px; font-size: 9px; }}") btn_toggle.clicked.connect(lambda _, n=name: self._ext_toggle_pattern(n)) rl.addWidget(btn_toggle) btn_del = QPushButton("删除") btn_del.setFixedWidth(50) btn_del.setStyleSheet( f"QPushButton {{ background: {c['error']}; color: #fff; border-radius: 6px; padding: 4px 8px; font-size: 9px; }}" f"QPushButton:hover {{ background: #dc2626; }}") btn_del.clicked.connect(lambda _, n=name: self._ext_delete_pattern(n)) rl.addWidget(btn_del) lay.insertWidget(lay.count() - 1, row) def _ext_add_pattern(self): """新建空白行 — 用 QLineEdit 输入""" c = _TH[self._tn] row = QFrame() row.setObjectName("_ext_new_row") row.setStyleSheet( f"QFrame#_ext_new_row {{ background: {c['input']}; border-radius: 8px; border: 1px solid {c['accent']}; }}" f"QFrame#_ext_new_row QLabel {{ background: transparent; border: none; }}" f"QFrame#_ext_new_row QLineEdit {{ border: 1px solid {c['border']}; border-radius: 4px; padding: 4px 6px; background: {c['card']}; color: {c['text1']}; }}") rl = QHBoxLayout(row) rl.setContentsMargins(12, 6, 12, 6) rl.setSpacing(6) ent_name = QLineEdit() ent_name.setPlaceholderText("栏目名...") ent_name.setFixedWidth(120) rl.addWidget(ent_name) ent_regex = QLineEdit() ent_regex.setPlaceholderText("正则表达式...") rl.addWidget(ent_regex, 1) lbl_s = QLabel("启用") lbl_s.setFixedWidth(50) rl.addWidget(lbl_s) btn_ok = QPushButton("确认") btn_ok.setFixedWidth(50) btn_ok.clicked.connect(lambda: self._ext_confirm_new(ent_name, ent_regex, row)) rl.addWidget(btn_ok) btn_cancel = QPushButton("取消") btn_cancel.setFixedWidth(50) btn_cancel.setStyleSheet( f"QPushButton {{ background: {c['error']}; color: #fff; border-radius: 6px; padding: 4px 8px; font-size: 9px; }}" f"QPushButton:hover {{ background: #dc2626; }}") btn_cancel.clicked.connect(lambda: (row.deleteLater(),)) rl.addWidget(btn_cancel) self._ext_pat_layout.insertWidget(self._ext_pat_layout.count() - 1, row) ent_name.setFocus() def _ext_confirm_new(self, ent_name, ent_regex, row_widget): """确认新建正则""" name = ent_name.text().strip() regex = ent_regex.text().strip() if not name or not regex: self._ext_log("栏目名和正则不能为空") return import re as _re try: _re.compile(regex) except _re.error as e: self._ext_log(f"正则语法错误: {e}") return self._ext_custom_patterns[name] = {"regex": regex, "enabled": True} row_widget.deleteLater() self._ext_refresh_custom_patterns() self._auto_save() self._ext_log(f"已添加规则: {name}") def _ext_pattern_ctx(self, pos, name): """右键菜单""" info = self._ext_custom_patterns.get(name, "") regex = info.get("regex", info) if isinstance(info, dict) else info menu = QMenu(self) menu.addAction("测试正则", lambda: self._ext_test_pattern(name)) menu.addAction("复制正则", lambda: QApplication.clipboard().setText(regex)) # 找到发送信号的 widget sender = self.sender() if sender: menu.exec(sender.mapToGlobal(pos)) else: menu.exec(self.cursor().pos()) def _ext_test_pattern(self, name): """弹窗测试正则""" info = self._ext_custom_patterns.get(name, "") regex = info.get("regex", info) if isinstance(info, dict) else info import re as _re c = _TH[self._tn] dlg = QDialog(self) dlg.setWindowTitle(f"测试正则 - {name}") dlg.resize(600, 400) dlg.setStyleSheet(f""" QDialog {{ background: {c['bg']}; color: {c['text1']}; }} QLabel {{ color: {c['text2']}; background: transparent; }} QTextEdit {{ background: {c['input']}; color: {c['text1']}; border: 1px solid {c['border']}; border-radius: 8px; padding: 6px; font-family: {_FM}; }} QPushButton {{ background: {c['accent']}; color: #111; border-radius: 8px; padding: 8px 16px; font-size: 13px; }} QPushButton:hover {{ background: {c['accent2']}; }} """) lay = QVBoxLayout(dlg) lay.setContentsMargins(16, 16, 16, 16) lay.setSpacing(8) lay.addWidget(QLabel(f"正则: {regex}")) input_lbl = QLabel("输入测试文本:") lay.addWidget(input_lbl) input_box = QTextEdit() input_box.setFont(QFont(_FM, 9)) input_box.setPlaceholderText("在此粘贴或输入要测试的文本...") lay.addWidget(input_box, 1) result_lbl = QLabel("匹配结果:") lay.addWidget(result_lbl) result_box = QTextEdit() result_box.setReadOnly(True) result_box.setFont(QFont(_FM, 9)) lay.addWidget(result_box, 1) def do_test(): text = input_box.toPlainText() try: pat = _re.compile(regex, _re.IGNORECASE) matches = pat.findall(text) if matches: if isinstance(matches[0], tuple): matches = [m.group(0) for m in pat.finditer(text)] result_box.setPlainText(f"找到 {len(matches)} 个匹配:\n" + "\n".join(str(m) for m in matches)) else: result_box.setPlainText("无匹配") except _re.error as e: result_box.setPlainText(f"正则错误: {e}") btn_test = QPushButton("测试") btn_test.clicked.connect(do_test) lay.addWidget(btn_test) dlg.exec() def _ext_edit_pattern(self, name): """编辑正则 — 替换该行为可编辑的 QLineEdit 行""" info = self._ext_custom_patterns.get(name, "") if isinstance(info, str): pat = info; enabled = True else: pat = info.get("regex", ""); enabled = info.get("enabled", True) c = _TH[self._tn] # 找到并删除原行 lay = self._ext_pat_layout idx = -1 for i in range(lay.count()): w = lay.itemAt(i).widget() if w: rl = w.layout() if rl and rl.count() > 0: first = rl.itemAt(0).widget() if isinstance(first, QLabel) and first.text() == name: idx = i w.deleteLater() break if idx < 0: idx = lay.count() - 1 # before stretch # 创建编辑行 row = QFrame() row.setObjectName("_ext_edit_row") row.setStyleSheet( f"QFrame#_ext_edit_row {{ background: {c['input']}; border-radius: 8px; border: 1px solid {c['accent']}; }}" f"QFrame#_ext_edit_row QLabel {{ background: transparent; border: none; }}" f"QFrame#_ext_edit_row QLineEdit {{ border: 1px solid {c['border']}; border-radius: 4px; padding: 4px 6px; background: {c['card']}; color: {c['text1']}; }}") rl = QHBoxLayout(row) rl.setContentsMargins(12, 6, 12, 6) rl.setSpacing(6) ent_name = QLineEdit(name) ent_name.setFixedWidth(120) rl.addWidget(ent_name) ent_regex = QLineEdit(pat) rl.addWidget(ent_regex, 1) lbl_s = QLabel("启用" if enabled else "禁用") lbl_s.setFixedWidth(50) rl.addWidget(lbl_s) btn_save = QPushButton("保存") btn_save.setFixedWidth(50) btn_save.clicked.connect(lambda: self._ext_save_edit(name, ent_name, ent_regex, enabled, row)) rl.addWidget(btn_save) btn_cancel = QPushButton("取消") btn_cancel.setFixedWidth(50) btn_cancel.setStyleSheet( f"QPushButton {{ background: {c['error']}; color: #fff; border-radius: 6px; padding: 4px 8px; font-size: 9px; }}" f"QPushButton:hover {{ background: #dc2626; }}") btn_cancel.clicked.connect(lambda: (row.deleteLater(), self._ext_refresh_custom_patterns())) rl.addWidget(btn_cancel) lay.insertWidget(idx, row) ent_name.setFocus() def _ext_save_edit(self, old_name, ent_name, ent_regex, enabled, row_widget): """保存编辑后的正则""" new_name = ent_name.text().strip() new_regex = ent_regex.text().strip() if not new_name or not new_regex: self._ext_log("栏目名和正则不能为空") return import re as _re try: _re.compile(new_regex) except _re.error as e: self._ext_log(f"正则语法错误: {e}") return self._ext_custom_patterns.pop(old_name, None) self._ext_custom_patterns[new_name] = {"regex": new_regex, "enabled": enabled} row_widget.deleteLater() self._ext_refresh_custom_patterns() self._auto_save() self._ext_log(f"已保存规则: {new_name}") def _ext_toggle_pattern(self, name): """切换正则启用/禁用""" info = self._ext_custom_patterns.get(name, "") if isinstance(info, str): self._ext_custom_patterns[name] = {"regex": info, "enabled": False} else: info["enabled"] = not info.get("enabled", True) self._ext_refresh_custom_patterns() self._auto_save() def _ext_delete_pattern(self, name): self._ext_custom_patterns.pop(name, None) self._ext_refresh_custom_patterns() self._auto_save() self._ext_log(f"已删除规则: {name}") # ── 调试开关 (vConsole) ── def _build_vconsole(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 12, 24, 16) lay.setSpacing(10) lay.setAlignment(Qt.AlignTop) # 风险警告卡片 warn_card = _make_card() warn_lay = QVBoxLayout(warn_card) warn_lay.setContentsMargins(16, 12, 16, 12) warn_lay.setSpacing(6) warn_title = QLabel("⚠ 风险提示") warn_title.setFont(QFont(_FN, 11, QFont.Bold)) warn_title.setStyleSheet("color: #e6a23c;") warn_lay.addWidget(warn_title) warn_text = QLabel( "非正规开启小程序调试有封号风险。测试需谨慎!\n" "请勿在主力账号上使用,建议使用测试号操作。") warn_text.setWordWrap(True) warn_text.setStyleSheet("color: #e6a23c; font-size: 12px;") warn_lay.addWidget(warn_text) lay.addWidget(warn_card) # 功能说明卡片 info_card = _make_card() info_lay = QVBoxLayout(info_card) info_lay.setContentsMargins(16, 12, 16, 12) info_lay.setSpacing(6) info_lay.addWidget(_make_label("功能说明", bold=True)) desc = QLabel( "通过官方 API wx.setEnableDebug 开启小程序内置的 vConsole 调试面板。\n\n" "开启后可以:\n" " • 在小程序内直接执行 JS 代码\n" " • 调用 wx.cloud.callFunction 调试云函数\n\n" "关闭后重启小程序即可恢复正常。") desc.setWordWrap(True) desc.setProperty("class", "muted") info_lay.addWidget(desc) ref_lbl = QLabel( '学习文档: ' '微信公众号文档') ref_lbl.setOpenExternalLinks(True) ref_lbl.setStyleSheet("font-size: 11px;") info_lay.addWidget(ref_lbl) lay.addWidget(info_card) # 操作卡片 op_card = _make_card() op_lay = QVBoxLayout(op_card) op_lay.setContentsMargins(16, 12, 16, 12) op_lay.setSpacing(8) op_lay.addWidget(_make_label("操作", bold=True)) btn_row = QHBoxLayout() self._btn_vc_enable = _make_btn("▶ 开启调试", self._do_vc_enable) self._btn_vc_enable.setFont(QFont(_FN, 10, QFont.Bold)) self._btn_vc_enable.setEnabled(False) btn_row.addWidget(self._btn_vc_enable) self._btn_vc_disable = _make_btn("■ 关闭调试", self._do_vc_disable) self._btn_vc_disable.setFont(QFont(_FN, 10, QFont.Bold)) self._btn_vc_disable.setEnabled(False) btn_row.addWidget(self._btn_vc_disable) btn_row.addStretch() op_lay.addLayout(btn_row) self._vc_status_lbl = QLabel("状态: 未连接小程序") self._vc_status_lbl.setProperty("class", "muted") op_lay.addWidget(self._vc_status_lbl) lay.addWidget(op_card) lay.addStretch() self._stack.addWidget(page) self._page_map["vconsole"] = self._stack.count() - 1 def _do_vc_enable(self): if not self._engine or not self._loop or not self._loop.is_running(): self._log_add("error", "[调试] 请先启动调试并连接小程序") return from PySide6.QtWidgets import QMessageBox r = QMessageBox.warning( self, "风险确认", "非正规开启小程序调试有封号风险。\n测试需谨慎!\n\n确定要开启吗?", QMessageBox.Ok | QMessageBox.Cancel, QMessageBox.Cancel, ) if r != QMessageBox.Ok: return self._btn_vc_enable.setEnabled(False) asyncio.run_coroutine_threadsafe(self._avc_set_debug(True), self._loop) def _do_vc_disable(self): if not self._engine or not self._loop or not self._loop.is_running(): self._log_add("error", "[调试] 请先启动调试并连接小程序") return self._btn_vc_disable.setEnabled(False) asyncio.run_coroutine_threadsafe(self._avc_set_debug(False), self._loop) async def _avc_set_debug(self, enable): try: val = "true" if enable else "false" # 先确保 navigator 已注入,通过 wxFrame.wx 调用避免超时 await self._navigator._ensure(force=True) result = await self._engine.evaluate_js( "(function(){" "try{" "var nav=window.nav;" "if(!nav||!nav.wxFrame||!nav.wxFrame.wx)return JSON.stringify({err:'no wxFrame'});" f"nav.wxFrame.wx.setEnableDebug({{enableDebug:{val}," "success:function(){console.log('[First] setEnableDebug success')}," "fail:function(e){console.error('[First] setEnableDebug fail',e)}" "});" "return JSON.stringify({ok:true})" "}catch(e){return JSON.stringify({err:e.message})}" "})()", timeout=5.0, ) value = None if result: r = result.get("result", {}) inner = r.get("result", {}) value = inner.get("value") if value: import json as _json info = _json.loads(value) if info.get("err"): raise RuntimeError(info["err"]) state = "已开启" if enable else "已关闭" self._rte_q.put(("__vc__", enable, True)) self._log_q.put(("info", f"[调试] vConsole {state}")) except Exception as e: self._rte_q.put(("__vc__", enable, False)) self._log_q.put(("error", f"[调试] 操作失败: {e}")) async def _avc_detect_debug(self): """自动检测小程序是否已开启 vConsole 调试。""" try: await self._navigator._ensure(force=True) result = await self._engine.evaluate_js( "(function(){" "try{" "var f=window.nav&&window.nav.wxFrame?window.nav.wxFrame:window;" "var c=f.__wxConfig||{};" "var d=!!c.debug;" "var v=!!(f.document&&f.document.getElementById('__vconsole'));" "return JSON.stringify({debug:d,vconsole:v})" "}catch(e){return JSON.stringify({err:e.message})}" "})()", timeout=5.0, ) value = None if result: r = result.get("result", {}) inner = r.get("result", {}) value = inner.get("value") if value: info = json.loads(value) if info.get("err"): return is_debug = info.get("debug", False) or info.get("vconsole", False) self._rte_q.put(("__vc_detect__", is_debug)) except Exception: pass # ── 日志 ── def _build_logs(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 12, 24, 16) lay.setSpacing(10) # 调试选项卡片 dc = _make_card() dc_lay = QVBoxLayout(dc) dc_lay.setContentsMargins(16, 10, 16, 10) dc_lay.setSpacing(6) dc_lay.addWidget(_make_label("调试选项", bold=True)) warn_lbl = QLabel("⚠ 开启后可能导致小程序卡死,请谨慎使用") warn_lbl.setStyleSheet("color: #fbbf24; font-size: 9px;") dc_lay.addWidget(warn_lbl) chkr = QHBoxLayout() self._tog_dm = ToggleSwitch(self._cfg.get("debug_main", False)) self._tog_dm.toggled.connect(lambda v: self._auto_save()) chkr.addWidget(self._tog_dm) chkr.addWidget(QLabel("调试主包")) chkr.addSpacing(24) self._tog_df = ToggleSwitch(self._cfg.get("debug_frida", False)) self._tog_df.toggled.connect(lambda v: self._auto_save()) chkr.addWidget(self._tog_df) chkr.addWidget(QLabel("调试 Frida")) chkr.addStretch() dc_lay.addLayout(chkr) lay.addWidget(dc) hdr = QHBoxLayout() hdr.addWidget(_make_label("日志输出", bold=True)) hdr.addStretch() self._btn_clear = _make_btn("清空", self._do_clear) hdr.addWidget(self._btn_clear) lay.addLayout(hdr) lc = _make_card() lc_lay = QVBoxLayout(lc) lc_lay.setContentsMargins(0, 0, 0, 0) self._logbox = QTextEdit() self._logbox.setReadOnly(True) self._logbox.setFont(QFont(_FM, 9)) lc_lay.addWidget(self._logbox) lay.addWidget(lc, 1) self._stack.addWidget(page) self._page_map["logs"] = self._stack.count() - 1 # ────────────────────────────────── # 页面切换 # ────────────────────────────────── def _show(self, pid): self._pg = pid idx = self._page_map.get(pid, 0) self._stack.setCurrentIndexAnimated(idx) titles = {k: n for k, _, n in _MENU} self._hdr_title.setText(titles.get(pid, "")) self._hl_sb() def _hl_sb(self): for pid, (fr, ic, nm) in self._sb_items.items(): if pid == self._pg: fr.setProperty("class", "sb_item_active") else: fr.setProperty("class", "sb_item") fr.style().unpolish(fr) fr.style().polish(fr) ic.style().unpolish(ic) ic.style().polish(ic) nm.style().unpolish(nm) nm.style().polish(nm) # ────────────────────────────────── # 主题 # ────────────────────────────────── def _toggle_theme(self): self._tn = "light" if self._tn == "dark" else "dark" self.setStyleSheet(build_qss(self._tn)) self._update_theme_label() self._update_toggle_colors() self._refresh_sb_app_card() self._ext_refresh_custom_patterns() self._hl_sb() self._auto_save() def _update_theme_label(self): txt = "☀ 浅色模式" if self._tn == "dark" else "☽ 深色模式" self._sb_theme.setText(txt) def _update_toggle_colors(self): c = _TH[self._tn] for tog in (self._tog_dm, self._tog_df, self._tog_auto_dec, self._tog_auto_scan): tog.set_colors(c["accent"], c["text4"]) def _refresh_sb_app_card(self): """主题切换时刷新侧栏小程序卡片颜色。""" c = _TH[self._tn] if self._sb_app_id.isVisible(): self._sb_app_name.setStyleSheet(f"color: {c['success']};") self._sb_app_id.setStyleSheet(f"color: {c['success']};") else: self._sb_app_name.setStyleSheet(f"color: {c['text3']};") def _auto_save(self): data = { "theme": self._tn, "cdp_port": self._cp_ent.text(), "debug_main": self._tog_dm.isChecked(), "debug_frida": self._tog_df.isChecked(), "extract_packages_dir": self._ext_path_ent.text(), "extract_custom_patterns": dict(self._ext_custom_patterns), "auto_decompile": self._tog_auto_dec.isChecked(), "auto_scan": self._tog_auto_scan.isChecked(), "global_hook_scripts": list(self._global_hook_scripts), } _save_cfg(data) # ────────────────────────────────── # 业务 # ────────────────────────────────── def _copy_devtools_url(self): url = self._devtools_lbl.text() if url: QApplication.clipboard().setText(url) c = _TH[self._tn] self._devtools_copy_hint.setText("已复制!") self._devtools_copy_hint.setStyleSheet(f"color: {c['success']};") QTimer.singleShot(1500, lambda: ( self._devtools_copy_hint.setText("点击复制"), self._devtools_copy_hint.setStyleSheet(f"color: {c['text3']};") )) self._log_add("info", "[gui] DevTools 链接已复制到剪贴板") def _do_clear(self): self._logbox.clear() _LOG_MAX_BLOCKS = 500 # 最多保留的日志行数 def _log_add(self, lv, txt): c = _TH[self._tn] color_map = { "info": c["text2"], "error": c["error"], "debug": c["text3"], "frida": c["accent"], "warn": c["warning"], } color = color_map.get(lv, c["text2"]) self._logbox.append(f'{txt}') # 限制日志行数,防止 QTextEdit 内容过多导致 UI 卡顿 doc = self._logbox.document() overflow = doc.blockCount() - self._LOG_MAX_BLOCKS if overflow > 50: # 攒够 50 行再批量删,减少操作频率 cursor = self._logbox.textCursor() cursor.movePosition(cursor.MoveOperation.Start) for _ in range(overflow): cursor.movePosition(cursor.MoveOperation.Down, cursor.MoveMode.KeepAnchor) cursor.removeSelectedText() cursor.deleteChar() # 删掉残留空行 sb = self._logbox.verticalScrollBar() sb.setValue(sb.maximum()) def _do_start(self): if self._running: return try: cp = int(self._cp_ent.text()) except ValueError: self._log_add("error", "[gui] 端口号无效") return opts = CliOptions( cdp_port=cp, debug_main=self._tog_dm.isChecked(), debug_frida=self._tog_df.isChecked(), scripts_dir="", script_files=[]) logger = Logger(opts) logger.set_output_callback(lambda lv, tx: self._log_q.put((lv, tx))) self._engine = DebugEngine(opts, logger) self._navigator = MiniProgramNavigator(self._engine) self._auditor = CloudAuditor(self._engine) self._engine.on_status_change(lambda s: self._sts_q.put(s)) self._loop = asyncio.new_event_loop() self._loop_th = threading.Thread( target=lambda: (asyncio.set_event_loop(self._loop), self._loop.run_forever()), daemon=True) self._loop_th.start() asyncio.run_coroutine_threadsafe(self._astart(), self._loop) self._running = True self._btn_start.setEnabled(False) self._btn_stop.setEnabled(True) self._btn_fetch.setEnabled(True) url = f"devtools://devtools/bundled/inspector.html?ws=127.0.0.1:{cp}" self._devtools_lbl.setText(url) c = _TH[self._tn] self._devtools_copy_hint.setText("点击复制") self._devtools_copy_hint.setStyleSheet(f"color: {c['text3']};") self._log_add("info", f"[gui] 浏览器访问: {url}") async def _astart(self): try: await self._engine.start() except Exception as e: self._log_q.put(("error", f"[gui] 启动失败: {e}")) QTimer.singleShot(0, self._on_fail) def _on_fail(self): self._running = False self._btn_start.setEnabled(True) self._btn_stop.setEnabled(False) self._btn_fetch.setEnabled(False) self._nav_btns(False) self._targets_btns(False) if self._loop and self._loop.is_running(): self._loop.call_soon_threadsafe(self._loop.stop) def _do_stop(self): if not self._running: return self._running = False self._poll_route_stop() if self._cloud_scan_active: self._cloud_scan_active = False if self._cloud_scan_poll_timer: self._cloud_scan_poll_timer.stop() self._cloud_scan_poll_timer = None if self._cancel_ev: self._cancel_ev.set() if self._engine and self._loop and self._loop.is_running(): fut = asyncio.run_coroutine_threadsafe(self._engine.stop(), self._loop) fut.add_done_callback(lambda _: self._loop.call_soon_threadsafe(self._loop.stop)) self._btn_start.setEnabled(True) self._btn_stop.setEnabled(False) self._btn_fetch.setEnabled(False) self._nav_btns(False) self._targets_btns(False) self._btn_autostop.setEnabled(False) self._redirect_guard_on = False self._guard_switch.setChecked(False) self._guard_label.setText("防跳转: 关闭") self._targets_tree.clear() self._targets_status_lbl.setText("状态: 未连接小程序") self._devtools_lbl.setText("") self._devtools_copy_hint.setText("") # 引擎停止,清除侧栏和运行状态卡片的小程序信息 c = _TH[self._tn] self._sb_app_name.setText("未连接") self._sb_app_name.setStyleSheet(f"color: {c['text3']};") self._sb_app_id.setText("") self._sb_app_id.setVisible(False) self._app_lbl.setText("AppID: --") self._appname_lbl.setText("") self._appname_lbl.setVisible(False) def _nav_btns(self, on): for b in (self._btn_go, self._btn_relaunch, self._btn_back, self._btn_refresh, self._btn_auto, self._btn_prev, self._btn_next, self._btn_copy_route): b.setEnabled(on) self._guard_switch.setEnabled(on) def _do_fetch(self): if self._engine and self._loop: asyncio.run_coroutine_threadsafe(self._afetch(), self._loop) async def _afetch(self): try: await self._navigator.fetch_config() self._rte_q.put(("routes", self._navigator.pages, self._navigator.tab_bar_pages)) self._rte_q.put(("app_info", self._navigator.app_info)) QTimer.singleShot(0, self._poll_route_start) # fetch_config 的 name 可能为空,补充通过 wxFrame 路径获取完整信息 await self._afetch_app_info() except Exception as e: self._log_q.put(("error", f"[导航] 获取失败: {e}")) async def _afetch_app_info(self): """通过 nav_inject 的 wxFrame.__wxConfig 获取小程序名称和appid,用于侧栏显示。""" try: # 强制重新注入 navigator(重连后 WebView 上下文是全新的) await self._navigator._ensure(force=True) result = await self._engine.evaluate_js( "(function(){" "try{" "var nav=window.nav;" "if(!nav||!nav.wxFrame)return JSON.stringify({err:'no nav'});" "var c=nav.wxFrame.__wxConfig||{};" "var ai=c.accountInfo||{};" "var aa=ai.appAccount||{};" "return JSON.stringify({" "appid:aa.appId||ai.appId||c.appid||''," "name:aa.nickname||ai.nickname||c.appname||''" "})" "}catch(e){return JSON.stringify({err:e.message})}" "})()", timeout=5.0, ) value = None if result: r = result.get("result", {}) inner = r.get("result", {}) value = inner.get("value") if value: info = json.loads(value) if info.get("err"): return self._rte_q.put(("app_info", info)) except Exception: pass def _delayed_stable_connect(self, gen): """连接稳定后再启用按钮和触发后续操作,gen 不匹配说明中间又断过,跳过。""" if gen != self._vc_stable_gen: return if not self._miniapp_connected: return self._nav_btns(True) self._targets_btns(True) self._targets_status_lbl.setText("状态: 可刷新目标") self._btn_vc_enable.setEnabled(True) self._btn_vc_disable.setEnabled(True) self._vc_status_lbl.setText("状态: 就绪") # 自动检测 vConsole 调试状态 if self._engine and self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._avc_detect_debug(), self._loop) # 延迟获取侧栏信息 self._sb_fetch_gen += 1 fetch_gen = self._sb_fetch_gen QTimer.singleShot(1500, lambda: self._delayed_fetch_app_info(fetch_gen)) # 自动恢复云扫描 if not self._cloud_scan_active and self._auditor: self._cloud_start_scan() self._log_add("info", "[云扫描] 小程序连接后自动恢复捕获") def _delayed_fetch_app_info(self, gen): """延迟调用,只有最后一次触发的 gen 匹配才执行。""" if gen != self._sb_fetch_gen: return if self._miniapp_connected and self._engine and self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._afetch_app_info(), self._loop) def _delayed_clear_app_info(self, gen): """延迟清除侧栏信息,gen 不匹配说明已重连,跳过。""" if gen != self._sb_fetch_gen: return c = _TH[self._tn] self._sb_app_name.setText("未连接") self._sb_app_name.setStyleSheet(f"color: {c['text3']};") self._sb_app_id.setText("") self._sb_app_id.setVisible(False) self._app_lbl.setText("AppID: --") self._appname_lbl.setText("") self._appname_lbl.setVisible(False) def _poll_route_start(self): if not self._running: return if self._engine and self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._apoll_route(), self._loop) self._route_poll_id = QTimer.singleShot(2000, self._poll_route_start) def _poll_route_stop(self): self._route_poll_id = None async def _apoll_route(self): try: r = await self._navigator.get_current_route() self._rte_q.put(("current", r)) if self._redirect_guard_on: blocked = await self._navigator.get_blocked_redirects() if blocked: self._rte_q.put(("blocked", blocked)) except Exception: pass def _sel_route(self): items = self._tree.selectedItems() if not items: self._log_add("error", "[导航] 请先选择路由") return None item = items[0] return item.data(0, Qt.UserRole) def _do_go(self): r = self._sel_route() if r and self._engine and self._loop: if r in self._flat_routes: self._nav_route_idx = self._flat_routes.index(r) asyncio.run_coroutine_threadsafe( self._anav("navigate_to", r, "跳转"), self._loop) def _do_relaunch(self): r = self._sel_route() if r and self._engine and self._loop: asyncio.run_coroutine_threadsafe( self._anav("relaunch_to", r, "重启"), self._loop) def _do_back(self): if self._engine and self._loop: asyncio.run_coroutine_threadsafe(self._aback(), self._loop) async def _anav(self, method, route, desc): try: await getattr(self._navigator, method)(route) self._log_q.put(("info", f"[导航] 已{desc}到: {route}")) except Exception as e: self._log_q.put(("error", f"[导航] {desc}失败: {e}")) async def _aback(self): try: await self._navigator.navigate_back() self._log_q.put(("info", "[导航] 已返回")) except Exception as e: self._log_q.put(("error", f"[导航] 返回失败: {e}")) def _do_refresh(self): if self._engine and self._loop: asyncio.run_coroutine_threadsafe(self._arefresh(), self._loop) async def _arefresh(self): try: result = await self._navigator.refresh_page() if result: import json as _json try: info = _json.loads(result) if info.get("err"): self._log_q.put(("error", f"[导航] 刷新失败: {info['err']}")) return route = info.get("route", "") self._log_q.put(("info", f"[导航] 已刷新页面: /{route}")) except (_json.JSONDecodeError, TypeError): self._log_q.put(("info", "[导航] 已刷新页面")) else: self._log_q.put(("info", "[导航] 已刷新页面")) except Exception as e: self._log_q.put(("error", f"[导航] 刷新失败: {e}")) def _do_autovis(self): if not self._navigator or not self._navigator.pages: self._log_add("error", "[导航] 请先获取路由") return self._cancel_ev = asyncio.Event() self._btn_auto.setEnabled(False) self._btn_autostop.setEnabled(True) asyncio.run_coroutine_threadsafe( self._aauto(list(self._navigator.pages)), self._loop) async def _aauto(self, pages): def prog(i, total, route): self._rte_q.put(("progress", i, total, route)) try: await self._navigator.auto_visit( pages, delay=2.0, on_progress=prog, cancel_event=self._cancel_ev) except Exception as e: self._log_q.put(("error", f"[导航] 遍历出错: {e}")) finally: self._rte_q.put(("auto_done",)) def _do_autostop(self): if self._cancel_ev: self._cancel_ev.set() self._btn_autostop.setEnabled(False) self._btn_auto.setEnabled(True) def _do_prev(self): routes = self._flat_routes or self._all_routes if not routes: self._log_add("error", "[导航] 请先获取路由") return if self._nav_route_idx <= 0: self._nav_route_idx = len(routes) - 1 else: self._nav_route_idx -= 1 route = routes[self._nav_route_idx] self._select_tree_route(route) self._log_add("info", f"[导航] 上一个: {route} ({self._nav_route_idx + 1}/{len(routes)})") if self._engine and self._loop: asyncio.run_coroutine_threadsafe( self._anav("navigate_to", route, "跳转"), self._loop) def _do_next(self): routes = self._flat_routes or self._all_routes if not routes: self._log_add("error", "[导航] 请先获取路由") return if self._nav_route_idx >= len(routes) - 1: self._nav_route_idx = 0 else: self._nav_route_idx += 1 route = routes[self._nav_route_idx] self._select_tree_route(route) self._log_add("info", f"[导航] 下一个: {route} ({self._nav_route_idx + 1}/{len(routes)})") if self._engine and self._loop: asyncio.run_coroutine_threadsafe( self._anav("navigate_to", route, "跳转"), self._loop) def _do_manual_go(self): route = self._nav_input.text().strip().lstrip("/") if not route: self._log_add("error", "[导航] 请输入路由路径") return if self._engine and self._loop: asyncio.run_coroutine_threadsafe( self._anav("navigate_to", route, "跳转"), self._loop) def _do_copy_route(self): items = self._tree.selectedItems() if not items: self._log_add("error", "[导航] 请先选择路由") return route = items[0].data(0, Qt.UserRole) if route: QApplication.clipboard().setText(route) self._log_add("info", f"[导航] 已复制路由: {route}") def _nav_context_menu(self, pos): item = self._tree.itemAt(pos) if not item: return route = item.data(0, Qt.UserRole) if not route: return self._tree.setCurrentItem(item) menu = QMenu(self) menu.addAction("复制路由", lambda: ( QApplication.clipboard().setText(route), self._log_add("info", f"[导航] 已复制: {route}"))) menu.addSeparator() menu.addAction("跳转", lambda: asyncio.run_coroutine_threadsafe( self._anav("navigate_to", route, "跳转"), self._loop) if self._engine and self._loop else None) menu.addAction("重启到页面", lambda: asyncio.run_coroutine_threadsafe( self._anav("relaunch_to", route, "重启"), self._loop) if self._engine and self._loop else None) menu.exec(self._tree.viewport().mapToGlobal(pos)) def _do_toggle_guard_switch(self, checked): if not self._engine or not self._loop: self._guard_switch.blockSignals(True) self._guard_switch.setChecked(not checked) self._guard_switch.blockSignals(False) return asyncio.run_coroutine_threadsafe(self._atoggle_guard(checked), self._loop) async def _atoggle_guard(self, enable): try: if enable: r = await self._navigator.enable_redirect_guard() if r.get("ok"): self._redirect_guard_on = True self._blocked_seen = 0 self._log_q.put(("info", "[导航] 防跳转已开启,将拦截 redirectTo/reLaunch")) QTimer.singleShot(0, lambda: self._guard_label.setText("防跳转: 开启")) else: self._redirect_guard_on = False self._log_q.put(("error", "[导航] 开启防跳转失败")) QTimer.singleShot(0, self._guard_reset_switch) else: await self._navigator.disable_redirect_guard() self._redirect_guard_on = False self._log_q.put(("info", "[导航] 防跳转已关闭")) QTimer.singleShot(0, lambda: self._guard_label.setText("防跳转: 关闭")) except Exception as e: self._log_q.put(("error", f"[导航] 防跳转切换失败: {e}")) QTimer.singleShot(0, self._guard_reset_switch) def _guard_reset_switch(self): self._guard_switch.blockSignals(True) self._guard_switch.setChecked(self._redirect_guard_on) self._guard_switch.blockSignals(False) self._guard_label.setText("防跳转: 开启" if self._redirect_guard_on else "防跳转: 关闭") def _do_filter(self): q = self._srch_ent.text().strip().lower() if not q: if self._navigator: self._fill_tree(self._all_routes, self._navigator.tab_bar_pages) return flt = [p for p in self._all_routes if q in p.lower()] self._tree.setUpdatesEnabled(False) self._tree.clear() for p in flt: item = QTreeWidgetItem([p]) item.setData(0, Qt.UserRole, p) self._tree.addTopLevelItem(item) self._tree.setUpdatesEnabled(True) def _fill_tree(self, pages, tab_bar): self._tree.setUpdatesEnabled(False) self._tree.clear() tabs = set(tab_bar) groups = {} for p in pages: parts = p.split("/") g = parts[0] if len(parts) > 1 else "(root)" groups.setdefault(g, []).append(p) flat = [] # tree visual order for prev/next tl = [p for p in pages if p in tabs] if tl: nd = QTreeWidgetItem(["TabBar"]) nd.setExpanded(True) self._tree.addTopLevelItem(nd) for p in tl: d = p.split("/")[-1] if "/" in p else p child = QTreeWidgetItem([d]) child.setData(0, Qt.UserRole, p) nd.addChild(child) flat.append(p) for g in sorted(groups): nd = QTreeWidgetItem([g]) self._tree.addTopLevelItem(nd) for p in groups[g]: if p in tabs: continue d = p[len(g) + 1:] if p.startswith(g + "/") else p child = QTreeWidgetItem([d]) child.setData(0, Qt.UserRole, p) nd.addChild(child) flat.append(p) self._flat_routes = flat self._tree.setUpdatesEnabled(True) # 默认选中并定位到第一个页面 if flat and self._nav_route_idx < 0: self._nav_route_idx = 0 self._select_tree_route(flat[0]) def _select_tree_route(self, route): """Select the tree item matching the given route path.""" for i in range(self._tree.topLevelItemCount()): top = self._tree.topLevelItem(i) if top.data(0, Qt.UserRole) == route: self._tree.setCurrentItem(top) self._tree.scrollToItem(top) return for j in range(top.childCount()): child = top.child(j) if child.data(0, Qt.UserRole) == route: self._tree.setCurrentItem(child) self._tree.scrollToItem(child) return # ────────────────────────────────── # 云扫描业务 # ────────────────────────────────── def _cloud_tree_context_menu(self, pos): item = self._cloud_tree.itemAt(pos) if not item: return self._cloud_tree.setCurrentItem(item) vals = [item.text(i) for i in range(6)] menu = QMenu(self) full_text = " | ".join(vals) menu.addAction("复制整行", lambda: QApplication.clipboard().setText(full_text)) name_str = vals[2] if len(vals) > 2 else "" if name_str: menu.addAction(f"复制名称: {name_str[:30]}", lambda: QApplication.clipboard().setText(name_str)) menu.addSeparator() row_id = id(item) if row_id in self._cloud_row_results: res = self._cloud_row_results[row_id] menu.addAction("查看返回结果", lambda: self._cloud_show_result(name_str, res)) menu.addSeparator() menu.addAction("删除此项", lambda: self._cloud_delete_item(item)) menu.exec(self._cloud_tree.viewport().mapToGlobal(pos)) def _cloud_delete_item(self, item): vals = tuple(item.text(i) for i in range(6)) idx = self._cloud_tree.indexOfTopLevelItem(item) if idx >= 0: self._cloud_tree.takeTopLevelItem(idx) self._cloud_all_items = [v for v in self._cloud_all_items if tuple(str(x) for x in v) != vals] self._cloud_row_results.pop(id(item), None) self._cloud_update_status() def _cloud_show_result(self, name, result): detail = json.dumps(result, ensure_ascii=False, indent=2, default=str) c = _TH[self._tn] self._cloud_result.setHtml(f'「{name}」返回结果:\n{detail}') def _cloud_update_status(self): count = self._cloud_tree.topLevelItemCount() total = len(self._cloud_all_items) if count < total: self._cloud_status_lbl.setText(f"显示: {count} / {total} 条") else: self._cloud_status_lbl.setText(f"捕获: {count} 条") def _cloud_filter(self): kw = self._cloud_search_ent.text().strip().lower() self._cloud_tree.clear() for vals in self._cloud_all_items: if kw and not any(kw in str(v).lower() for v in vals): continue item = QTreeWidgetItem([str(v) for v in vals]) self._cloud_tree.addTopLevelItem(item) self._cloud_update_status() def _cloud_on_select(self, item): if item and item.columnCount() >= 4: self._cloud_name_ent.setText(item.text(2)) data_str = item.text(3).strip() try: json.loads(data_str) self._cloud_data_ent.setText(data_str) except Exception: self._cloud_data_ent.setText("{}") def _cloud_ensure_auditor(self): if not self._engine or not self._loop or not self._loop.is_running(): self._log_add("error", "[云扫描] 请先启动调试") return False if not self._auditor: self._auditor = CloudAuditor(self._engine) return True def _cloud_do_toggle(self): if not self._cloud_ensure_auditor(): return if self._cloud_scan_active: self._cloud_stop_scan() else: self._cloud_start_scan() def _cloud_start_scan(self): if not self._cloud_ensure_auditor(): return self._cloud_scan_active = True c = _TH[self._tn] self._btn_cloud_toggle.setText("停止捕获") self._cloud_scan_lbl.setText("捕获中...") self._cloud_scan_lbl.setStyleSheet(f"color: {c['success']};") self._log_add("info", "[云扫描] 全局捕获已启动") asyncio.run_coroutine_threadsafe(self._acloud_start(), self._loop) self._cloud_scan_poll() async def _acloud_start(self): try: await self._auditor.start() except Exception as e: self._log_q.put(("error", f"[云扫描] Hook 启动异常: {e}")) def _cloud_stop_scan(self): self._cloud_scan_active = False c = _TH[self._tn] self._btn_cloud_toggle.setText("开启捕获") self._cloud_scan_lbl.setText("已停止") self._cloud_scan_lbl.setStyleSheet(f"color: {c['text3']};") if self._cloud_scan_poll_timer: self._cloud_scan_poll_timer.stop() self._cloud_scan_poll_timer = None if self._auditor and self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._auditor.stop(), self._loop) self._log_add("info", "[云扫描] 全局捕获已停止") def _cloud_scan_poll(self): if not self._cloud_scan_active or not self._auditor: return if self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._acloud_poll(), self._loop) self._cloud_scan_poll_timer = QTimer() self._cloud_scan_poll_timer.setSingleShot(True) self._cloud_scan_poll_timer.timeout.connect(self._cloud_scan_poll) self._cloud_scan_poll_timer.start(2000) async def _acloud_poll(self): try: new_calls = await self._auditor.poll() if new_calls: self._cld_q.put(("new_calls", new_calls)) except Exception: pass def _cloud_do_static_scan(self): if not self._cloud_ensure_auditor(): return self._btn_cloud_static.setEnabled(False) self._log_add("info", "[云扫描] 开始静态扫描 JS 源码...") asyncio.run_coroutine_threadsafe(self._acloud_static_scan(), self._loop) async def _acloud_static_scan(self): try: def progress(msg): self._log_q.put(("info", f"[云扫描] {msg}")) results = await self._auditor.static_scan(on_progress=progress) self._cld_q.put(("static_results", results)) except Exception as e: self._log_q.put(("error", f"[云扫描] 静态扫描异常: {e}")) finally: self._cld_q.put(("static_done",)) def _cloud_do_clear(self): self._cloud_tree.clear() self._cloud_all_items.clear() self._cloud_row_results.clear() if self._auditor and self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._auditor.clear(), self._loop) self._cloud_status_lbl.setText("捕获: 0 条") def _cloud_do_call(self): if not self._cloud_ensure_auditor(): return name = self._cloud_name_ent.text().strip() if not name: self._cloud_result.setPlainText("请输入函数名") return try: data = json.loads(self._cloud_data_ent.text()) except (json.JSONDecodeError, TypeError): self._cloud_result.setPlainText("参数 JSON 格式错误") return self._btn_cloud_call.setEnabled(False) self._cloud_result.setPlainText(f"正在调用 {name} ...") asyncio.run_coroutine_threadsafe(self._acloud_call(name, data), self._loop) async def _acloud_call(self, name, data): try: res = await self._auditor.call_function(name, data) self._cld_q.put(("call_result", name, res)) except Exception as e: self._cld_q.put(("call_result", name, {"ok": False, "status": "fail", "error": str(e)})) def _cloud_do_export(self): if not self._auditor: self._log_add("error", "[云扫描] 无数据") return report = self._auditor.export_report(self._cloud_all_items, self._cloud_call_history) path = os.path.join(_BASE_DIR, "cloud_audit_report.json") try: with open(path, "w", encoding="utf-8") as f: json.dump(report, f, indent=2, ensure_ascii=False, default=str) self._log_add("info", f"[云扫描] 报告已导出: {path}") except Exception as e: self._log_add("error", f"[云扫描] 导出失败: {e}") # ────────────────────────────────── # 轮询 # ────────────────────────────────── def _tick(self): for _ in range(60): # 每轮最多处理60条日志,防止阻塞UI try: msg = self._log_q.get_nowait() except queue.Empty: break if isinstance(msg, tuple) and len(msg) == 3 and msg[0] == "__hook_status__": _, fn, ok = msg self._hook_update_status(fn, ok) else: lv, tx = msg self._log_add(lv, tx) last_sts = None for _ in range(50): try: last_sts = self._sts_q.get_nowait() except queue.Empty: break if last_sts is not None: self._apply_sts(last_sts) for _ in range(50): try: item = self._rte_q.get_nowait() except queue.Empty: break self._handle_rte(item) for _ in range(50): try: item = self._cld_q.get_nowait() except queue.Empty: break self._handle_cld(item) for _ in range(50): try: item = self._tgt_q.get_nowait() except queue.Empty: break self._handle_tgt(item) for _ in range(50): try: item = self._ext_q.get_nowait() except queue.Empty: break self._handle_ext(item) def _apply_sts(self, sts): c = _TH[self._tn] is_connected = sts.get("miniapp", False) for key, (dot, lb, name) in self._dots.items(): on = sts.get(key, False) dot.set_color(c["success"] if on else c["text4"]) lb.setText(f"{name}: {'已连接' if on else '未连接'}") lb.setStyleSheet(f"color: {c['success'] if on else c['text2']};") # 断开时立即禁用(除了已有路由时保留导航按钮) if not is_connected and self._miniapp_connected: if not self._all_routes: self._nav_btns(False) self._targets_btns(False) self._targets_status_lbl.setText("状态: 未连接小程序") self._btn_vc_enable.setEnabled(False) self._btn_vc_disable.setEnabled(False) self._vc_status_lbl.setText("状态: 未连接小程序") # 清除注入标记,切换小程序时全局脚本会重新注入 self._hook_injected.clear() self._hook_refresh() # 已有路由数据时不清除侧栏信息(短暂断连不影响) if not self._all_routes: self._sb_fetch_gen += 1 gen = self._sb_fetch_gen QTimer.singleShot(5000, lambda: self._delayed_clear_app_info(gen)) # 小程序连接时,独立定时器触发全局 Hook 注入(不受 CDP 等状态变化影响) if is_connected and not self._miniapp_connected and self._global_hook_scripts: self._global_inject_gen += 1 _gi_gen = self._global_inject_gen QTimer.singleShot(1500, lambda: self._do_global_inject(_gi_gen)) # 连接时延迟启用,等连接稳定(防止重启时反复抖动) self._vc_stable_gen += 1 gen_stable = self._vc_stable_gen if is_connected: QTimer.singleShot(1500, lambda: self._delayed_stable_connect(gen_stable)) self._miniapp_connected = is_connected def _handle_rte(self, item): kind = item[0] if kind == "routes": _, pages, tab = item self._all_routes = list(pages) self._fill_tree(pages, tab) elif kind == "app_info": info = item[1] aid = info.get("appid", "") aname = info.get("name", "") ent = info.get("entry", "") # 运行状态卡片 — appid txt = f"AppID: {aid}" if aid else "AppID: --" if ent: txt += f" | 入口: {ent}" self._app_lbl.setText(txt) # 运行状态卡片 — 名称 if aname: self._appname_lbl.setText(f"当前链接小程序: {aname}") self._appname_lbl.setVisible(True) else: self._appname_lbl.setVisible(False) # 更新侧栏小程序信息卡片 c = _TH[self._tn] if aname or aid: self._sb_app_name.setText(f"名称: {aname}" if aname else "名称: --") self._sb_app_name.setStyleSheet(f"color: {c['success']};") self._sb_app_id.setText(f"AppID: {aid}" if aid else "AppID: --") self._sb_app_id.setStyleSheet(f"color: {c['success']};") self._sb_app_id.setVisible(True) else: self._sb_app_name.setText("未连接") self._sb_app_name.setStyleSheet(f"color: {c['text3']};") self._sb_app_id.setVisible(False) elif kind == "current": r = item[1] self._route_lbl.setText(f"当前路由: /{r}" if r else "当前路由: --") if r: routes = self._flat_routes or self._all_routes if r in routes: self._nav_route_idx = routes.index(r) self._select_tree_route(r) elif kind == "progress": _, i, total, route = item if total > 0: self._prog.setValue(int((i / total) * 100)) if route != "done": self._select_tree_route(route) self._route_lbl.setText( f"正在访问: /{route}" if route != "done" else "遍历完成") elif kind == "blocked": blocked = item[1] for b in blocked[self._blocked_seen:]: self._log_add("warn", f"[防跳转] 拦截 {b.get('type','')} → {b.get('url','')} ({b.get('time','')})") self._blocked_seen = len(blocked) elif kind == "auto_done": self._prog.setValue(100) self._btn_auto.setEnabled(True) self._btn_autostop.setEnabled(False) self._log_add("info", "[导航] 遍历完成") elif kind == "__vc__": _, enable, ok = item c = _TH[self._tn] if ok: if enable: self._vc_status_lbl.setText("状态: 已开启 (重启小程序后生效)") self._vc_status_lbl.setStyleSheet(f"color: {c['success']};") else: self._vc_status_lbl.setText("状态: 已关闭 (重启小程序后生效)") self._vc_status_lbl.setStyleSheet(f"color: {c['text3']};") else: self._vc_status_lbl.setText("状态: 操作失败") self._vc_status_lbl.setStyleSheet(f"color: {c['error']};") self._btn_vc_enable.setEnabled(True) self._btn_vc_disable.setEnabled(True) elif kind == "__vc_detect__": is_debug = item[1] c = _TH[self._tn] if is_debug: self._vc_status_lbl.setText("状态: 已开启") self._vc_status_lbl.setStyleSheet(f"color: {c['success']};") else: self._vc_status_lbl.setText("状态: 未开启") self._vc_status_lbl.setStyleSheet(f"color: {c['text3']};") def _handle_cld(self, item): kind = item[0] c = _TH[self._tn] _type_cn = {"function": "云函数", "storage": "存储", "container": "容器"} if kind == "new_calls": calls = item[1] if calls: kw = self._cloud_search_ent.text().strip().lower() for call in calls: data_str = json.dumps(call.get("data", {}), ensure_ascii=False) if len(data_str) > 80: data_str = data_str[:77] + "..." ctype = call.get("type", "function") type_label = _type_cn.get(ctype, ctype) if ctype.startswith("db"): type_label = "数据库" status = call.get("status", "") vals = (call.get("appId", ""), type_label, call.get("name", ""), data_str, status, call.get("timestamp", "")) self._cloud_all_items.append(vals) if kw and not any(kw in str(v).lower() for v in vals): continue tree_item = QTreeWidgetItem([str(v) for v in vals]) self._cloud_tree.addTopLevelItem(tree_item) result_data = call.get("result") or call.get("error") if result_data is not None: self._cloud_row_results[id(tree_item)] = { "status": status, "result": call.get("result"), "error": call.get("error"), "data": call.get("data"), } self._cloud_tree.scrollToBottom() self._cloud_update_status() self._cloud_scan_lbl.setText(f"捕获中... {len(self._cloud_all_items)} 条") self._cloud_scan_lbl.setStyleSheet(f"color: {c['success']};") elif kind == "static_results": funcs = item[1] if funcs: kw = self._cloud_search_ent.text().strip().lower() for f in funcs: params = ", ".join(f.get("params", [])) or "--" if len(params) > 80: params = params[:77] + "..." ftype = f.get("type", "function") type_label = {"function": "云函数", "storage": "存储", "database": "数据库"}.get(ftype, ftype) vals = (f.get("appId", ""), f"[静态]{type_label}", f["name"], params, f"x{f.get('count',1)}", "") self._cloud_all_items.append(vals) if kw and not any(kw in str(v).lower() for v in vals): continue tree_item = QTreeWidgetItem([str(v) for v in vals]) self._cloud_tree.addTopLevelItem(tree_item) self._cloud_tree.scrollToBottom() self._cloud_update_status() self._log_add("info", f"[云扫描] 静态扫描发现 {len(funcs)} 个云函数引用") elif kind == "static_done": self._btn_cloud_static.setEnabled(True) elif kind == "call_result": _, name, res = item self._btn_cloud_call.setEnabled(True) status = res.get("status", "unknown") if status == "success": detail = json.dumps(res.get("result", {}), ensure_ascii=False, default=str) self._cloud_result.setHtml( f'{name} -> 成功:\n{detail}') elif status == "fail": err = res.get("error", "") or res.get("reason", "未知错误") self._cloud_result.setHtml( f'{name} -> 失败: {err}') else: detail = json.dumps(res, ensure_ascii=False, default=str) self._cloud_result.setHtml( f'{name} -> {detail}') def _handle_ext(self, item): kind = item.get("type", "") c = _TH[self._tn] op = self._ext_current_op # ("decompile"/"scan", appid) or None if kind == "progress": done = item.get("done", 0) total = item.get("total", 1) if total > 0: self._ext_prog.setValue(int((done / total) * 100)) self._ext_status_lbl.setText(f"进度: {done}/{total}") elif kind == "log": self._ext_log(item.get("msg", "")) elif kind == "result": data = item.get("data", {}) if op: op_type, appid = op if op_type == "decompile": # 反编译完成 state = self._ext_app_states.setdefault(appid, {}) state["decompiled"] = True decompile_dir = data.get("decompile_dir", "") if decompile_dir: state["decompile_dir"] = decompile_dir extracted = data.get("extracted", 0) self._ext_status_lbl.setText(f"反编译完成! {appid} 提取了 {extracted} 个文件") self._ext_status_lbl.setStyleSheet(f"color: {c['success']};") self._ext_update_app_buttons(appid) # 刷新名称显示(解包后可读取 app-config.json) widgets = self._ext_app_widgets.get(appid, {}) if "lbl_name" in widgets: pkgs = state.get("packages", []) output_base = os.path.join(_BASE_DIR, "output") name = self._ext_get_app_name(appid, pkgs, output_base) widgets["lbl_name"].setText(name) elif op_type == "scan": # 扫描完成 state = self._ext_app_states.setdefault(appid, {}) state["scanned"] = True result_dir = data.get("result_dir", "") if result_dir: state["result_dir"] = result_dir findings = data.get("findings", 0) self._ext_status_lbl.setText(f"扫描完成! {appid} 发现 {findings} 条敏感信息") self._ext_status_lbl.setStyleSheet(f"color: {c['success']};") self._ext_update_app_buttons(appid) elif kind == "error": self._ext_log(f"错误: {item.get('msg', '')}") self._ext_status_lbl.setText("出错") self._ext_status_lbl.setStyleSheet(f"color: {c['error']};") elif kind == "__done__": self._ext_proc = None prev_op = self._ext_current_op self._ext_current_op = None self._ext_prog.setValue(100) rc = item.get("returncode", -1) if rc != 0 and "完成" not in self._ext_status_lbl.text(): self._ext_status_lbl.setText(f"进程退出 (code={rc})") self._ext_status_lbl.setStyleSheet(f"color: {c['error']};") # 自动处理链: 反编译完成后 → 自动扫描; 扫描完成后 → 处理下一个 if prev_op and rc == 0: op_type, appid = prev_op if op_type == "decompile" and self._tog_auto_scan.isChecked(): state = self._ext_app_states.get(appid, {}) if state.get("decompiled") and not state.get("scanned"): QTimer.singleShot(500, lambda a=appid: self._ext_do_scan(a)) return # 继续处理其他未完成的小程序 QTimer.singleShot(500, self._ext_auto_process_pending) # ────────────────────────────────── # 退出 # ────────────────────────────────── def closeEvent(self, event): if self._ext_proc: try: self._ext_proc.kill() # 强制杀死子进程 self._ext_proc.wait(timeout=2) except Exception: pass self._ext_proc = None if self._running: self._do_stop() QTimer.singleShot(400, lambda: QApplication.quit()) event.ignore() else: event.accept() if __name__ == "__main__": multiprocessing.freeze_support() # PyInstaller 打包需要 import signal signal.signal(signal.SIGINT, signal.SIG_DFL) # Ctrl+C 直接退出 # Windows 任务栏图标: 设置 AppUserModelID 使其显示自定义图标而非 Python 默认图标 if sys.platform == "win32": try: import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("spade.first.gui") except Exception: pass app = QApplication(sys.argv) app.setFont(QFont(_FN, 9)) _ico = os.path.join(_BASE_DIR, "icon.png") if os.path.exists(_ico): app.setWindowIcon(QIcon(_ico)) window = App() window.show() sys.exit(app.exec())