wechat-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wechat_cli/__init__.py +3 -0
- wechat_cli/__main__.py +3 -0
- wechat_cli/assets/app.js +79 -0
- wechat_cli/assets/bridge.cpp +643 -0
- wechat_cli/assets/controls.swift +218 -0
- wechat_cli/assets/entitlements.plist +38 -0
- wechat_cli/assets/favicon.svg +1 -0
- wechat_cli/assets/index.html +10 -0
- wechat_cli/assets/native-agent.js +334 -0
- wechat_cli/assets/pdf-thumbnail.swift +13 -0
- wechat_cli/assets/profile.json +232 -0
- wechat_cli/assets/profiles/4.1.13-269631.json +182 -0
- wechat_cli/assets/profiles/4.1.13.34-269602.json +232 -0
- wechat_cli/assets/profiles/4.1.15-270135.json +194 -0
- wechat_cli/assets/startup-notice.js +78 -0
- wechat_cli/assets/style.css +1 -0
- wechat_cli/cli.py +238 -0
- wechat_cli/client.py +59 -0
- wechat_cli/codec.py +14 -0
- wechat_cli/config.py +84 -0
- wechat_cli/controls.py +223 -0
- wechat_cli/errors.py +7 -0
- wechat_cli/idempotency.py +82 -0
- wechat_cli/lifecycle.py +141 -0
- wechat_cli/media.py +341 -0
- wechat_cli/models.py +260 -0
- wechat_cli/native.py +229 -0
- wechat_cli/network.py +9 -0
- wechat_cli/server.py +518 -0
- wechat_cli/setup.py +342 -0
- wechat_cli/worker.py +227 -0
- wechat_cli-0.1.0.dist-info/METADATA +178 -0
- wechat_cli-0.1.0.dist-info/RECORD +36 -0
- wechat_cli-0.1.0.dist-info/WHEEL +4 -0
- wechat_cli-0.1.0.dist-info/entry_points.txt +2 -0
- wechat_cli-0.1.0.dist-info/licenses/LICENSE +7 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Darwin
|
|
3
|
+
import ApplicationServices
|
|
4
|
+
import Foundation
|
|
5
|
+
|
|
6
|
+
// Operates only the configured WeChat process, through its own native controls.
|
|
7
|
+
// Never grants permissions. Send confirmation is scoped to the selected file dialog.
|
|
8
|
+
struct ControlError: Error { let code: String }
|
|
9
|
+
var fileSendStarted = false
|
|
10
|
+
let request = try JSONSerialization.jsonObject(with: FileHandle.standardInput.readDataToEndOfFile()) as! [String:Any]
|
|
11
|
+
func emit(_ value: [String:Any]) { let data = try! JSONSerialization.data(withJSONObject:value, options:[.sortedKeys]); FileHandle.standardOutput.write(data) }
|
|
12
|
+
func attribute(_ element: AXUIElement, _ name: String) -> AnyObject? {
|
|
13
|
+
var value: CFTypeRef?; guard AXUIElementCopyAttributeValue(element,name as CFString,&value) == .success else { return nil }; return value
|
|
14
|
+
}
|
|
15
|
+
func string(_ element: AXUIElement, _ name: String) -> String { attribute(element,name) as? String ?? "" }
|
|
16
|
+
func elements(_ root: AXUIElement) -> [AXUIElement] {
|
|
17
|
+
var output: [AXUIElement] = [], pending = [root], visited = 0
|
|
18
|
+
while let element = pending.popLast(), visited < 6000 {
|
|
19
|
+
visited += 1; output.append(element)
|
|
20
|
+
if let children = attribute(element,kAXChildrenAttribute) as? [AXUIElement] { pending.append(contentsOf:children.reversed()) }
|
|
21
|
+
}
|
|
22
|
+
return output
|
|
23
|
+
}
|
|
24
|
+
func label(_ e: AXUIElement) -> String { [kAXTitleAttribute,kAXDescriptionAttribute,kAXHelpAttribute].map{string(e,$0)}.filter{!$0.isEmpty}.joined(separator:" ") }
|
|
25
|
+
func press(_ e: AXUIElement) throws {
|
|
26
|
+
if let enabled = attribute(e,kAXEnabledAttribute) as? Bool, !enabled { throw ControlError(code:"native_control_disabled") }
|
|
27
|
+
var pid:pid_t=0;guard AXUIElementGetPid(e,&pid) == .success else { throw ControlError(code:"native_control_action_failed") }
|
|
28
|
+
guard let position=attribute(e,kAXPositionAttribute),let size=attribute(e,kAXSizeAttribute),CFGetTypeID(position)==AXValueGetTypeID(),CFGetTypeID(size)==AXValueGetTypeID() else { throw ControlError(code:"native_control_action_failed") }
|
|
29
|
+
var origin=CGPoint.zero,extent=CGSize.zero
|
|
30
|
+
guard AXValueGetValue(position as! AXValue,.cgPoint,&origin),AXValueGetValue(size as! AXValue,.cgSize,&extent),extent.width>0,extent.height>0 else { throw ControlError(code:"native_control_action_failed") }
|
|
31
|
+
let point=CGPoint(x:origin.x+extent.width/2,y:origin.y+extent.height/2)
|
|
32
|
+
guard let down=CGEvent(mouseEventSource:nil,mouseType:.leftMouseDown,mouseCursorPosition:point,mouseButton:.left),let up=CGEvent(mouseEventSource:nil,mouseType:.leftMouseUp,mouseCursorPosition:point,mouseButton:.left) else { throw ControlError(code:"native_control_action_failed") }
|
|
33
|
+
guard NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { throw ControlError(code:"native_app_not_frontmost") }
|
|
34
|
+
CGEvent(mouseEventSource:nil,mouseType:.mouseMoved,mouseCursorPosition:point,mouseButton:.left)?.post(tap:.cghidEventTap)
|
|
35
|
+
Thread.sleep(forTimeInterval:0.08)
|
|
36
|
+
down.post(tap:.cghidEventTap);Thread.sleep(forTimeInterval:0.05);up.post(tap:.cghidEventTap)
|
|
37
|
+
}
|
|
38
|
+
func scrollHistoryUp(_ root:AXUIElement,_ pid:Int32) throws {
|
|
39
|
+
guard NSWorkspace.shared.frontmostApplication?.processIdentifier == pid,
|
|
40
|
+
let list=elements(root).first(where:{string($0,kAXIdentifierAttribute)=="chat_message_list"}),
|
|
41
|
+
let position=attribute(list,kAXPositionAttribute),let size=attribute(list,kAXSizeAttribute) else { throw ControlError(code:"native_attachment_not_visible") }
|
|
42
|
+
var origin=CGPoint.zero,extent=CGSize.zero
|
|
43
|
+
guard CFGetTypeID(position)==AXValueGetTypeID(),CFGetTypeID(size)==AXValueGetTypeID(),
|
|
44
|
+
AXValueGetValue(position as! AXValue,.cgPoint,&origin),AXValueGetValue(size as! AXValue,.cgSize,&extent),extent.width>0,extent.height>0 else { throw ControlError(code:"native_attachment_not_visible") }
|
|
45
|
+
let point=CGPoint(x:origin.x+extent.width/2,y:origin.y+extent.height/2)
|
|
46
|
+
CGEvent(mouseEventSource:nil,mouseType:.mouseMoved,mouseCursorPosition:point,mouseButton:.left)?.post(tap:.cghidEventTap)
|
|
47
|
+
Thread.sleep(forTimeInterval:0.05)
|
|
48
|
+
CGEvent(scrollWheelEvent2Source:nil,units:.pixel,wheelCount:1,wheel1:600,wheel2:0,wheel3:0)?.post(tap:.cghidEventTap)
|
|
49
|
+
Thread.sleep(forTimeInterval:0.15)
|
|
50
|
+
}
|
|
51
|
+
func find(_ root: AXUIElement, _ names: [String]) throws -> AXUIElement {
|
|
52
|
+
let matches = elements(root).filter { e in
|
|
53
|
+
string(e,kAXRoleAttribute) == kAXButtonRole && names.contains { label(e) == $0 || label(e).hasPrefix($0+"(") || label(e).hasPrefix($0+" (") }
|
|
54
|
+
}
|
|
55
|
+
guard let first = matches.last else { throw ControlError(code:"native_control_not_found") }; return first
|
|
56
|
+
}
|
|
57
|
+
func state(_ root: AXUIElement) -> [String:Any] {
|
|
58
|
+
let list = elements(root)
|
|
59
|
+
let draft = list.first { string($0,kAXIdentifierAttribute) == "chat_input_field" }
|
|
60
|
+
let focusedWindow=attribute(root,kAXFocusedWindowAttribute)
|
|
61
|
+
let fileUiReady=focusedWindow.map{elements($0 as! AXUIElement).contains{string($0,kAXIdentifierAttribute)=="chat_input_field"}} ?? false
|
|
62
|
+
let title = list.first { string($0,kAXIdentifierAttribute) == "current_chat_name_label" }
|
|
63
|
+
// Return only active feature controls, not conversation/message contents.
|
|
64
|
+
let names = list.filter { string($0,kAXRoleAttribute) == kAXButtonRole }.map{label($0)}
|
|
65
|
+
let featureNames = names.filter { value in ["Voice Input","Send Voice","Cancel","Done","Finish","Stop","Start","Import","语音","取消","完成","停止","开始","导入"].contains{value.hasPrefix($0)} }
|
|
66
|
+
return ["accessibility":true,"fileUiReady":fileUiReady,"chatName":title.map{string($0,kAXValueAttribute)} ?? "","draft":draft.map{string($0,kAXValueAttribute)} ?? "","controls":Array(Set(featureNames)).sorted()]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func key(_ pid: Int32, _ code: CGKeyCode, _ flags: CGEventFlags = []) throws {
|
|
70
|
+
guard let down = CGEvent(keyboardEventSource:nil,virtualKey:code,keyDown:true), let up = CGEvent(keyboardEventSource:nil,virtualKey:code,keyDown:false) else { throw ControlError(code:"native_control_failed") }
|
|
71
|
+
down.flags=flags;up.flags=flags;guard NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { throw ControlError(code:"native_app_not_frontmost") };down.post(tap:.cghidEventTap);up.post(tap:.cghidEventTap)
|
|
72
|
+
}
|
|
73
|
+
func type(_ pid:Int32, _ value:String) throws {
|
|
74
|
+
let chars=Array(value.utf16)
|
|
75
|
+
guard let event=CGEvent(keyboardEventSource:nil,virtualKey:0,keyDown:true) else { throw ControlError(code:"native_control_failed") }
|
|
76
|
+
chars.withUnsafeBufferPointer { event.keyboardSetUnicodeString(stringLength:chars.count,unicodeString:$0.baseAddress!) }
|
|
77
|
+
event.postToPid(pid);event.type = .keyUp;event.postToPid(pid)
|
|
78
|
+
}
|
|
79
|
+
func waitFor(_ root:AXUIElement,_ names:[String],_ seconds:Double=3) throws -> AXUIElement {
|
|
80
|
+
let end=Date().addingTimeInterval(seconds)
|
|
81
|
+
repeat { if let button=try? find(root,names) { return button };Thread.sleep(forTimeInterval:0.1) } while Date()<end
|
|
82
|
+
throw ControlError(code:"native_control_not_found")
|
|
83
|
+
}
|
|
84
|
+
func sendFile(_ root:AXUIElement,_ pid:Int32,_ path:String,_ name:String) throws {
|
|
85
|
+
let expectedChat=state(root)["chatName"] as? String ?? ""
|
|
86
|
+
// A blank draft and exact chat match are checked before entering the picker.
|
|
87
|
+
guard (state(root)["draft"] as? String ?? "").isEmpty else { throw ControlError(code:"native_draft_not_empty") }
|
|
88
|
+
try press(find(root,["Send File","发送文件"]))
|
|
89
|
+
_ = try waitFor(root,["Open","打开","Choose","选择"])
|
|
90
|
+
try key(pid,5,[.maskCommand,.maskShift]) // Native NSOpenPanel Go to Folder.
|
|
91
|
+
Thread.sleep(forTimeInterval:0.15)
|
|
92
|
+
let fields=elements(root).filter { string($0,kAXRoleAttribute)==kAXTextFieldRole || string($0,kAXRoleAttribute)==kAXComboBoxRole }
|
|
93
|
+
guard let field=fields.last else { throw ControlError(code:"native_file_picker_not_found") }
|
|
94
|
+
guard AXUIElementSetAttributeValue(field,kAXValueAttribute as CFString,path as CFString) == .success else { throw ControlError(code:"native_file_picker_not_found") }
|
|
95
|
+
try key(pid,36);Thread.sleep(forTimeInterval:0.15)
|
|
96
|
+
// Selecting a file can auto-send in some builds. An error after this action
|
|
97
|
+
// is an unknown send outcome, never a promise that nothing was sent.
|
|
98
|
+
do {
|
|
99
|
+
guard state(root)["chatName"] as? String == expectedChat else { throw ControlError(code:"native_active_chat_mismatch") }
|
|
100
|
+
guard (state(root)["draft"] as? String ?? "").isEmpty else { throw ControlError(code:"native_draft_not_empty") }
|
|
101
|
+
fileSendStarted=true
|
|
102
|
+
try press(waitFor(root,["Open","打开","Choose","选择"]))
|
|
103
|
+
Thread.sleep(forTimeInterval:0.3)
|
|
104
|
+
let windows=attribute(root,kAXWindowsAttribute) as? [AXUIElement] ?? []
|
|
105
|
+
let sheets=windows.flatMap{elements($0).filter{string($0,kAXRoleAttribute)==kAXSheetRole}}
|
|
106
|
+
for window in windows+sheets {
|
|
107
|
+
let list=elements(window)
|
|
108
|
+
let containsName=list.contains { label($0).contains(name) || string($0,kAXValueAttribute).contains(name) }
|
|
109
|
+
let title=string(window,kAXTitleAttribute)
|
|
110
|
+
if containsName && (title.contains("Send") || title.contains("发送") || string(window,kAXRoleAttribute)==kAXSheetRole || string(window,kAXSubroleAttribute)=="AXDialog") {
|
|
111
|
+
if let button=try? find(window,["Send","发送"]) { try press(button);return }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// This build inserts the selected file into the composer. Send only
|
|
115
|
+
// that attachment-only draft in the same chat; never send user text.
|
|
116
|
+
let after=state(root)
|
|
117
|
+
if after["chatName"] as? String == expectedChat && after["draft"] as? String == "\u{fffc}" {
|
|
118
|
+
try press(find(root,["Send","发送"]))
|
|
119
|
+
}
|
|
120
|
+
} catch { if fileSendStarted { throw ControlError(code:"native_send_outcome_unknown") };throw error }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
do {
|
|
124
|
+
guard AXIsProcessTrusted() else { throw ControlError(code:"native_accessibility_required") }
|
|
125
|
+
guard let pid = request["pid"] as? Int32, let expected = request["app"] as? String,
|
|
126
|
+
let app = NSRunningApplication(processIdentifier:pid) else { throw ControlError(code:"native_process_identity_mismatch") }
|
|
127
|
+
// LaunchServices can report another copy's bundle URL when both share WeChat's bundle ID.
|
|
128
|
+
// Verify the actual process executable instead, resolving /tmp aliases on both sides.
|
|
129
|
+
var pathBuffer=[CChar](repeating:0,count:4096)
|
|
130
|
+
let pathLength=proc_pidpath(pid,&pathBuffer,UInt32(pathBuffer.count))
|
|
131
|
+
let actualPath=String(cString:pathBuffer)
|
|
132
|
+
let expectedPath=URL(fileURLWithPath:expected).appendingPathComponent("Contents/MacOS/WeChat").resolvingSymlinksInPath().path
|
|
133
|
+
guard pathLength>0, URL(fileURLWithPath:actualPath).resolvingSymlinksInPath().path == expectedPath else { throw ControlError(code:"native_process_identity_mismatch") }
|
|
134
|
+
let root = AXUIElementCreateApplication(pid)
|
|
135
|
+
let action = request["action"] as? String ?? "status"
|
|
136
|
+
if action != "status" && action != "attachment.status" {
|
|
137
|
+
_ = NSApplication.shared
|
|
138
|
+
if let window=attribute(root,kAXMainWindowAttribute) { AXUIElementPerformAction(window as! AXUIElement,kAXRaiseAction as CFString) }
|
|
139
|
+
app.activate(options:[.activateIgnoringOtherApps])
|
|
140
|
+
let deadline=Date().addingTimeInterval(3)
|
|
141
|
+
while NSWorkspace.shared.frontmostApplication?.processIdentifier != pid && Date()<deadline { RunLoop.current.run(until:Date().addingTimeInterval(0.1)) }
|
|
142
|
+
guard NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { throw ControlError(code:"native_app_not_frontmost") }
|
|
143
|
+
}
|
|
144
|
+
if let chatName = request["chatName"] as? String, !chatName.isEmpty {
|
|
145
|
+
guard state(root)["chatName"] as? String == chatName else { throw ControlError(code:"native_active_chat_mismatch") }
|
|
146
|
+
}
|
|
147
|
+
if action == "dictation.start" || action == "recording.start" {
|
|
148
|
+
guard let composer=elements(root).first(where:{string($0,kAXIdentifierAttribute)=="chat_input_field"}) else { throw ControlError(code:"native_control_not_found") }
|
|
149
|
+
try press(composer)
|
|
150
|
+
AXUIElementSetAttributeValue(composer,kAXFocusedAttribute as CFString,kCFBooleanTrue)
|
|
151
|
+
}
|
|
152
|
+
switch action {
|
|
153
|
+
case "status": break
|
|
154
|
+
case "file.send":
|
|
155
|
+
guard let path=request["filePath"] as? String,let name=request["fileName"] as? String,path.hasPrefix("/"),!name.isEmpty else { throw ControlError(code:"invalid_input") }
|
|
156
|
+
try sendFile(root,pid,path,name)
|
|
157
|
+
case "attachment.status":
|
|
158
|
+
guard let name=request["fileName"] as? String,!name.isEmpty else { throw ControlError(code:"invalid_input") }
|
|
159
|
+
for window in attribute(root,kAXWindowsAttribute) as? [AXUIElement] ?? [] {
|
|
160
|
+
let list=elements(window)
|
|
161
|
+
if list.contains(where:{label($0).contains(name)||string($0,kAXValueAttribute).contains(name)}) &&
|
|
162
|
+
list.contains(where:{ e in ["File cleared","File expired","文件已清理","文件已过期"].contains { label(e).contains($0)||string(e,kAXValueAttribute).contains($0) } }) {
|
|
163
|
+
throw ControlError(code:"native_attachment_unavailable")
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
case "attachment.download":
|
|
167
|
+
guard let name=request["fileName"] as? String,!name.isEmpty else { throw ControlError(code:"invalid_input") }
|
|
168
|
+
var target:AXUIElement?
|
|
169
|
+
for _ in 0..<20 {
|
|
170
|
+
guard state(root)["chatName"] as? String == request["chatName"] as? String else { throw ControlError(code:"native_active_chat_mismatch") }
|
|
171
|
+
let matches=elements(root).filter { e in
|
|
172
|
+
string(e,kAXIdentifierAttribute)=="chat_bubble_item_view" && elements(e).contains { child in
|
|
173
|
+
[label(child),string(child,kAXValueAttribute)].contains { $0.components(separatedBy:"\n").contains { $0.trimmingCharacters(in:.whitespaces)==name } }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if matches.count>1 { throw ControlError(code:"native_attachment_ambiguous") }
|
|
177
|
+
if let match=matches.first {target=match;break}
|
|
178
|
+
try scrollHistoryUp(root,pid)
|
|
179
|
+
}
|
|
180
|
+
guard let target=target else { throw ControlError(code:"native_attachment_not_visible") }
|
|
181
|
+
try press(target)
|
|
182
|
+
// A file bubble can open WeChat's attachment window first. Activate
|
|
183
|
+
// only its Download control, scoped to the requested filename.
|
|
184
|
+
Thread.sleep(forTimeInterval:0.25)
|
|
185
|
+
for window in attribute(root,kAXWindowsAttribute) as? [AXUIElement] ?? [] {
|
|
186
|
+
let scoped=elements(window).contains { label($0).contains(name)||string($0,kAXValueAttribute).contains(name) }
|
|
187
|
+
let main=elements(window).contains { string($0,kAXIdentifierAttribute)=="chat_input_field" }
|
|
188
|
+
if scoped && !main,let download=try? find(window,["Download","下载"]) {try press(download);break}
|
|
189
|
+
}
|
|
190
|
+
case "recording.send":
|
|
191
|
+
// Only a voice recording window can provide this Send button.
|
|
192
|
+
let windows=attribute(root,kAXWindowsAttribute) as? [AXUIElement] ?? []
|
|
193
|
+
guard let window=windows.first(where:{string($0,kAXTitleAttribute).contains("Voice") || string($0,kAXTitleAttribute).contains("语音")}) else { throw ControlError(code:"native_control_not_found") }
|
|
194
|
+
try press(find(window,["Send","发送"]))
|
|
195
|
+
case "dictation.start":
|
|
196
|
+
let e=CGEvent(keyboardEventSource:nil,virtualKey:63,keyDown:true)!;e.type = .flagsChanged;e.flags = .maskSecondaryFn;e.post(tap:.cghidEventTap)
|
|
197
|
+
case "dictation.stop":
|
|
198
|
+
let e=CGEvent(keyboardEventSource:nil,virtualKey:63,keyDown:false)!;e.type = .flagsChanged;e.flags = [];e.post(tap:.cghidEventTap)
|
|
199
|
+
case "dictation.cancel":
|
|
200
|
+
try key(pid,53);let e=CGEvent(keyboardEventSource:nil,virtualKey:63,keyDown:false)!;e.type = .flagsChanged;e.flags = [];e.post(tap:.cghidEventTap)
|
|
201
|
+
case "recording.cancel":
|
|
202
|
+
try key(pid,53);let e=CGEvent(keyboardEventSource:nil,virtualKey:61,keyDown:false)!;e.type = .flagsChanged;e.flags = [];e.post(tap:.cghidEventTap)
|
|
203
|
+
case "recording.start":
|
|
204
|
+
let e=CGEvent(keyboardEventSource:nil,virtualKey:61,keyDown:true)!;e.type = .flagsChanged;e.flags = CGEventFlags(rawValue:CGEventFlags.maskAlternate.rawValue | 0x40);e.post(tap:.cghidEventTap)
|
|
205
|
+
case "recording.stop":
|
|
206
|
+
let e=CGEvent(keyboardEventSource:nil,virtualKey:61,keyDown:false)!;e.type = .flagsChanged;e.flags = [];e.post(tap:.cghidEventTap)
|
|
207
|
+
case "import.start": try press(find(root,["Import chat history from phone","从手机导入聊天记录"]))
|
|
208
|
+
case "import.cancel":
|
|
209
|
+
let windows=attribute(root,kAXWindowsAttribute) as? [AXUIElement] ?? []
|
|
210
|
+
guard let window=windows.first(where:{["Import Chat History","导入聊天记录"].contains(string($0,kAXTitleAttribute))}),let close=attribute(window,kAXCloseButtonAttribute) else { throw ControlError(code:"native_control_not_found") }
|
|
211
|
+
guard AXUIElementPerformAction(close as! AXUIElement,kAXPressAction as CFString) == .success else { throw ControlError(code:"native_control_action_failed") }
|
|
212
|
+
default: throw ControlError(code:"invalid_native_control")
|
|
213
|
+
}
|
|
214
|
+
// WeChat changes controls asynchronously; observe without synthesizing state.
|
|
215
|
+
if action != "status" && action != "attachment.status" { Thread.sleep(forTimeInterval:0.15) }
|
|
216
|
+
emit(state(root))
|
|
217
|
+
} catch let error as ControlError { emit(["error":error.code,"possibleSend":fileSendStarted]) }
|
|
218
|
+
catch { emit(["error":"native_control_failed"]) }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>com.apple.security.app-sandbox</key>
|
|
6
|
+
<true/>
|
|
7
|
+
<key>com.apple.security.application-groups</key>
|
|
8
|
+
<array>
|
|
9
|
+
<string>5A4RE8SF68.com.tencent.xinWeChat</string>
|
|
10
|
+
</array>
|
|
11
|
+
<key>com.apple.security.cs.allow-jit</key>
|
|
12
|
+
<true/>
|
|
13
|
+
<key>com.apple.security.cs.disable-library-validation</key>
|
|
14
|
+
<true/>
|
|
15
|
+
<key>com.apple.security.device.audio-input</key>
|
|
16
|
+
<true/>
|
|
17
|
+
<key>com.apple.security.device.camera</key>
|
|
18
|
+
<true/>
|
|
19
|
+
<key>com.apple.security.files.bookmarks.app-scope</key>
|
|
20
|
+
<true/>
|
|
21
|
+
<key>com.apple.security.files.downloads.read-write</key>
|
|
22
|
+
<true/>
|
|
23
|
+
<key>com.apple.security.files.user-selected.read-write</key>
|
|
24
|
+
<true/>
|
|
25
|
+
<key>com.apple.security.get-task-allow</key>
|
|
26
|
+
<true/>
|
|
27
|
+
<key>com.apple.security.network.client</key>
|
|
28
|
+
<true/>
|
|
29
|
+
<key>com.apple.security.network.server</key>
|
|
30
|
+
<true/>
|
|
31
|
+
<key>com.apple.security.personal-information.location</key>
|
|
32
|
+
<true/>
|
|
33
|
+
<key>com.apple.security.personal-information.photos-library</key>
|
|
34
|
+
<true/>
|
|
35
|
+
<key>com.apple.security.print</key>
|
|
36
|
+
<true/>
|
|
37
|
+
</dict>
|
|
38
|
+
</plist>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#163f34"/><path d="M14 16h36v27H30l-12 9v-9h-4z" fill="#fff"/><g fill="#163f34"><circle cx="23" cy="29" r="3"/><circle cx="32" cy="29" r="3"/><circle cx="41" cy="29" r="3"/></g></svg>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
3
|
+
<title>WeChat local</title><link rel="icon" href="/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
|
|
4
|
+
<body><header><div><strong>WeChat local</strong><span>Python gateway</span></div><button id="refresh">Refresh chats</button><button id="connection">Connection</button></header>
|
|
5
|
+
<p id="status" role="status" aria-live="polite">Connecting to your local session…</p>
|
|
6
|
+
<main><aside aria-label="Conversations"><label for="search">Conversations</label><input id="search" type="search" placeholder="Search by name or ID"><nav id="chats"></nav></aside>
|
|
7
|
+
<section id="conversation"><div class="conversation-title"><h1 id="title">Select a conversation</h1><label>History <select id="limit"><option>20</option><option selected>50</option><option>100</option></select></label><button id="history" disabled>Refresh history</button></div>
|
|
8
|
+
<div id="messages" aria-live="polite"></div><form id="compose"><label for="text">Message</label><textarea id="text" maxlength="4096" rows="3" disabled placeholder="Write a message…"></textarea><button id="send" disabled>Send text</button></form>
|
|
9
|
+
<form id="upload"><label for="file">Send file or voice</label><input type="file" id="file"><select id="file-kind"><option value="file">File</option><option value="wav">Voice (mono PCM WAV)</option><option value="silk">Voice (SILK)</option></select><button id="send-file" disabled>Send upload</button></form></section></main>
|
|
10
|
+
<footer>Sign into the configured WeChat app. File sending requires its target chat to be open and Accessibility permission.</footer></body></html>
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Frida runtime script, not a Node module. Loaded only by the native bridge.
|
|
2
|
+
// Read native results; message calls run inside a registered native task context.
|
|
3
|
+
const nativeProfile = globalThis.WECHAT_NATIVE_PROFILE;
|
|
4
|
+
const nativeState = { state: 'waiting_for_module', protocol: 'wechat-macos',
|
|
5
|
+
connected: false, installed: false, enumerationObserved: false, chats: [],
|
|
6
|
+
messageReadsObserved: 0, failure: null };
|
|
7
|
+
let liveNativeBase = null;
|
|
8
|
+
let liveEnumerationAddress = null;
|
|
9
|
+
let liveManagerSlot = null;
|
|
10
|
+
|
|
11
|
+
function managerSlotForEnumeration(enumeration) {
|
|
12
|
+
const callsite = enumeration.add(0x68);
|
|
13
|
+
if (!readable(callsite, 4)) return null;
|
|
14
|
+
const call = callsite.readU32();
|
|
15
|
+
if ((call & 0xfc000000) >>> 0 !== 0x94000000) return null;
|
|
16
|
+
let displacement = call & 0x3ffffff;
|
|
17
|
+
if (displacement & 0x2000000) displacement -= 0x4000000;
|
|
18
|
+
const helper = callsite.add(displacement * 4);
|
|
19
|
+
if (!readable(helper, 12)) return null;
|
|
20
|
+
const adrp = helper.readU32();
|
|
21
|
+
const ldr = helper.add(4).readU32();
|
|
22
|
+
if ((adrp & 0x9f00001f) >>> 0 !== 0x90000008 || (ldr & 0xffc003ff) >>> 0 !== 0xf9400100 || helper.add(8).readU32() !== 0xd65f03c0) return null;
|
|
23
|
+
let immediate = ((adrp >>> 5) & 0x7ffff) * 4 + ((adrp >>> 29) & 3);
|
|
24
|
+
if (immediate & 0x100000) immediate -= 0x200000;
|
|
25
|
+
const slot = helper.and(ptr('0xfffffffffffff000')).add(immediate * 4096).add(((ldr >>> 10) & 4095) * 8);
|
|
26
|
+
if (readable(slot, 8)) return slot;
|
|
27
|
+
// Mach region enumeration can omit specially mapped pages. Test only this
|
|
28
|
+
// metadata-derived pointer slot; NativePointer raises on unreadable memory.
|
|
29
|
+
try { slot.readPointer(); return slot; } catch (_) { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readable(address, bytes) {
|
|
33
|
+
if (address.isNull() || !Number.isSafeInteger(bytes) || bytes < 0 || bytes > 1048576) return false;
|
|
34
|
+
// Guarded reads validate only a recovered field's bounds. Walking every Mach
|
|
35
|
+
// region for each string is extremely slow in the protected WeChat image.
|
|
36
|
+
try { address.readByteArray(bytes); return true; } catch (_) { return false; }
|
|
37
|
+
}
|
|
38
|
+
function hexBytes(address, count) {
|
|
39
|
+
return Array.from(new Uint8Array(address.readByteArray(count)), byte => byte.toString(16).padStart(2, '0')).join('');
|
|
40
|
+
}
|
|
41
|
+
function install(module) {
|
|
42
|
+
if (nativeState.installed || module.name !== 'wechat.dylib' || !module.path.includes('/Contents/Resources/') || module.size < 100000000) return;
|
|
43
|
+
try {
|
|
44
|
+
if (Process.arch !== nativeProfile.arch) throw Error('unsupported_architecture');
|
|
45
|
+
const enumeration = module.base.add(nativeProfile.enumeration.offset);
|
|
46
|
+
const managerSlot = managerSlotForEnumeration(enumeration);
|
|
47
|
+
if (!managerSlot) {
|
|
48
|
+
nativeState.state = 'waiting_for_live_image';
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const name of ['enumeration', 'initialMessages', 'pagedMessages']) {
|
|
52
|
+
const entry = nativeProfile[name];
|
|
53
|
+
const address = module.base.add(entry.offset);
|
|
54
|
+
const executable = Process.findRangeByAddress(address);
|
|
55
|
+
if (!executable || !executable.protection.includes('x')) throw Error('native_module_not_executable');
|
|
56
|
+
if (!readable(address, entry.signature.length / 2) || hexBytes(address, entry.signature.length / 2) !== entry.signature) throw Error('native_signature_mismatch');
|
|
57
|
+
}
|
|
58
|
+
nativeState.installed = true;
|
|
59
|
+
liveNativeBase = module.base;
|
|
60
|
+
liveEnumerationAddress = enumeration;
|
|
61
|
+
liveManagerSlot = managerSlot;
|
|
62
|
+
nativeState.state = 'native_ready';
|
|
63
|
+
} catch (error) {
|
|
64
|
+
nativeState.failure = error.message;
|
|
65
|
+
nativeState.state = 'error';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let nativeSupport;
|
|
70
|
+
function supportFunctions() {
|
|
71
|
+
if(nativeSupport) return nativeSupport;
|
|
72
|
+
const module=Module.load(globalThis.WECHAT_NATIVE_SUPPORT);
|
|
73
|
+
const exports=new Map(module.enumerateExports().map(entry=>[entry.name,entry.address]));
|
|
74
|
+
const ownExport=name=>{const address=exports.get(name);if(!address)throw Error('native_support_export_missing');return address;};
|
|
75
|
+
nativeSupport={
|
|
76
|
+
selfId:new NativeFunction(ownExport('bridge_self_id'),'pointer',['pointer','pointer'],{exceptions:'steal'}),
|
|
77
|
+
importControl:new NativeFunction(ownExport('bridge_import_control'),'pointer',['pointer','pointer','pointer','pointer','uint'],{exceptions:'steal'}),
|
|
78
|
+
captureVoice:new NativeFunction(ownExport('bridge_capture_recorded_voice'),'pointer',['pointer','pointer'],{exceptions:'steal'}),
|
|
79
|
+
sendVoice:new NativeFunction(ownExport('bridge_send_voice'),'pointer',['pointer','pointer','pointer','pointer','pointer','pointer','pointer','uint','uint','bool'],{exceptions:'steal'}),
|
|
80
|
+
ocr:new NativeFunction(ownExport('bridge_extract_image_text'),'pointer',['pointer','pointer','pointer','pointer','pointer'],{exceptions:'steal'}),
|
|
81
|
+
transcribe:new NativeFunction(ownExport('bridge_start_voice_transcription'),'pointer',['pointer','pointer','pointer','pointer','pointer','pointer','pointer'],{exceptions:'steal'}),
|
|
82
|
+
transcript:new NativeFunction(ownExport('bridge_voice_transcription_result'),'pointer',['pointer','pointer','pointer','pointer'],{exceptions:'steal'}),
|
|
83
|
+
probeProcessor:new NativeFunction(ownExport('bridge_probe_text_processor_v2'),'pointer',['pointer','pointer','pointer','pointer','pointer'],{exceptions:'steal'}),
|
|
84
|
+
prepareText:new NativeFunction(ownExport('bridge_prepare_text_v2'),'pointer',['pointer','pointer','pointer','pointer','pointer','pointer','pointer'],{exceptions:'steal'}),
|
|
85
|
+
send:new NativeFunction(ownExport('bridge_send_text_v2'),'pointer',['pointer','pointer','pointer','pointer','pointer','pointer','pointer'],{exceptions:'steal'}),
|
|
86
|
+
stage:new NativeFunction(ownExport('bridge_last_stage'),'uint',[]),
|
|
87
|
+
hasTask:new NativeFunction(ownExport('bridge_has_task_context'),'bool',['pointer']),
|
|
88
|
+
chats:new NativeFunction(ownExport('bridge_read_chats_v2'),'pointer',['pointer','pointer','pointer','pointer','pointer'],{exceptions:'steal'}),
|
|
89
|
+
cachedAttachment:new NativeFunction(ownExport('bridge_cached_attachment'),'pointer',['pointer','uint64','pointer'],{exceptions:'steal'}),
|
|
90
|
+
cachedMedia:new NativeFunction(ownExport('bridge_cached_media'),'pointer',['pointer'],{exceptions:'steal'}),
|
|
91
|
+
messages:new NativeFunction(ownExport('bridge_read_messages'),'pointer',['pointer','pointer','pointer','pointer','pointer','pointer','uint','pointer'],{exceptions:'steal'})};
|
|
92
|
+
return nativeSupport;
|
|
93
|
+
}
|
|
94
|
+
function nativeEntry(name) {
|
|
95
|
+
if(!liveNativeBase) throw Error('native_module_missing');
|
|
96
|
+
const entry=nativeProfile[name];
|
|
97
|
+
if(!entry) throw Error('native_feature_unavailable');
|
|
98
|
+
const address=liveNativeBase.add(entry.offset);
|
|
99
|
+
if(!readable(address,entry.signature.length/2)||hexBytes(address,entry.signature.length/2)!==entry.signature) throw Error('native_signature_mismatch');
|
|
100
|
+
return address;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let migrationHooksInstalled=false;
|
|
104
|
+
const migrationState={starts:0,listenerAttempts:0,listenerReady:false};
|
|
105
|
+
function installMigrationHooks(){
|
|
106
|
+
if(migrationHooksInstalled)return;
|
|
107
|
+
Interceptor.attach(nativeEntry('migrationStart'),{onEnter(){migrationState.starts++;migrationState.listenerReady=false;}});
|
|
108
|
+
Interceptor.attach(nativeEntry('migrationRoamStart'),{onEnter(){migrationState.listenerAttempts++;},onLeave(result){migrationState.listenerReady=Boolean(result.toInt32()&255);}});
|
|
109
|
+
migrationHooksInstalled=true;
|
|
110
|
+
}
|
|
111
|
+
let microphoneHooksInstalled=false;
|
|
112
|
+
const microphoneState={dictation:{starts:0,finishes:0,active:false},recording:{starts:0,active:false}};
|
|
113
|
+
let controlledRecording=null;
|
|
114
|
+
function installMicrophoneHooks(){
|
|
115
|
+
if(microphoneHooksInstalled)return;
|
|
116
|
+
nativeEntry('dictationStart');nativeEntry('dictationFinish');nativeEntry('recordingStart');nativeEntry('recordingSend');nativeEntry('recordingBackendStart');
|
|
117
|
+
Interceptor.attach(nativeEntry('dictationStart'),{onEnter(){microphoneState.dictation.starts++;microphoneState.dictation.active=true;}});
|
|
118
|
+
Interceptor.attach(nativeEntry('dictationFinish'),{onEnter(args){microphoneState.dictation.finishes++;microphoneState.dictation.active=false;microphoneState.dictation.finishReason=args[1].toInt32();}});
|
|
119
|
+
Interceptor.attach(nativeEntry('recordingStart'),{onEnter(){microphoneState.recording.starts++;microphoneState.recording.active=true;}});
|
|
120
|
+
Interceptor.attach(nativeEntry('recordingBackendStart'),{onEnter(){if(controlledRecording){microphoneState.recording.starts++;microphoneState.recording.active=true;}}});
|
|
121
|
+
const entry=nativeEntry('recordingSend'),original=new NativeFunction(entry,'void',['pointer','pointer']);
|
|
122
|
+
const replacement=new NativeCallback((context,audio)=>{
|
|
123
|
+
if(!controlledRecording){original(context,audio);return;}
|
|
124
|
+
// API-owned recording is intercepted before native SendVoice queues it.
|
|
125
|
+
// The caller must invoke the separate send endpoint to transmit it.
|
|
126
|
+
microphoneState.recording.active=false;
|
|
127
|
+
try{const value=decodeResult(supportFunctions().captureVoice(context,audio));
|
|
128
|
+
if(value.chatId!==controlledRecording.chatId)throw Error('native_recording_recipient_changed');
|
|
129
|
+
controlledRecording.result=value;controlledRecording.status='recorded';
|
|
130
|
+
}catch(error){controlledRecording.status='failed';controlledRecording.error=error.message;}
|
|
131
|
+
},'void',['pointer','pointer']);
|
|
132
|
+
Interceptor.replace(entry,replacement);microphoneHooksInstalled=true;
|
|
133
|
+
}
|
|
134
|
+
function accountContext() {
|
|
135
|
+
if(!nativeState.installed) rpc.exports.discoverLiveImage();
|
|
136
|
+
if(!liveManagerSlot) throw Error('native_module_missing');
|
|
137
|
+
const manager=liveManagerSlot.readPointer();
|
|
138
|
+
if(!readable(manager,0x78)) throw Error('native_manager_unavailable');
|
|
139
|
+
const vtable=manager.readPointer();
|
|
140
|
+
if(!readable(vtable.add(0x68),8)) throw Error('native_manager_unavailable');
|
|
141
|
+
return {manager,getter:vtable.add(0x68).readPointer(),available:!manager.add(0x68).readPointer().isNull()};
|
|
142
|
+
}
|
|
143
|
+
function decodeResult(address) {
|
|
144
|
+
const value=JSON.parse(address.readUtf8String());
|
|
145
|
+
if(value.error) throw Error(value.error);
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
function nameFunctions() {
|
|
149
|
+
const addresses=[nativeEntry('accountServices'),nativeEntry('contactService'),nativeEntry('contactDisplayName')];
|
|
150
|
+
const table=Memory.alloc(addresses.length*Process.pointerSize);
|
|
151
|
+
addresses.forEach((address,i)=>table.add(i*Process.pointerSize).writePointer(address));
|
|
152
|
+
return table;
|
|
153
|
+
}
|
|
154
|
+
function recentFunctions() {
|
|
155
|
+
const addresses=[nativeEntry('accountServices'),nativeEntry('messageService'),nativeEntry('initialMessages'),nativeEntry('sessionService'),liveNativeBase.add(nativeProfile.sessionServiceMarkerOffset),nativeEntry('copySessions'),nativeEntry('sessionDestructor'),nativeEntry('isMinimizedSession'),nativeEntry('isPinnedSession')];
|
|
156
|
+
const table=Memory.alloc(addresses.length*Process.pointerSize);
|
|
157
|
+
addresses.forEach((address,i)=>table.add(i*Process.pointerSize).writePointer(address));
|
|
158
|
+
return table;
|
|
159
|
+
}
|
|
160
|
+
function refreshConversations(withNames=false) {
|
|
161
|
+
const context=accountContext();
|
|
162
|
+
if(!context.available) {nativeState.chats=[];nativeState.connected=false;throw Error('native_account_not_ready');}
|
|
163
|
+
const value=decodeResult(supportFunctions().chats(nativeEntry('enumeration'),context.getter,context.manager,withNames?nameFunctions():ptr(0),withNames?recentFunctions():ptr(0)));
|
|
164
|
+
nativeState.chats=value.items;nativeState.enumerationObserved=true;nativeState.connected=true;nativeState.failure=null;
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
function nativeMessages(chatId,limit,mediaMessageId=null) {
|
|
168
|
+
if(mediaMessageId!==null&&!/^\d{1,20}$/.test(mediaMessageId))throw Error('native_invalid_media_id');
|
|
169
|
+
if(typeof chatId!=='string'||!chatId||chatId.length>1024||/[\x00-\x1f\x7f]/.test(chatId))throw Error('invalid_chat_id');
|
|
170
|
+
if(!Number.isInteger(limit)||limit<1||limit>100)throw Error('invalid_limit');
|
|
171
|
+
refreshConversations();
|
|
172
|
+
if(!nativeState.chats.some(chat=>chat.id===chatId))throw Error('native_chat_not_found');
|
|
173
|
+
const context=accountContext();
|
|
174
|
+
if(nativePendingRead) throw Error('native_bridge_busy');
|
|
175
|
+
const services=nativeEntry('accountServices'),service=nativeEntry('messageService'),initial=Memory.alloc(8*Process.pointerSize);
|
|
176
|
+
[nativeEntry('initialMessages'),nativeEntry('messageUiConversion'),nativeEntry('messageUiDestructor'),nativeEntry('accountServices'),nativeEntry('contactService'),nativeEntry('contactDisplayName'),nativeEntry('voiceAudioPath'),nativeEntry('voiceDataGetter')].forEach((address,i)=>initial.add(i*Process.pointerSize).writePointer(address));
|
|
177
|
+
const chat=Memory.allocUtf8String(chatId);const support=supportFunctions();
|
|
178
|
+
return inNativeTask(()=>{
|
|
179
|
+
const value=decodeResult(support.messages(context.getter,context.manager,services,service,initial,chat,limit,mediaMessageId===null?ptr(0):Memory.allocUtf8String(mediaMessageId)));
|
|
180
|
+
nativeState.historyVerified=nativeState.historyVerified||value.items.some(item=>item.content?.length>0);
|
|
181
|
+
for(const item of value.items){if([3,34,43,47,49,62].includes(item.type))for(const key of [item.id,item.serverId])if(/^\d{12,20}$/.test(key))observedMediaHashes.add(key);for(const match of item.content.matchAll(/(?:md5|voicemd5)=["']([a-f0-9]{32})["']/gi))observedMediaHashes.add(match[1].toLowerCase());for(const hash of Object.values(item.nativeMediaFields??{}))if(/^[a-zA-Z0-9_.-]{8,128}$/.test(hash)&&!hash.includes('..'))observedMediaHashes.add(hash);}
|
|
182
|
+
nativeState.messageReadsObserved++;
|
|
183
|
+
return value;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function nativeSendText(chatId,text,prepare=false) {
|
|
187
|
+
if(typeof chatId!=='string'||!chatId||chatId.length>1024||/[\x00-\x1f\x7f]/.test(chatId))throw Error('invalid_chat_id');
|
|
188
|
+
if(['@placeholder_foldgroup','brandsessionholder','brandservicesessionholder'].includes(chatId))throw Error('native_invalid_recipient');
|
|
189
|
+
if(typeof text!=='string'||!text.trim()||text.length>4096||text.includes('\0'))throw Error('invalid_text');
|
|
190
|
+
refreshConversations();
|
|
191
|
+
if(!nativeState.chats.some(chat=>chat.id===chatId))throw Error('native_chat_not_found');
|
|
192
|
+
const context=accountContext(),functions=Memory.alloc(4*Process.pointerSize);
|
|
193
|
+
['makeNativeUuid','makeUiSendInfo','sendGroupMessages','sendResultDestructor'].forEach((name,i)=>functions.add(i*Process.pointerSize).writePointer(nativeEntry(name)));
|
|
194
|
+
const chat=Memory.allocUtf8String(chatId),content=Memory.allocUtf8String(text),services=nativeEntry('accountServices'),service=nativeEntry('messageService');
|
|
195
|
+
// Native UiSendInfo was verified with a server-acknowledged File Transfer test.
|
|
196
|
+
return inNativeTask(()=>decodeResult((prepare?supportFunctions().prepareText:supportFunctions().send)(context.getter,context.manager,services,service,functions,chat,content)));
|
|
197
|
+
|
|
198
|
+
}
|
|
199
|
+
function inNativeTask(work) {
|
|
200
|
+
if(nativePendingRead) throw Error('native_bridge_busy');
|
|
201
|
+
const guard=nativeEntry('taskContextGuard'),support=supportFunctions();
|
|
202
|
+
return new Promise((resolve,reject)=>{
|
|
203
|
+
let listener,timer,finished=false;
|
|
204
|
+
const finish=(error,value)=>{
|
|
205
|
+
if(finished)return;finished=true;clearTimeout(timer);nativePendingRead=false;
|
|
206
|
+
listener?.detach();
|
|
207
|
+
if(error)reject(error);else resolve(value);
|
|
208
|
+
};
|
|
209
|
+
nativePendingRead=true;
|
|
210
|
+
timer=setTimeout(()=>finish(Error('native_task_context_unavailable')),12000);
|
|
211
|
+
try {listener=Interceptor.attach(guard,{onEnter(){
|
|
212
|
+
if(finished||!nativePendingRead||nativeTaskReadActive)return;
|
|
213
|
+
if(!support.hasTask(liveNativeBase.add(nativeProfile.taskContextTlsOffset)))return;
|
|
214
|
+
nativeTaskReadActive=true;
|
|
215
|
+
try{finish(null,work());}
|
|
216
|
+
catch(error){lastNativeFault={address:error.address?.toString(),offset:error.address&&liveNativeBase?error.address.sub(liveNativeBase).toString():null};finish(error);}
|
|
217
|
+
finally{nativeTaskReadActive=false;}
|
|
218
|
+
}});}catch(error){finish(error);}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const observedMediaHashes=new Set();
|
|
222
|
+
let nativePendingRead=false,nativeTaskReadActive=false;
|
|
223
|
+
let lastNativeFault=null;
|
|
224
|
+
const observer = Process.attachModuleObserver({ onAdded: install });
|
|
225
|
+
rpc.exports = {
|
|
226
|
+
startupStatus() {
|
|
227
|
+
let applicationRunning = null;
|
|
228
|
+
try {
|
|
229
|
+
const app = Module.getGlobalExportByName('NSApp').readPointer();
|
|
230
|
+
if (!app.isNull()) {
|
|
231
|
+
const register = new NativeFunction(Module.getGlobalExportByName('sel_registerName'), 'pointer', ['pointer']);
|
|
232
|
+
const message = new NativeFunction(Module.getGlobalExportByName('objc_msgSend'), 'bool', ['pointer', 'pointer']);
|
|
233
|
+
applicationRunning = message(app, register(Memory.allocUtf8String('isRunning')));
|
|
234
|
+
}
|
|
235
|
+
} catch (_) {}
|
|
236
|
+
return { pid:Process.id, executable:Process.mainModule.path, applicationRunning, largeModules: Process.enumerateModules()
|
|
237
|
+
.filter(module => module.size > 50000000 && !module.path.startsWith('/System/') && !module.path.startsWith('/usr/lib/'))
|
|
238
|
+
.map(module => ({ name: module.name, path: module.path, size: module.size })) };
|
|
239
|
+
},
|
|
240
|
+
discoverLiveImage() {
|
|
241
|
+
if (nativeState.installed) return { installed: true, candidatesChecked: 0 };
|
|
242
|
+
let bytesChecked = 0;
|
|
243
|
+
let candidatesChecked = 0;
|
|
244
|
+
const ranges = Process.enumerateRanges({ protection: 'r-x', coalesce: true })
|
|
245
|
+
.filter(range => {
|
|
246
|
+
if (range.protection !== 'r-x' || range.size < 128 || range.size > 536870912) return false;
|
|
247
|
+
const owner = Process.findModuleByAddress(range.base);
|
|
248
|
+
return owner === null || (!owner.path.startsWith('/System/') && !owner.path.startsWith('/usr/lib/'));
|
|
249
|
+
})
|
|
250
|
+
.sort((a, b) => b.size - a.size);
|
|
251
|
+
for (const range of ranges.slice(0, 512)) {
|
|
252
|
+
if (bytesChecked + range.size > 805306368) break;
|
|
253
|
+
bytesChecked += range.size;
|
|
254
|
+
for (const hit of Memory.scanSync(range.base, range.size, nativeProfile.enumeration.scanPattern)) {
|
|
255
|
+
candidatesChecked++;
|
|
256
|
+
const base = hit.address.sub(nativeProfile.enumeration.offset);
|
|
257
|
+
if (!managerSlotForEnumeration(hit.address)) continue;
|
|
258
|
+
install({ name: 'wechat.dylib', path: '/Contents/Resources/live-native-image', size: range.size, base });
|
|
259
|
+
if (nativeState.installed) return { installed: true, candidatesChecked, bytesChecked };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { installed: false, candidatesChecked, bytesChecked };
|
|
263
|
+
},
|
|
264
|
+
status() {
|
|
265
|
+
let accountAvailable = false;
|
|
266
|
+
try {accountAvailable=accountContext().available;nativeState.failure=null;} catch(error) {nativeState.failure=error.message;}
|
|
267
|
+
return { protocol: nativeState.protocol, state: nativeState.state, wechatVersion:nativeProfile.version, build:nativeProfile.build,
|
|
268
|
+
installationNoticeSuppression:{installed:Boolean(globalThis.WECHAT_INSTALL_NOTICE?.installed),suppressed:globalThis.WECHAT_INSTALL_NOTICE?.suppressed??0},
|
|
269
|
+
connected: accountAvailable, accountAvailable, installed: nativeState.installed,
|
|
270
|
+
enumerationObserved: nativeState.enumerationObserved,
|
|
271
|
+
chatCount: nativeState.chats.length, messageReadsObserved: nativeState.messageReadsObserved,
|
|
272
|
+
failure: nativeState.failure, lastNativeFault, lastNativeStage:nativeSupport?.stage(), messagesImplemented: true, historyVerified: Boolean(nativeState.historyVerified), sendsImplemented: true };
|
|
273
|
+
},
|
|
274
|
+
probeTextProcessor() {const context=accountContext();const addresses=[nativeEntry('accountServices'),nativeEntry('messageService'),nativeEntry('sendProcessor')];return inNativeTask(()=>{const value=decodeResult(supportFunctions().probeProcessor(context.getter,context.manager,...addresses));value.processorVtableOffset=ptr(value.processorVtable).sub(liveNativeBase).toString();delete value.processorVtable;return value;});},
|
|
275
|
+
voiceData(chatId,id){return nativeMessages(chatId,100,id);},
|
|
276
|
+
importControl(action){accountContext();installMigrationHooks();if(![0,1,2].includes(action))throw Error('invalid_input');const result=decodeResult(supportFunctions().importControl(nativeEntry('migrationWindow'),nativeEntry('migrationStopServices'),nativeEntry('migrationCancelTransfer'),nativeEntry('migrationBusyKind'),action));return {...result,...migrationState};},
|
|
277
|
+
migrationStatus(){return rpc.exports.importControl(0);},
|
|
278
|
+
selfId(){const context=accountContext();return decodeResult(supportFunctions().selfId(context.getter,context.manager));},
|
|
279
|
+
microphoneStatus(){accountContext();installMicrophoneHooks();return {dictation:{...microphoneState.dictation},recording:{...microphoneState.recording,status:controlledRecording?.status??'idle'}};},
|
|
280
|
+
armRecording(chatId){accountContext();installMicrophoneHooks();if(controlledRecording)throw Error('native_bridge_busy');refreshConversations();if(!nativeState.chats.some(chat=>chat.id===chatId))throw Error('native_chat_not_found');controlledRecording={chatId,status:'armed'};return {status:'armed'};},
|
|
281
|
+
recordingResult(){if(!controlledRecording)throw Error('native_recording_unavailable');return {status:controlledRecording.status,...(controlledRecording.result??{}),...(controlledRecording.error?{error:controlledRecording.error}:{})};},
|
|
282
|
+
discardRecording(){controlledRecording=null;microphoneState.recording.active=false;return {status:'discarded'};},
|
|
283
|
+
sendVoice(chatId,dataBase64,durationMs,prepare=false){
|
|
284
|
+
if(typeof chatId!=='string'||!chatId||chatId.length>1024||/[\x00-\x1f\x7f]/.test(chatId))throw Error('invalid_chat_id');
|
|
285
|
+
if(['@placeholder_foldgroup','brandsessionholder','brandservicesessionholder'].includes(chatId))throw Error('native_invalid_recipient');
|
|
286
|
+
refreshConversations();if(!nativeState.chats.some(chat=>chat.id===chatId))throw Error('native_chat_not_found');
|
|
287
|
+
if(typeof dataBase64!=='string'||dataBase64.length>1398104||dataBase64.length%4||!/^[A-Za-z0-9+/]+={0,2}$/.test(dataBase64)||!Number.isInteger(durationMs)||durationMs<1000||durationMs>60000)throw Error('invalid_voice');
|
|
288
|
+
const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/',values=[];let acc=0,bits=0;
|
|
289
|
+
for(const c of dataBase64){if(c==='=')break;acc=(acc<<6)|alphabet.indexOf(c);bits+=6;if(bits>=8){bits-=8;values.push((acc>>>bits)&255);}}
|
|
290
|
+
const bytes=new Uint8Array(values);if(bytes.length<10||bytes.length>1048576)throw Error('invalid_voice');
|
|
291
|
+
const buffer=Memory.alloc(bytes.length);buffer.writeByteArray(bytes);
|
|
292
|
+
const context=accountContext(),functions=Memory.alloc(4*Process.pointerSize);
|
|
293
|
+
['makeNativeUuid','makeVoiceSendInfo','sendGroupMessages','sendResultDestructor'].forEach((name,i)=>functions.add(i*Process.pointerSize).writePointer(nativeEntry(name)));
|
|
294
|
+
return inNativeTask(()=>decodeResult(supportFunctions().sendVoice(context.getter,context.manager,nativeEntry('accountServices'),nativeEntry('messageService'),functions,Memory.allocUtf8String(chatId),buffer,bytes.length,durationMs,prepare?1:0)));
|
|
295
|
+
},
|
|
296
|
+
extractImageText(dataBase64){
|
|
297
|
+
if(typeof dataBase64!=='string'||dataBase64.length<4||dataBase64.length>11184812||dataBase64.length%4||!/^[A-Za-z0-9+/]+={0,2}$/.test(dataBase64))throw Error('native_invalid_ocr_image');
|
|
298
|
+
const context=accountContext(),functions=Memory.alloc(3*Process.pointerSize);
|
|
299
|
+
['ocrService','ocrInitialize','ocrLocalRecognition'].forEach((name,i)=>functions.add(i*Process.pointerSize).writePointer(nativeEntry(name)));
|
|
300
|
+
const services=nativeEntry('accountServices'),image=Memory.allocUtf8String(dataBase64);
|
|
301
|
+
return inNativeTask(()=>decodeResult(supportFunctions().ocr(context.getter,context.manager,services,functions,image)));
|
|
302
|
+
},
|
|
303
|
+
voiceTranscriptionResult(chatId,id){
|
|
304
|
+
if(typeof chatId!=='string'||!chatId||chatId.length>1024||/[\x00-\x1f\x7f]/.test(chatId)||!/^\d{1,20}$/.test(id))throw Error('invalid_chat_id');
|
|
305
|
+
const context=accountContext();return decodeResult(supportFunctions().transcript(context.getter,context.manager,Memory.allocUtf8String(chatId),Memory.allocUtf8String(id)));
|
|
306
|
+
},
|
|
307
|
+
transcribeVoice(chatId,id){
|
|
308
|
+
if(typeof chatId!=='string'||!chatId||chatId.length>1024||/[\x00-\x1f\x7f]/.test(chatId)||!/^\d{1,20}$/.test(id))throw Error('invalid_chat_id');
|
|
309
|
+
refreshConversations();if(!nativeState.chats.some(chat=>chat.id===chatId))throw Error('native_chat_not_found');
|
|
310
|
+
const context=accountContext(),functions=Memory.alloc(7*Process.pointerSize);
|
|
311
|
+
['initialMessages','messageUiConversion','messageUiDestructor','voiceFrontRegistry'].forEach((name,i)=>functions.add(i*Process.pointerSize).writePointer(nativeEntry(name)));
|
|
312
|
+
functions.add(4*Process.pointerSize).writePointer(liveNativeBase.add(nativeProfile.voiceFrontDescriptorOffset));
|
|
313
|
+
functions.add(5*Process.pointerSize).writePointer(nativeEntry('voiceTranscriptionStart'));
|
|
314
|
+
functions.add(6*Process.pointerSize).writePointer(nativeEntry('voiceTranscriptionAttach'));
|
|
315
|
+
const services=nativeEntry('accountServices'),service=nativeEntry('messageService'),chat=Memory.allocUtf8String(chatId),message=Memory.allocUtf8String(id);
|
|
316
|
+
return inNativeTask(()=>decodeResult(supportFunctions().transcribe(context.getter,context.manager,services,service,functions,chat,message)));
|
|
317
|
+
},
|
|
318
|
+
async attachmentData(chatId,id) {
|
|
319
|
+
const value=await nativeMessages(chatId,100),message=value.items.find(item=>item.id===id);
|
|
320
|
+
if(!message||message.type!==49||!/<appmsg[\s>]/.test(message.content)||!/<type>6<\/type>/.test(message.content))throw Error('invalid_media_type');
|
|
321
|
+
const name=message.nativeMediaFields?.['344'],match=message.content.match(/<totallen>(\d+)<\/totallen>/);
|
|
322
|
+
const size=match?Number(match[1]):0;if(!name||!Number.isSafeInteger(size)||size<1||size>128*1024*1024)throw Error('native_media_not_cached');
|
|
323
|
+
const context=accountContext(),support=supportFunctions(),self=decodeResult(support.selfId(context.getter,context.manager)).id;
|
|
324
|
+
return decodeResult(support.cachedAttachment(Memory.allocUtf8String(name),size,Memory.allocUtf8String(self)));
|
|
325
|
+
},
|
|
326
|
+
cachedMedia(hash) {accountContext();if(!observedMediaHashes.has(hash))throw Error('native_invalid_media_id');return decodeResult(supportFunctions().cachedMedia(Memory.allocUtf8String(hash)));},
|
|
327
|
+
prepareText(chatId,text) {return nativeSendText(chatId,text,true);},
|
|
328
|
+
sendText(chatId,text) {return nativeSendText(chatId,text);},
|
|
329
|
+
listMessages(chatId,limit) {return nativeMessages(chatId,limit);},
|
|
330
|
+
listChats() {
|
|
331
|
+
if(!accountContext().available) throw Error('native_account_not_ready');
|
|
332
|
+
return inNativeTask(()=>refreshConversations(true));
|
|
333
|
+
},
|
|
334
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import PDFKit
|
|
3
|
+
import Foundation
|
|
4
|
+
// Render the first page of an already retrieved PDF. No network or external app.
|
|
5
|
+
guard CommandLine.arguments.count == 3,
|
|
6
|
+
let document=PDFDocument(url:URL(fileURLWithPath:CommandLine.arguments[1])),
|
|
7
|
+
let page=document.page(at:0) else { exit(1) }
|
|
8
|
+
let bounds=page.bounds(for:.mediaBox)
|
|
9
|
+
guard bounds.width>0,bounds.height>0,bounds.width.isFinite,bounds.height.isFinite else { exit(1) }
|
|
10
|
+
let image=page.thumbnail(of:NSSize(width:320,height:420),for:.mediaBox)
|
|
11
|
+
guard let tiff=image.tiffRepresentation,let bitmap=NSBitmapImageRep(data:tiff),
|
|
12
|
+
let png=bitmap.representation(using:.png,properties:[:]) else { exit(1) }
|
|
13
|
+
do {try png.write(to:URL(fileURLWithPath:CommandLine.arguments[2]),options:.atomic)} catch {exit(1)}
|