From 797004582f9fc64e43cea2355cf434683a52f15f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=89=9F=E4=B8=8D=E6=87=82=E5=93=8E?= <1797499294@qq.com>
Date: Mon, 4 May 2026 23:07:44 +0800
Subject: [PATCH] v1.0.8
---
gui.py | 4201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
启动.bat | 119 ++
启动.sh | 137 ++
3 files changed, 4457 insertions(+)
create mode 100644 gui.py
create mode 100644 启动.bat
create mode 100755 启动.sh
diff --git a/gui.py b/gui.py
new file mode 100644
index 0000000..2f81e23
--- /dev/null
+++ b/gui.py
@@ -0,0 +1,4201 @@
+"""
+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}
+# Platform-aware default fonts (overridable via gui_config.json: font_ui / font_mono)
+if sys.platform == "darwin":
+ _FN = "PingFang SC"
+ _FM = "Menlo"
+else:
+ _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
+
+
+# Apply font overrides from config.
+# Editable fields in gui_config.json:
+# font_ui — UI font family name
+# font_ui_size — UI base font size (int, default 9)
+# font_mono — monospace font family name
+# font_mono_size — monospace font size (int, default 9)
+_early_cfg = _load_cfg()
+if _early_cfg.get("font_ui"):
+ _FN = _early_cfg["font_ui"]
+if _early_cfg.get("font_mono"):
+ _FM = _early_cfg["font_mono"]
+_FN_SIZE: int = int(_early_cfg.get("font_ui_size", 9))
+_FM_SIZE: int = int(_early_cfg.get("font_mono_size", 9))
+
+# Write defaults back so the fields appear in gui_config.json on first launch
+_init_cfg = dict(_early_cfg)
+_cfg_dirty = False
+for _k, _v in [("font_ui", _FN), ("font_ui_size", _FN_SIZE),
+ ("font_mono", _FM), ("font_mono_size", _FM_SIZE)]:
+ if _k not in _init_cfg:
+ _init_cfg[_k] = _v
+ _cfg_dirty = True
+if _cfg_dirty:
+ _save_cfg(_init_cfg)
+del _init_cfg, _cfg_dirty
+
+
+def _qfn(offset: int = 0, weight=None):
+ """Return QFont for UI font with size relative to _FN_SIZE."""
+ f = QFont(_FN, max(6, _FN_SIZE + offset))
+ if weight is not None:
+ f.setWeight(weight)
+ return f
+
+
+def _qfm(offset: int = 0, weight=None):
+ """Return QFont for mono font with size relative to _FM_SIZE."""
+ f = QFont(_FM, max(6, _FM_SIZE + offset))
+ if weight is not None:
+ f.setWeight(weight)
+ return f
+
+
+# ══════════════════════════════════════════
+# 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(_qfm(1))
+ 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(1060, 660)
+ self.setMinimumSize(900, 540)
+
+ 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(_qfn(4))
+ nm = QLabel(name)
+ nm.setProperty("class", "sb_name")
+ nm.setFont(_qfn(1))
+ 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(_qfn(-1))
+ 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(_qfn(-1))
+ 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(_qfn())
+ 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(_qfn(-1))
+ 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(_qfn(-2))
+ 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.8")
+ sb_ver.setObjectName("sb_theme")
+ sb_ver.setAlignment(Qt.AlignCenter)
+ sb_ver.setFont(_qfn(-2))
+ 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(_qfn(1, QFont.Bold))
+ ar.addWidget(self._btn_start)
+ self._btn_stop = _make_btn("■ 停止", self._do_stop)
+ self._btn_stop.setFont(_qfn(1, 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(_qfm(-1))
+ 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(_qfn(-1))
+ 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.setFont(_qfn())
+ 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(_qfm(-1, QFont.Bold))
+ icon_lbl.setFixedWidth(30)
+ icon_lbl.setAlignment(Qt.AlignCenter)
+ row_lay.addWidget(icon_lbl)
+
+ name_lbl = QLabel(fn)
+ name_lbl.setFont(_qfn(1))
+ 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.setFont(_qfn())
+ self._targets_tree.header().setFont(_qfn())
+ 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.Interactive)
+ hdr.setSectionResizeMode(2, QHeaderView.Stretch)
+ hdr.setSectionResizeMode(3, QHeaderView.Interactive)
+ hdr.setMinimumSectionSize(50)
+ self._targets_tree.setColumnWidth(1, 200)
+ self._targets_tree.setColumnWidth(3, 290)
+ 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.setFont(_qfn())
+ self._cloud_tree.header().setFont(_qfn())
+ 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(_qfm())
+ 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(_qfm())
+ 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.setFont(_qfn())
+ self._ext_builtin_tree.header().setFont(_qfn())
+ 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(_qfm())
+ 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(_qfn())
+ 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, "lbl_id": lbl_id,
+ }
+
+ # 插入在最前面(最新的在上面)
+ 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(_qfn(2, 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(_qfm())
+ 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(_qfm())
+ 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(_qfm())
+ 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(_qfm())
+ 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(_qfm())
+ 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(_qfn(2, 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(_qfn(1, 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(_qfn(1, 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(_qfm())
+ 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_restyle_rows()
+ self._ext_refresh_custom_patterns()
+ self._hl_sb()
+ self._auto_save()
+
+ def _ext_restyle_rows(self):
+ """切换主题时更新应用列表行的内联样式。"""
+ c = _TH[self._tn]
+ row_ss = (
+ f"QFrame {{ background: {c['input']}; border-radius: 8px; }}"
+ f"QFrame QLabel {{ background: transparent; }}"
+ )
+ for appid, widgets in self._ext_app_widgets.items():
+ row = widgets.get("row")
+ if row:
+ row.setStyleSheet(row_ss)
+ lbl_id = widgets.get("lbl_id")
+ if lbl_id:
+ lbl_id.setStyleSheet(f"color: {c['text1']};")
+ lbl_name = widgets.get("lbl_name")
+ if lbl_name:
+ lbl_name.setStyleSheet(f"color: {c['text2']};")
+ # 反编译按钮颜色也跟主题走
+ btn_dec = widgets.get("btn_dec")
+ if btn_dec:
+ state = self._ext_app_states.get(appid, {})
+ if state.get("decompiled"):
+ btn_dec.setStyleSheet(
+ f"QPushButton {{ background: {c['success']}; color: #111; "
+ f"border-radius: 6px; padding: 4px 8px; font-size: 9px; }}")
+
+ 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),
+ "font_ui": _FN,
+ "font_ui_size": _FN_SIZE,
+ "font_mono": _FM,
+ "font_mono_size": _FM_SIZE,
+ }
+ _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, _FN_SIZE))
+ _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())
diff --git a/启动.bat b/启动.bat
new file mode 100644
index 0000000..bc0f27f
--- /dev/null
+++ b/启动.bat
@@ -0,0 +1,119 @@
+@echo off
+setlocal enabledelayedexpansion
+chcp 437 >nul
+title First - Launcher
+cd /d "%~dp0"
+
+set "CHOICE_FILE=.launch_choice"
+
+:: ══════════════════════════════════════════
+:: Read saved choice and launch directly
+:: ══════════════════════════════════════════
+if exist "%CHOICE_FILE%" (
+ set /p SAVED=<"%CHOICE_FILE%"
+ if "!SAVED!"=="uv" goto :RUN_UV
+ if "!SAVED!"=="system" goto :RUN_SYSTEM
+)
+
+:: ══════════════════════════════════════════
+:: First run: ask user to choose environment
+:: ══════════════════════════════════════════
+echo.
+echo ============================================
+echo First ^| Launch Configuration
+echo ============================================
+echo.
+echo Please choose how to run First:
+echo.
+echo [1] uv virtual environment (Recommended)
+echo Isolated dependencies, won't affect system Python
+echo.
+echo [2] System Python
+echo Use system-installed Python directly
+echo.
+set /p CHOICE=" Enter choice (1/2): "
+
+if "%CHOICE%"=="1" goto :SETUP_UV
+if "%CHOICE%"=="2" goto :SETUP_SYSTEM
+echo Invalid choice. Exiting.
+pause
+exit /b 1
+
+:: ══════════════════════════════════════════
+:: Branch: uv virtual environment
+:: ══════════════════════════════════════════
+:SETUP_UV
+where uv >nul 2>nul
+if errorlevel 1 (
+ echo.
+ echo [WARNING] uv not found on this system.
+ echo.
+ set /p INSTALLUV=" Install uv now? (pip install uv) [Y/N]: "
+ if /i "!INSTALLUV!"=="Y" (
+ echo Installing uv...
+ pip install uv
+ if errorlevel 1 (
+ echo [ERROR] uv installation failed. Please run manually: pip install uv
+ pause
+ exit /b 1
+ )
+ ) else (
+ echo Cancelled.
+ pause
+ exit /b 0
+ )
+)
+
+:RUN_UV
+echo.
+echo Syncing uv virtual environment...
+echo.
+uv sync -i https://pypi.tuna.tsinghua.edu.cn/simple
+if errorlevel 1 (
+ echo.
+ echo [ERROR] uv sync failed. Check network or pyproject.toml.
+ pause
+ exit /b 1
+)
+echo uv>"%CHOICE_FILE%"
+start "" ".venv\Scripts\pythonw.exe" gui.py
+exit /b 0
+
+:: ══════════════════════════════════════════
+:: Branch: System Python
+:: ══════════════════════════════════════════
+:SETUP_SYSTEM
+where python >nul 2>nul
+if errorlevel 1 (
+ echo.
+ echo [ERROR] python not found. Please install Python 3.9+ first.
+ pause
+ exit /b 1
+)
+
+echo.
+echo Checking dependencies...
+python -c "import frida, websockets, google.protobuf, PySide6, Crypto" >nul 2>nul
+if errorlevel 1 (
+ echo Some dependencies are missing.
+ echo.
+ set /p INSTALLDEPS=" Install dependencies now? (pip install -r requirements.txt) [Y/N]: "
+ if /i "!INSTALLDEPS!"=="Y" (
+ echo Installing dependencies...
+ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+ if errorlevel 1 (
+ echo [ERROR] Installation failed. Try manually: pip install -r requirements.txt
+ pause
+ exit /b 1
+ )
+ ) else (
+ echo Cancelled.
+ pause
+ exit /b 0
+ )
+)
+
+:RUN_SYSTEM
+echo system>"%CHOICE_FILE%"
+start "" pythonw gui.py
+exit /b 0
diff --git a/启动.sh b/启动.sh
new file mode 100755
index 0000000..a77b865
--- /dev/null
+++ b/启动.sh
@@ -0,0 +1,137 @@
+#!/bin/bash
+cd "$(dirname "$0")"
+
+# ── 配置文件路径 ──
+CHOICE_FILE=".launch_choice"
+
+# ══════════════════════════════════════════
+# 对话框工具(macOS 用 osascript,Linux 降级为 read)
+# ══════════════════════════════════════════
+dialog_choice() {
+ # $1=标题 $2=提示文字 $3=按钮1 $4=按钮2
+ # 返回 0 = 按钮1,1 = 按钮2
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ local btn
+ btn=$(osascript -e "button returned of (display dialog \"$2\" with title \"$1\" buttons {\"$4\", \"$3\"} default button \"$3\")" 2>/dev/null)
+ [[ "$btn" == "$3" ]] && return 0 || return 1
+ else
+ echo ""
+ echo " $1"
+ echo " $2"
+ echo " [1] $3 [2] $4"
+ read -rp " 请输入选择 (1/2): " sel
+ [[ "$sel" == "1" ]] && return 0 || return 1
+ fi
+}
+
+dialog_info() {
+ # $1=标题 $2=内容
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ osascript -e "display dialog \"$2\" with title \"$1\" buttons {\"确定\"} default button \"确定\"" >/dev/null 2>&1
+ else
+ echo ""
+ echo " [$1] $2"
+ echo ""
+ fi
+}
+
+# ══════════════════════════════════════════
+# 读取上次选择
+# ══════════════════════════════════════════
+if [[ -f "$CHOICE_FILE" ]]; then
+ SAVED=$(cat "$CHOICE_FILE")
+ # 直接按上次结果启动
+ if [[ "$SAVED" == "uv" ]]; then
+ echo " [启动] 使用 uv 虚拟环境..."
+ uv sync -i https://pypi.tuna.tsinghua.edu.cn/simple
+ if [[ $? -ne 0 ]]; then
+ dialog_info "启动失败" "uv sync 失败,请检查网络或 uv 配置。"
+ exit 1
+ fi
+ .venv/bin/python gui.py &
+ exit 0
+ elif [[ "$SAVED" == "system" ]]; then
+ echo " [启动] 使用系统 Python..."
+ python3 gui.py &
+ exit 0
+ fi
+fi
+
+# ══════════════════════════════════════════
+# 首次运行:让用户选择环境
+# ══════════════════════════════════════════
+dialog_choice "First — 启动配置" \
+ "请选择运行方式:\n\n ✦ uv 虚拟环境(推荐)\n 自动隔离依赖,不影响系统 Python\n\n ✦ 系统 Python\n 使用系统已安装的 Python 直接运行" \
+ "uv 虚拟环境" "系统 Python"
+USE_UV=$?
+
+# ══════════════════════════════════════════
+# 分支:uv 虚拟环境
+# ══════════════════════════════════════════
+if [[ $USE_UV -eq 0 ]]; then
+
+ # 检查 uv 是否已安装
+ if ! command -v uv >/dev/null 2>&1; then
+ dialog_choice "未找到 uv" \
+ "系统中未检测到 uv 工具。\n是否现在安装 uv?\n\n(将执行:pip install uv)" \
+ "安装 uv" "取消"
+ if [[ $? -eq 0 ]]; then
+ echo " 正在安装 uv..."
+ pip install uv
+ if [[ $? -ne 0 ]]; then
+ dialog_info "安装失败" "uv 安装失败,请手动执行:pip install uv"
+ exit 1
+ fi
+ else
+ echo " 已取消。"
+ exit 0
+ fi
+ fi
+
+ echo " 正在同步 uv 虚拟环境..."
+ uv sync -i https://pypi.tuna.tsinghua.edu.cn/simple
+ if [[ $? -ne 0 ]]; then
+ dialog_info "同步失败" "uv sync 失败,请检查网络连接或 pyproject.toml 配置。"
+ exit 1
+ fi
+
+ echo "uv" > "$CHOICE_FILE"
+ .venv/bin/python gui.py &
+ exit 0
+
+# ══════════════════════════════════════════
+# 分支:系统 Python
+# ══════════════════════════════════════════
+else
+
+ # 检查 python3
+ if ! command -v python3 >/dev/null 2>&1; then
+ dialog_info "未找到 Python" "系统中未检测到 python3,请先安装 Python 3.9+。"
+ exit 1
+ fi
+
+ # 检查是否缺少依赖
+ MISSING=0
+ python3 -c "import frida, websockets, google.protobuf, PySide6, Crypto" 2>/dev/null || MISSING=1
+
+ if [[ $MISSING -eq 1 ]]; then
+ dialog_choice "缺少依赖库" \
+ "检测到部分依赖库未安装。\n是否现在安装?\n\n(将执行:pip install -r requirements.txt)" \
+ "立即安装" "取消"
+ if [[ $? -eq 0 ]]; then
+ echo " 正在安装依赖..."
+ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+ if [[ $? -ne 0 ]]; then
+ dialog_info "安装失败" "依赖安装失败,请检查网络或手动执行:\npip install -r requirements.txt"
+ exit 1
+ fi
+ else
+ echo " 已取消。"
+ exit 0
+ fi
+ fi
+
+ echo "system" > "$CHOICE_FILE"
+ python3 gui.py &
+ exit 0
+fi