refactor: support WeChat 4.x

This commit is contained in:
Sunny Young
2025-12-05 15:52:37 +08:00
parent 1eee214417
commit c51ccc8ac9
50 changed files with 1219 additions and 2529 deletions
+60
View File
@@ -0,0 +1,60 @@
//
// Command.swift
//
// Created by Sunny Young.
//
import Foundation
import PromiseKit
import ArgumentParser
struct Command {
enum Error: @unchecked Sendable, LocalizedError {
case executing(command: String, error: NSDictionary)
var errorDescription: String? {
switch self {
case let .executing(command, error):
return "Execute command: \(command) failed: \(error)"
}
}
}
static func patch(app: URL, config: Config) -> Promise<Void> {
print("------ Path ------")
return Promise { seal in
do {
seal.fulfill(try Patcher.patch(binary: app.appendingPathComponent("Contents/MacOS/WeChat"), config: config))
} catch {
seal.reject(error)
}
}
}
static func resign(app: URL) -> Promise<Void> {
print("------ Resign ------")
return firstly {
Command.execute(command: "codesign --remove-sign \(app.path)")
}.then {
Command.execute(command: "codesign --force --deep --sign - \(app.path)")
}.then {
Command.execute(command: "xattr -cr \(app.path)")
}
}
private static func execute(command: String) -> Promise<Void> {
return Promise { seal in
print("Execute command: \(command)")
var error: NSDictionary?
guard let script = NSAppleScript(source: "do shell script \"\(command)\"") else {
return seal.reject(Error.executing(command: command, error: ["error": "Create script failed."]))
}
script.executeAndReturnError(&error)
if let error = error {
seal.reject(Error.executing(command: command, error: error))
} else {
seal.fulfill(())
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
//
// Config.swift
// WeChatTweak
//
// Created by Sunny Young on 2025/12/5.
//
import Foundation
import MachO
struct Config: Decodable {
enum Arch: String, Decodable {
case arm64
case x86_64
var cpu: UInt32 {
switch self {
case .arm64:
return UInt32(CPU_TYPE_ARM64)
case .x86_64:
return UInt32(CPU_TYPE_X86_64)
}
}
}
struct Entry: Decodable {
let arch: Arch
let addr: UInt64
let asm: Data
private enum CodingKeys: CodingKey {
case arch
case addr
case asm
}
init(from decoder: any Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
self.arch = try container.decode(Arch.self, forKey: .arch)
self.addr = try {
let hex = try container.decode(String.self, forKey: .addr)
guard let value = UInt64(hex, radix: 16) else {
throw DecodingError.dataCorruptedError(
forKey: CodingKeys.addr,
in: container,
debugDescription: "Invalid Entry.addr"
)
}
return value
}()
self.asm = try {
let hex = try container.decode(String.self, forKey: .asm)
guard let value = Data(hex: hex) else {
throw DecodingError.dataCorruptedError(
forKey: CodingKeys.asm,
in: container,
debugDescription: "Invalid Entry.asm"
)
}
return value
}()
}
}
struct Target: Decodable {
let identifier: String
let entries: [Entry]
private enum CodingKeys: CodingKey {
case identifier
case entries
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.identifier = try container.decode(String.self, forKey: .identifier)
self.entries = try container.decode([Entry].self, forKey: .entries)
}
}
let version: String
let targets: [Target]
}
private extension Data {
init?(hex: String) {
let chars = Array(hex.utf8)
guard chars.count % 2 == 0 else { return nil }
self.init()
self.reserveCapacity(chars.count / 2)
func nibble(_ c: UInt8) -> UInt8? {
switch c {
case 48...57: return c - 48 // '0'...'9'
case 65...70: return c - 55 // 'A'...'F'
case 97...102: return c - 87 // 'a'...'f'
default: return nil
}
}
var i = 0
while i < chars.count {
guard let hi = nibble(chars[i]),
let lo = nibble(chars[i + 1]) else { return nil }
append(hi << 4 | lo)
i += 2
}
}
}
+161
View File
@@ -0,0 +1,161 @@
//
// Patcher.swift
// WeChatTweak
//
// Created by Sunny Young on 2025/12/4.
//
import Darwin
import MachO
import Foundation
struct Patcher {
enum Error: Swift.Error {
case invalidFile
case not64BitMachO(magic: UInt32)
case vaNotFound(arch: String, va: UInt64)
case noArchMatched
}
static func patch(binary: URL, config: Config) throws {
guard FileManager.default.fileExists(atPath: binary.path) else {
throw Error.invalidFile
}
let entries = config.targets.flatMap { $0.entries }
guard !entries.isEmpty else { throw Error.noArchMatched }
let fh = try FileHandle(forUpdating: binary)
defer { try? fh.close() }
// magic fat / thin
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)
var patchedCount = 0
if magicBE == FAT_MAGIC || magicBE == FAT_CIGAM {
// FAT header: magic(4) + nfat_arch(4)
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)
// fat_arch patch
var archEntries: [(cputype: UInt32, offset: UInt32)] = []
for _ in 0..<nfat {
// fat_arch: cputype(4) cpusub(4) offset(4) size(4) align(4) big-endian
guard let archData = try fh.read(upToCount: 20), archData.count == 20 else {
throw Error.invalidFile
}
let rawCpu = archData.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self) }
let rawOff = archData.withUnsafeBytes { $0.load(fromByteOffset: 8, as: UInt32.self) }
let cputype = isSwappedFat ? UInt32(littleEndian: rawCpu) : UInt32(bigEndian: rawCpu)
let offset = isSwappedFat ? UInt32(littleEndian: rawOff) : UInt32(bigEndian: rawOff)
archEntries.append((cputype, offset))
}
for entry in archEntries {
let matching = entries.filter { $0.arch.cpu == entry.cputype }
for target in matching {
try patchOneSlice(file: fh,
sliceOffset: UInt64(entry.offset),
targetVA: target.addr,
patch: target.asm,
archName: target.arch.rawValue)
patchedCount += 1
}
}
} else {
// thin mach-o mach_header_64
try fh.seek(toOffset: 0)
guard let hdr = try fh.read(upToCount: 32), hdr.count == 32 else {
throw Error.invalidFile
}
let magic = hdr.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian }
let cputype = hdr.withUnsafeBytes { $0.load(fromByteOffset: 4, as: Int32.self).littleEndian }
guard magic == MH_MAGIC_64 else {
throw Error.not64BitMachO(magic: magic)
}
let matching = entries.filter { Int32(bitPattern: $0.arch.cpu) == cputype }
if matching.isEmpty {
throw Error.noArchMatched
}
for target in matching {
try patchOneSlice(file: fh,
sliceOffset: 0,
targetVA: target.addr,
patch: target.asm,
archName: target.arch.rawValue)
patchedCount += 1
}
}
if patchedCount <= 0 {
throw Error.noArchMatched
}
}
private static func patchOneSlice(file fh: FileHandle,
sliceOffset: UInt64,
targetVA: UInt64,
patch: Data,
archName: String) throws {
// slice mach_header_64
try fh.seek(toOffset: sliceOffset)
guard let hdr = try fh.read(upToCount: 32), hdr.count == 32 else {
throw Error.invalidFile
}
let magic = hdr.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian }
let ncmds = hdr.withUnsafeBytes { $0.load(fromByteOffset: 16, as: UInt32.self).littleEndian }
guard magic == MH_MAGIC_64 else {
throw Error.not64BitMachO(magic: magic)
}
var lcOffset = sliceOffset + 32
for _ in 0..<ncmds {
try fh.seek(toOffset: lcOffset)
guard let lcHead = try fh.read(upToCount: 8), lcHead.count == 8 else {
throw Error.invalidFile
}
let cmd = lcHead.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian }
let cmdsize = lcHead.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self).littleEndian }
if cmd == LC_SEGMENT_64 {
guard let segData = try fh.read(upToCount: 64), segData.count == 64 else {
throw Error.invalidFile
}
let vmaddr = segData.withUnsafeBytes { $0.load(fromByteOffset: 16, as: UInt64.self).littleEndian }
let vmsize = segData.withUnsafeBytes { $0.load(fromByteOffset: 24, as: UInt64.self).littleEndian }
let fileoff = segData.withUnsafeBytes { $0.load(fromByteOffset: 32, as: UInt64.self).littleEndian }
if vmaddr <= targetVA && targetVA < vmaddr + vmsize {
let fileOffset = sliceOffset + fileoff + (targetVA - vmaddr)
print("[\(archName)] vmaddr=\(String(format: "0x%llx", vmaddr)), fileoff=\(String(format: "0x%llx", fileoff)), sliceoff=\(String(format: "0x%llx", sliceOffset))")
print("[\(archName)] patch VA=\(String(format: "0x%llx", targetVA)), fileoff=\(String(format: "0x%llx", fileOffset))")
try fh.seek(toOffset: fileOffset)
try fh.write(contentsOf: patch)
return
}
}
lcOffset += UInt64(cmdsize)
}
throw Error.vaNotFound(arch: archName, va: targetVA)
}
}
+97
View File
@@ -0,0 +1,97 @@
//
// main.swift
//
// Created by Sunny Young.
//
import Foundation
import PromiseKit
import ArgumentParser
struct Patch: ParsableCommand {
enum Error: LocalizedError {
case invalidApp
case invalidConfig
case unsupportedVersion
var errorDescription: String? {
switch self {
case .invalidApp:
return "Invalid app path"
case .invalidConfig:
return "Invalid patch config"
case .unsupportedVersion:
return "Unsupported WeChat version"
}
}
}
static let configuration = CommandConfiguration(abstract: "Patch WeChat.app")
@Option(
name: .shortAndLong,
help: "Default: /Applications/WeChat.app",
transform: {
guard FileManager.default.fileExists(atPath: $0) else {
throw Error.invalidApp
}
return URL(fileURLWithPath: $0)
}
)
var app: URL = URL(fileURLWithPath: "/Applications/WeChat.app", isDirectory: true)
@Option(
name: .shortAndLong,
help: "Default: ./config.json",
transform: {
guard FileManager.default.fileExists(atPath: $0) else {
throw Error.invalidConfig
}
return URL(fileURLWithPath: $0)
}
)
var config: URL = URL(fileURLWithPath: "config.json")
func run() throws {
let configs = try JSONDecoder().decode([Config].self, from: Data(contentsOf: self.config))
guard
let info = NSDictionary(contentsOf: self.app.appendingPathComponent("Contents/Info.plist")),
let version = info["CFBundleVersion"] as? String,
let config = configs.first(where: { $0.version == version })
else {
throw Error.unsupportedVersion
}
firstly {
Command.patch(
app: self.app,
config: config
)
}.then {
Command.resign(app: self.app)
}.ensure {
print("")
}.done {
print("🎉 Done!")
Darwin.exit(EXIT_SUCCESS)
}.catch { error in
print("🚨 \(error.localizedDescription)", stderr)
Darwin.exit(EXIT_FAILURE)
}
}
}
struct Tweak: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "wechattweak",
abstract: "A command-line tool for tweaking WeChat.",
subcommands: [
Patch.self
],
defaultSubcommand: Self.self
)
}
Tweak.main()
CFRunLoopRun()