From 5faafb70751c8d98c28a3cec071084bf1af262c3 Mon Sep 17 00:00:00 2001 From: Air Date: Sun, 8 Feb 2026 21:47:29 +0800 Subject: [PATCH] add revoke msg highlight --- .gitignore | 1 + Makefile | 21 +- Plugin/WeChatTweakPlugin.m | 80 ++++++++ Sources/WeChatTweak/Command.swift | 23 +++ Sources/WeChatTweak/DylibInjector.swift | 243 ++++++++++++++++++++++++ Sources/WeChatTweak/main.swift | 6 + 6 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 Plugin/WeChatTweakPlugin.m create mode 100644 Sources/WeChatTweak/DylibInjector.swift diff --git a/.gitignore b/.gitignore index d2e0555..58c1fce 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ xcuserdata/ ### Workspace ### /wechattweak +/WeChatTweakPlugin.dylib diff --git a/Makefile b/Makefile index 8f63ac1..2ea2eb3 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,24 @@ .PHONY: build clean -build:: - swift build -c release --arch arm64 --arch x86_64 - cp -f .build/apple/Products/Release/wechattweak ./wechattweak +DYLIB_SRC = Plugin/WeChatTweakPlugin.m +DYLIB_OUT = WeChatTweakPlugin.dylib + +build:: $(DYLIB_OUT) + swift build -c release + cp -f .build/release/wechattweak ./wechattweak + +$(DYLIB_OUT): $(DYLIB_SRC) + clang -dynamiclib \ + -arch arm64 \ + -mmacosx-version-min=12.0 \ + -framework Foundation \ + -fobjc-arc \ + -O2 \ + -o $(DYLIB_OUT) \ + $(DYLIB_SRC) + codesign --force --sign - $(DYLIB_OUT) clean:: rm -rf .build rm -f wechattweak + rm -f $(DYLIB_OUT) diff --git a/Plugin/WeChatTweakPlugin.m b/Plugin/WeChatTweakPlugin.m new file mode 100644 index 0000000..1b72d11 --- /dev/null +++ b/Plugin/WeChatTweakPlugin.m @@ -0,0 +1,80 @@ +// +// WeChatTweakPlugin.m +// WeChatTweak +// +// Runtime recall marking plugin. +// Hooks FFProcessReqsvrZZ's DelRevokedMsg:msgData: to prepend +// "[已撤回]" to recalled text messages. +// + +#import +#import +#import + +static IMP sOriginalDelRevokedMsg = NULL; + +static void hooked_DelRevokedMsg(id self, SEL _cmd, id session, id msgData) { + @try { + // Only modify text messages (messageType == 1) + BOOL shouldMark = NO; + if ([msgData respondsToSelector:NSSelectorFromString(@"messageType")]) { + NSInteger msgType = ((NSInteger (*)(id, SEL))objc_msgSend)( + msgData, NSSelectorFromString(@"messageType")); + shouldMark = (msgType == 1); + } + + if (shouldMark && [msgData respondsToSelector:NSSelectorFromString(@"msgContent")]) { + NSString *content = ((NSString *(*)(id, SEL))objc_msgSend)( + msgData, NSSelectorFromString(@"msgContent")); + if (content && ![content hasPrefix:@"[已撤回]"]) { + NSString *marked = [NSString stringWithFormat:@"[已撤回] %@", content]; + if ([msgData respondsToSelector:NSSelectorFromString(@"setMsgContent:")]) { + ((void (*)(id, SEL, id))objc_msgSend)( + msgData, NSSelectorFromString(@"setMsgContent:"), marked); + } + } + } + + // Persist the modification + SEL modifySel = NSSelectorFromString(@"ModifyMsgData:msgData:"); + if ([self respondsToSelector:modifySel]) { + NSMethodSignature *sig = [self methodSignatureForSelector:modifySel]; + if (sig) { + NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setTarget:self]; + [inv setSelector:modifySel]; + [inv setArgument:&session atIndex:2]; + [inv setArgument:&msgData atIndex:3]; + [inv invoke]; + } + } + } @catch (NSException *exception) { + NSLog(@"[WeChatTweak] Exception in hooked_DelRevokedMsg: %@", exception); + } + + // Call original (binary-patched to return 0, effectively a no-op) + if (sOriginalDelRevokedMsg) { + ((void (*)(id, SEL, id, id))sOriginalDelRevokedMsg)(self, _cmd, session, msgData); + } +} + +__attribute__((constructor)) +static void WeChatTweakPluginInit(void) { + NSLog(@"[WeChatTweak] Plugin loaded."); + + Class cls = NSClassFromString(@"FFProcessReqsvrZZ"); + if (!cls) { + NSLog(@"[WeChatTweak] FFProcessReqsvrZZ class not found. Plugin inactive."); + return; + } + + SEL sel = NSSelectorFromString(@"DelRevokedMsg:msgData:"); + Method method = class_getInstanceMethod(cls, sel); + if (!method) { + NSLog(@"[WeChatTweak] DelRevokedMsg:msgData: not found. Plugin inactive."); + return; + } + + sOriginalDelRevokedMsg = method_setImplementation(method, (IMP)hooked_DelRevokedMsg); + NSLog(@"[WeChatTweak] Successfully hooked DelRevokedMsg:msgData:"); +} diff --git a/Sources/WeChatTweak/Command.swift b/Sources/WeChatTweak/Command.swift index f0642b3..cc28ec9 100644 --- a/Sources/WeChatTweak/Command.swift +++ b/Sources/WeChatTweak/Command.swift @@ -27,6 +27,29 @@ struct Command { try Patcher.patch(binary: app.appendingPathComponent("Contents/MacOS/WeChat"), config: config) } + static func copyPlugin(app: URL) throws { + let executableDir = URL( + fileURLWithPath: ProcessInfo.processInfo.arguments[0] + ).deletingLastPathComponent() + let dylibSource = executableDir.appendingPathComponent("WeChatTweakPlugin.dylib") + let dylibDest = app.appendingPathComponent("Contents/MacOS/WeChatTweakPlugin.dylib") + + guard FileManager.default.fileExists(atPath: dylibSource.path) else { + print("WeChatTweakPlugin.dylib not found at \(dylibSource.path), skipping plugin.") + return + } + + if FileManager.default.fileExists(atPath: dylibDest.path) { + try FileManager.default.removeItem(at: dylibDest) + } + try FileManager.default.copyItem(at: dylibSource, to: dylibDest) + } + + static func injectDylib(app: URL) throws { + let binary = app.appendingPathComponent("Contents/MacOS/WeChat") + try DylibInjector.inject(binary: binary) + } + static func resign(app: URL) async throws { try await Command.execute(command: "codesign --remove-sign \(app.path)") try await Command.execute(command: "codesign --force --deep --sign - \(app.path)") diff --git a/Sources/WeChatTweak/DylibInjector.swift b/Sources/WeChatTweak/DylibInjector.swift new file mode 100644 index 0000000..377bf96 --- /dev/null +++ b/Sources/WeChatTweak/DylibInjector.swift @@ -0,0 +1,243 @@ +// +// DylibInjector.swift +// WeChatTweak +// +// Injects LC_LOAD_DYLIB into Mach-O binaries. +// + +import Darwin +import MachO +import Foundation + +struct DylibInjector { + enum Error: Swift.Error, LocalizedError { + case invalidFile + case not64BitMachO(magic: UInt32) + case dylibAlreadyInjected + case noSpaceForLoadCommand(available: Int, required: Int) + + var errorDescription: String? { + switch self { + case .invalidFile: + return "Invalid Mach-O file" + case .not64BitMachO(let magic): + return "Not a 64-bit Mach-O (magic: \(String(format: "0x%x", magic)))" + case .dylibAlreadyInjected: + return "Dylib already injected" + case .noSpaceForLoadCommand(let available, let required): + return "No space for load command (available: \(available), required: \(required))" + } + } + } + + static let dylibPath = "@executable_path/WeChatTweakPlugin.dylib" + + static func inject(binary: URL) throws { + guard FileManager.default.fileExists(atPath: binary.path) else { + throw Error.invalidFile + } + + let fh = try FileHandle(forUpdating: binary) + defer { try? fh.close() } + + guard let magicData = try fh.read(upToCount: 4), magicData.count == 4 else { + throw Error.invalidFile + } + let magicBE = magicData.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian } + let isSwappedFat = (magicBE == FAT_CIGAM) + + if magicBE == FAT_MAGIC || magicBE == FAT_CIGAM { + guard let nfatData = try fh.read(upToCount: 4), nfatData.count == 4 else { + throw Error.invalidFile + } + let rawNfat = nfatData.withUnsafeBytes { $0.load(as: UInt32.self) } + let nfat = isSwappedFat ? UInt32(littleEndian: rawNfat) : UInt32(bigEndian: rawNfat) + + var sliceOffsets: [UInt64] = [] + for _ in 0..= 0 && nameStart < rest.count { + let nameData = rest[nameStart...] + if let end = nameData.firstIndex(of: 0) { + let name = String(data: nameData[nameStart.. 0 { + minSectionFileOffset = min(minSectionFileOffset, UInt64(sectFileOff)) + } + } + } + + lcOffset += UInt64(cmdsize) + } + + // Calculate available space + let endOfLC = UInt64(32 + sizeofcmds) // relative to slice start + let available: Int + if minSectionFileOffset != UInt64.max { + available = Int(minSectionFileOffset) - Int(endOfLC) + } else { + available = 0 + } + + // Build dylib_command + let dylibCmd = buildDylibCommand(path: dylibPath) + + guard available >= dylibCmd.count else { + throw Error.noSpaceForLoadCommand(available: available, required: dylibCmd.count) + } + + // Write dylib_command at end of existing load commands + try fh.seek(toOffset: sliceOffset + endOfLC) + try fh.write(contentsOf: dylibCmd) + + // Update header: ncmds and sizeofcmds + let newNcmds = ncmds + 1 + let newSizeofcmds = sizeofcmds + UInt32(dylibCmd.count) + + var ncmdsBytes = newNcmds.littleEndian + var sizeBytes = newSizeofcmds.littleEndian + + try fh.seek(toOffset: sliceOffset + 16) + try fh.write(contentsOf: Data(bytes: &ncmdsBytes, count: 4)) + try fh.write(contentsOf: Data(bytes: &sizeBytes, count: 4)) + + print("LC_LOAD_DYLIB injected at slice offset \(String(format: "0x%llx", sliceOffset))") + } + + private static func buildDylibCommand(path: String) -> Data { + let pathBytes = Array(path.utf8) + [0] // null-terminated + let fixedSize = 24 // cmd(4) + cmdsize(4) + name_offset(4) + timestamp(4) + cur_ver(4) + compat_ver(4) + let rawSize = fixedSize + pathBytes.count + let cmdsize = (rawSize + 7) & ~7 // align to 8 + + var data = Data(capacity: cmdsize) + + // cmd: LC_LOAD_DYLIB = 0x0C + var cmd: UInt32 = UInt32(LC_LOAD_DYLIB).littleEndian + data.append(Data(bytes: &cmd, count: 4)) + + // cmdsize + var size: UInt32 = UInt32(cmdsize).littleEndian + data.append(Data(bytes: &size, count: 4)) + + // name offset (from start of command) + var nameOffset: UInt32 = UInt32(24).littleEndian + data.append(Data(bytes: &nameOffset, count: 4)) + + // timestamp + var timestamp: UInt32 = 0 + data.append(Data(bytes: ×tamp, count: 4)) + + // current_version + var currentVersion: UInt32 = 0 + data.append(Data(bytes: ¤tVersion, count: 4)) + + // compatibility_version + var compatVersion: UInt32 = 0 + data.append(Data(bytes: &compatVersion, count: 4)) + + // path string + data.append(contentsOf: pathBytes) + + // padding + let padding = cmdsize - rawSize + if padding > 0 { + data.append(Data(repeating: 0, count: padding)) + } + + return data + } +} diff --git a/Sources/WeChatTweak/main.swift b/Sources/WeChatTweak/main.swift index e853721..04f1e2d 100644 --- a/Sources/WeChatTweak/main.swift +++ b/Sources/WeChatTweak/main.swift @@ -52,6 +52,12 @@ extension Tweak { ) print("Done!") + print("------ Plugin ------") + try Command.copyPlugin(app: options.app) + print("Plugin copied!") + try Command.injectDylib(app: options.app) + print("Plugin injected!") + print("------ Resign ------") try await Command.resign( app: options.app