mirror of
https://github.com/Sunnyyoung/WeChatTweak-macOS.git
synced 2026-07-29 06:24:31 +08:00
refactor: support WeChat 4.x
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user