"""Opt-in debug copy. Never re-sign the installed app or change system policy.""" import hashlib import json import plistlib import secrets import shutil import subprocess import time from pathlib import Path from .mac_keys import wechat_pid def run(args, timeout=90): result = subprocess.run(args, capture_output=True, timeout=timeout) if result.returncode: raise ValueError('系统操作未成功:'+Path(args[0]).name+'。请检查应用完整性、文件访问权限或系统提示;原版应用没有被重签。') return result def entitlements(app): result = run(['/usr/bin/codesign', '-d', '--entitlements', ':-', str(app)]) for data in (result.stdout, result.stderr): start = data.find(b'') if start >= 0 and end >= start: value = plistlib.loads(data[start:end+8]) if not isinstance(value, dict): raise ValueError('应用权限文件格式错误') return value if data.startswith(b'bplist'): return plistlib.loads(data) return {} def fingerprint(app): digest = hashlib.sha256() for name in ('Contents/Info.plist', 'Contents/MacOS/WeChat', 'Contents/_CodeSignature/CodeResources'): path = app/name if path.is_file(): with path.open('rb') as file: while chunk := file.read(1024**2): digest.update(chunk) return digest.hexdigest() def inventory(root): result = {} for path in root.rglob('*'): if path.is_symlink(): raise ValueError('账号数据包含符号链接,无法创建独立的完整备份,已停止') if path.is_file(): stat = path.stat() result[str(path.relative_to(root))] = [stat.st_size, stat.st_mtime_ns] return result def safe_app_links(app): for link in app.rglob('*'): if link.is_symlink() and (link.readlink().is_absolute() or not link.resolve().is_relative_to(app)): raise ValueError('应用包含绝对路径或越界的符号链接,无法确保仅修改副本,已停止') class DebugRecovery: def __init__(self, data): self.data = Path(data).resolve() self.plans = {} self.prepared = None def plan(self, account, app=None): candidates = [Path('/Applications/WeChat.app'), Path.home()/'Applications/WeChat.app'] app = app or next((p for p in candidates if p.is_dir()), None) if not app: raise ValueError('未找到已安装的原版 WeChat.app') app = app.resolve() if self.data.is_relative_to(app) or app.is_relative_to(self.data): raise ValueError('工具数据目录不能与原版微信应用目录重叠') safe_app_links(app) info = plistlib.loads((app/'Contents/Info.plist').read_bytes()) if info.get('CFBundleIdentifier') != 'com.tencent.xinWeChat' or info.get('CFBundleExecutable') != 'WeChat': raise ValueError('应用标识不符合预期,已停止') run(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(app)]) token = secrets.token_hex(16) root = self.data/'mac_debug'/token # Copy the entire account plus login configuration before any clone launch. sources = [Path(account).parent] all_users = Path(account).parent.parent/'all_users' if all_users.is_dir(): sources.append(all_users) size = sum(sum(v[0] for v in inventory(source).values()) for source in sources) app_size = sum(p.stat().st_size for p in app.rglob('*') if p.is_file() and not p.is_symlink()) plan = {'id': token, 'created': time.monotonic(), 'app': app, 'account': Path(account), 'sources': sources, 'root': root, 'fingerprint': fingerprint(app), 'estimated_bytes': size+app_size+512*1024**2, 'version': str(info.get('CFBundleShortVersionString', '未知'))} self.plans = {token: plan} return {'plan_id': token, 'source': str(app), 'copy': str(root/'WeChat.app'), 'backup': str(root/'backup'), 'version': plan['version'], 'estimated_bytes': plan['estimated_bytes'], 'effects': ['先完整备份所选账号目录与本机登录配置,可能包含图片、附件,所需空间比只备份数据库更多。', '仅对工具目录里的临时应用副本重新签名并允许调试;不改动已安装的原版微信,不关闭 SIP。', '副本保留原应用标识,登录时可能使用或更新同一账号的本地数据,可能要求重新登录或系统授权。', '有调试权限的副本更容易被本机调试工具读取。仅用于本人账号取钥,用完退出副本并恢复原版。', '副本能否启动、登录和取钥仍需本机验证;失败不会自动重试、删除备份或修改原版。']} def consume(self, token, consent, account): if consent is not True: raise ValueError('请先阅读操作影响并明确勾选同意,才能创建调试副本') plan = self.plans.pop(token, None) if not plan or time.monotonic()-plan['created'] > 1800: raise ValueError('副本操作预览已过期或已使用,请重新预览') if Path(account) != plan['account']: raise ValueError('账号发生变化,请重新预览') return plan def prepare(self, plan, stage): if wechat_pid(): raise ValueError('请先正常退出所有微信,再创建临时副本;知微保持运行') if fingerprint(plan['app']) != plan['fingerprint']: raise ValueError('原版微信在预览后发生变化,请重新预览') safe_app_links(plan['app']) run(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(plan['app'])]) self.data.mkdir(parents=True, exist_ok=True, mode=0o700) if shutil.disk_usage(self.data).free < plan['estimated_bytes']: raise ValueError('磁盘空间不足以完整备份账号并创建应用副本') root = plan['root'] if not root.resolve().is_relative_to(self.data) or root.exists(): raise ValueError('副本目录无效或已存在,已停止') root.mkdir(parents=True, mode=0o700) stage('正在完整备份账号数据和登录配置;请保持微信退出') manifest = [] for index, source in enumerate(plan['sources']): before = inventory(source) destination = root/'backup'/f'{index}-{source.name}' shutil.copytree(source, destination) copied = inventory(destination) if {name:value[0] for name,value in copied.items()} != {name:value[0] for name,value in before.items()}: raise ValueError('备份文件清单或大小不完整,已停止,未创建调试副本') if inventory(source) != before or wechat_pid(): raise ValueError('备份期间账号数据发生变化,已停止;请退出微信后重新预览') manifest.append({'source': str(source), 'backup': str(destination), 'files': len(before)}) (root/'backup-manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8') stage('正在创建临时应用副本;原版安装文件不修改') copy = root/'WeChat.app' shutil.copytree(plan['app'], copy, symlinks=True) safe_app_links(copy) if fingerprint(copy) != plan['fingerprint'] or fingerprint(plan['app']) != plan['fingerprint']: raise ValueError('应用复制前后不一致,请重新预览') grants = entitlements(plan['app']) grants['com.apple.security.get-task-allow'] = True permissions = root/'debug-entitlements.plist' permissions.write_bytes(plistlib.dumps(grants)) stage('仅为临时副本添加调试权限并校验签名') run(['/usr/bin/codesign', '--force', '--deep', '--options', 'runtime', '--timestamp=none', '--sign', '-', '--entitlements', str(permissions), str(copy)], timeout=180) run(['/usr/bin/codesign', '--verify', '--deep', '--strict', str(copy)]) if entitlements(copy).get('com.apple.security.get-task-allow') is not True: raise ValueError('临时副本未取得预期调试权限,已停止') if fingerprint(plan['app']) != plan['fingerprint']: raise ValueError('原版应用在操作期间发生变化,请先检查原版状态') self.prepared = {'app': copy, 'fingerprint': fingerprint(copy), 'root': root, 'original': plan['app']} return {'message': '备份与临时副本已准备。下一步点击“启动临时副本”,在副本中登录并打开聊天,再读取密钥。', 'backup': str(root/'backup'), 'copy': str(copy), 'original_unchanged': True} def launch(self): if not self.prepared: raise ValueError('请先预览并创建临时副本') if wechat_pid(): raise ValueError('请先退出正在运行的微信,再启动临时副本') copy = self.prepared['app'] if not copy.resolve().is_relative_to(self.data) or fingerprint(copy) != self.prepared['fingerprint']: raise ValueError('临时副本已变化,拒绝启动,请重新准备') run(['/usr/bin/open', '-n', str(copy)]) return {'message': '已向 macOS 请求启动临时副本。请按系统提示完成打开/登录,进入聊天后再点击读取密钥;未自动绕过系统拦截。'}