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/setup.py ADDED
@@ -0,0 +1,342 @@
1
+ """Download, validate, copy and instrument WeChat without modifying the installed app."""
2
+
3
+ import hashlib
4
+ import json
5
+ import lzma
6
+ import os
7
+ import platform
8
+ import secrets
9
+ import shutil
10
+ import socket
11
+ import struct
12
+ import subprocess
13
+ import tempfile
14
+ import urllib.error
15
+ import urllib.request
16
+ from contextlib import contextmanager
17
+ from pathlib import Path
18
+
19
+ from .config import ASSETS, load_runtime, private_directory, save_json
20
+ from .errors import AppError
21
+ from .network import ssl_context
22
+
23
+ WECHAT_URL = "https://dldir1v6.qq.com/weixin/Universal/Mac/WeChatMac_4.1.15.dmg"
24
+ GADGET_URL = (
25
+ "https://github.com/frida/frida/releases/download/17.8.0/frida-gadget-17.8.0-macos-universal.dylib.xz"
26
+ )
27
+ GADGET_SHA256 = "4cf5433452e745e8b283c7a2e79518c2cd6295e85869589dc3c171d5689e6b8c"
28
+ GADGET_LOAD = b"@executable_path/../Frameworks/FridaGadget.dylib"
29
+ PROFILE = json.loads((ASSETS / "profile.json").read_text())
30
+
31
+
32
+ def require_macos():
33
+ if platform.system() != "Darwin" or platform.machine() != "arm64":
34
+ raise AppError("unsupported_platform", "Native setup requires Apple Silicon macOS", 424)
35
+
36
+
37
+ def execute(args, **kwargs):
38
+ try:
39
+ return subprocess.run(
40
+ [str(x) for x in args],
41
+ check=True,
42
+ capture_output=True,
43
+ timeout=kwargs.pop("timeout", 120),
44
+ **kwargs,
45
+ ).stdout
46
+ except (OSError, subprocess.SubprocessError) as error:
47
+ # Do not include command outputs; they may contain private paths or tokens.
48
+ raise AppError(
49
+ "setup_command_failed",
50
+ f"{Path(str(args[0])).name} failed; check Xcode command-line tools and app write permissions",
51
+ 424,
52
+ ) from error
53
+
54
+
55
+ def fingerprint(path):
56
+ with path.open("rb") as source:
57
+ return hashlib.file_digest(source, "sha256").hexdigest()
58
+
59
+
60
+ def check_payload(app):
61
+ try:
62
+ actual = fingerprint(app / "Contents/Resources/wechat.dylib")
63
+ except OSError:
64
+ raise AppError("invalid_wechat_app", "Missing Contents/Resources/wechat.dylib", 424) from None
65
+ for path in sorted((ASSETS / "profiles").glob("*.json")):
66
+ profile = json.loads(path.read_text())
67
+ if actual == profile["payloadSha256"]:
68
+ return profile
69
+ raise AppError(
70
+ "unsupported_wechat_version",
71
+ "This WeChat payload has no validated native profile. Update wechat-cli, "
72
+ "or select a supported installer with --wechat-url. Stock WeChat was not modified.",
73
+ 424,
74
+ )
75
+
76
+
77
+ def check_app(app):
78
+ app = Path(app).expanduser().resolve()
79
+ if app == Path("/Applications/WeChat.app").resolve():
80
+ raise AppError(
81
+ "stock_app_protected", "Use --source to copy stock WeChat, or --app with a separate patched copy"
82
+ )
83
+ check_payload(app)
84
+ try:
85
+ config = json.loads((app / "Contents/Frameworks/FridaGadget.config").read_text())["interaction"]
86
+ if (
87
+ config["type"] != "listen"
88
+ or config.get("address", "127.0.0.1") != "127.0.0.1"
89
+ or type(config["port"]) is not int
90
+ or not 1 <= config["port"] <= 65535
91
+ or not isinstance(config["token"], str)
92
+ or len(config["token"]) < 16
93
+ ):
94
+ raise ValueError()
95
+ if not (app / "Contents/Frameworks/FridaGadget.dylib").is_file():
96
+ raise ValueError()
97
+ except (OSError, KeyError, TypeError, ValueError):
98
+ raise AppError(
99
+ "invalid_gadget_config", "A loopback-only authenticated Frida Gadget is required", 424
100
+ ) from None
101
+ return app
102
+
103
+
104
+ def download(url, target, maximum, expected=None):
105
+ """Bounded, atomic HTTPS download; a failed download never replaces a cached file."""
106
+ if not url.startswith("https://"):
107
+ raise AppError("invalid_download_url", "Installer downloads require HTTPS")
108
+ if target.is_file() and expected and fingerprint(target) == expected:
109
+ return target
110
+ temporary = target.with_suffix(target.suffix + ".part")
111
+ try:
112
+ request = urllib.request.Request(url, headers={"User-Agent": "wechat-cli/0.1.0"})
113
+ with (
114
+ urllib.request.urlopen(request, timeout=60, context=ssl_context()) as response,
115
+ temporary.open("wb") as output,
116
+ ):
117
+ if not response.url.startswith("https://"):
118
+ raise AppError("invalid_download_url", "Download redirected away from HTTPS")
119
+ size = 0
120
+ while chunk := response.read(1024 * 1024):
121
+ size += len(chunk)
122
+ if size > maximum:
123
+ raise AppError("download_too_large", "Download exceeds the configured size limit", 413)
124
+ output.write(chunk)
125
+ if expected and fingerprint(temporary) != expected:
126
+ raise AppError(
127
+ "download_checksum_mismatch", "Downloaded Frida Gadget failed SHA-256 verification", 424
128
+ )
129
+ os.replace(temporary, target)
130
+ return target
131
+ except (OSError, urllib.error.URLError) as error:
132
+ raise AppError(
133
+ "download_failed",
134
+ "Cannot download the installer; check connectivity or supply --source /path/to/WeChat.app",
135
+ 424,
136
+ ) from error
137
+ finally:
138
+ temporary.unlink(missing_ok=True)
139
+
140
+
141
+ @contextmanager
142
+ def mounted_app(dmg, directory):
143
+ mount = Path(tempfile.mkdtemp(prefix="mount-", dir=directory))
144
+ attached = False
145
+ try:
146
+ execute(
147
+ ["/usr/bin/hdiutil", "attach", "-readonly", "-nobrowse", "-mountpoint", mount, dmg], timeout=180
148
+ )
149
+ attached = True
150
+ apps = [p for p in mount.glob("*.app") if p.name in {"WeChat.app", "微信.app"}]
151
+ if len(apps) != 1:
152
+ raise AppError("invalid_installer", "Installer must contain one WeChat.app", 424)
153
+ yield apps[0]
154
+ finally:
155
+ if attached:
156
+ execute(["/usr/bin/hdiutil", "detach", mount], timeout=30)
157
+ mount.rmdir()
158
+
159
+
160
+ def inject_gadget(binary):
161
+ """Add LC_LOAD_DYLIB only in verified empty Mach-O header padding.
162
+
163
+ No relocation or vendor payload modification. Support thin 64-bit and
164
+ universal 32-bit fat headers; fail closed for other layouts.
165
+ """
166
+ data = bytearray(binary.read_bytes())
167
+ if data[:4] == b"\xca\xfe\xba\xbe":
168
+ count = struct.unpack_from(">I", data, 4)[0]
169
+ if not 1 <= count <= 8 or len(data) < 8 + count * 20:
170
+ raise AppError("invalid_macho", "Invalid universal Mach-O header", 424)
171
+ slices = [struct.unpack_from(">IIIII", data, 8 + i * 20)[2:4] for i in range(count)]
172
+ else:
173
+ slices = [(0, len(data))]
174
+ for base, size in slices:
175
+ if base + size > len(data) or size < 32 or data[base : base + 4] != b"\xcf\xfa\xed\xfe":
176
+ raise AppError("invalid_macho", "Expected a 64-bit Mach-O executable", 424)
177
+ ncmds, cmdsize = struct.unpack_from("<II", data, base + 16)
178
+ end = base + 32 + cmdsize
179
+ if end > base + size:
180
+ raise AppError("invalid_macho", "Load commands exceed the Mach-O slice", 424)
181
+ cursor, found = base + 32, False
182
+ first_section = base + size
183
+ for _ in range(ncmds):
184
+ if cursor + 8 > end:
185
+ raise AppError("invalid_macho", "Truncated load command", 424)
186
+ cmd, length = struct.unpack_from("<II", data, cursor)
187
+ if length < 8 or cursor + length > end:
188
+ raise AppError("invalid_macho", "Invalid load command length", 424)
189
+ if cmd == 0x19:
190
+ if length < 72:
191
+ raise AppError("invalid_macho", "Truncated segment command", 424)
192
+ section_count = struct.unpack_from("<I", data, cursor + 64)[0]
193
+ if length < 72 + section_count * 80:
194
+ raise AppError("invalid_macho", "Truncated section table", 424)
195
+ for section in range(section_count):
196
+ section_offset = struct.unpack_from("<I", data, cursor + 72 + section * 80 + 48)[0]
197
+ if section_offset:
198
+ first_section = min(first_section, base + section_offset)
199
+ if cmd in {0xC, 0x80000018} and GADGET_LOAD in data[cursor : cursor + length]:
200
+ found = True
201
+ cursor += length
202
+ if cursor != end:
203
+ raise AppError("invalid_macho", "Inconsistent load command size", 424)
204
+ if found:
205
+ continue
206
+ length = (24 + len(GADGET_LOAD) + 1 + 7) & ~7
207
+ if end + length > first_section or any(data[end : end + length]):
208
+ raise AppError("macho_padding_unavailable", "No empty header padding for Frida Gadget", 424)
209
+ command = struct.pack("<IIIIII", 0xC, length, 24, 0, 0, 0) + GADGET_LOAD + b"\0"
210
+ data[end : end + length] = command.ljust(length, b"\0")
211
+ struct.pack_into("<II", data, base + 16, ncmds + 1, cmdsize + length)
212
+ binary.write_bytes(data)
213
+
214
+
215
+ def build_helper(app):
216
+ # Gadget configuration lives in Frameworks, so codesign treats it as nested code.
217
+ # A generic file signature is stored in extended attributes by macOS.
218
+ execute(["/usr/bin/codesign", "--force", "--sign", "-", app / "Contents/Frameworks/FridaGadget.config"])
219
+ compiler = execute(["/usr/bin/clang++", "--version"])
220
+ flags = ["-arch", "arm64", "-std=c++17", "-dynamiclib", "-O2", "-Wall", "-Wextra", "-Werror"]
221
+ source = ASSETS / "bridge.cpp"
222
+ digest = hashlib.sha256(source.read_bytes() + compiler + json.dumps(flags).encode()).hexdigest()[:16]
223
+ library = f"native-reader-{digest}.dylib"
224
+ output = app / "Contents/Frameworks" / library
225
+ if not output.exists():
226
+ temporary = output.with_suffix(f".{os.getpid()}.tmp")
227
+ try:
228
+ execute(["/usr/bin/clang++", *flags, source, "-o", temporary])
229
+ os.replace(temporary, output)
230
+ finally:
231
+ temporary.unlink(missing_ok=True)
232
+ execute(
233
+ ["/usr/bin/codesign", "--force", "--sign", "-", "--entitlements", ASSETS / "entitlements.plist", app]
234
+ )
235
+ return library
236
+
237
+
238
+ def register(app, directory):
239
+ app = check_app(app)
240
+ library = build_helper(app)
241
+ save_json(directory / "native-runtime.json", {"app": str(app), "supportLibrary": library})
242
+ return {
243
+ "configured": True,
244
+ "app": str(app),
245
+ "version": check_payload(app)["version"],
246
+ "build": check_payload(app)["build"],
247
+ }
248
+
249
+
250
+ def prepare(source, destination, gadget, directory):
251
+ source, destination = Path(source).resolve(), Path(destination).resolve()
252
+ if destination == Path("/Applications/WeChat.app").resolve() or destination == source:
253
+ raise AppError("stock_app_protected", "Destination must be a separate app copy")
254
+ if source in destination.parents:
255
+ raise AppError("invalid_destination", "Destination cannot be inside the source app")
256
+ if destination.exists():
257
+ raise AppError("destination_exists", "Destination already exists; use setup --app to register it")
258
+ check_payload(source) # Reject before copying hundreds of megabytes.
259
+ destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
260
+ with tempfile.TemporaryDirectory(prefix="prepare-", dir=destination.parent) as staging:
261
+ app = Path(staging) / "WeChatBridge.app"
262
+ execute(["/usr/bin/ditto", source, app], timeout=180)
263
+ frameworks = app / "Contents/Frameworks"
264
+ shutil.copyfile(gadget, frameworks / "FridaGadget.dylib")
265
+ with socket.socket() as sock:
266
+ sock.bind(("127.0.0.1", 0))
267
+ port = sock.getsockname()[1]
268
+ save_json(
269
+ frameworks / "FridaGadget.config",
270
+ {
271
+ "interaction": {
272
+ "type": "listen",
273
+ "address": "127.0.0.1",
274
+ "port": port,
275
+ "token": secrets.token_hex(32),
276
+ "on_load": "wait",
277
+ }
278
+ },
279
+ )
280
+ inject_gadget(app / "Contents/MacOS/WeChat")
281
+ execute(["/usr/bin/codesign", "--force", "--sign", "-", frameworks / "FridaGadget.dylib"])
282
+ library = build_helper(app)
283
+ # Ensure no partially prepared app is exposed as the configured runtime.
284
+ os.rename(app, destination)
285
+ save_json(directory / "native-runtime.json", {"app": str(destination), "supportLibrary": library})
286
+ return {
287
+ "configured": True,
288
+ "app": str(destination),
289
+ "version": check_payload(destination)["version"],
290
+ "build": check_payload(destination)["build"],
291
+ }
292
+
293
+
294
+ def setup(directory, *, app=None, source=None, destination=None, gadget=None, wechat_url=WECHAT_URL):
295
+ require_macos()
296
+ private_directory(directory)
297
+ if app:
298
+ return register(app, directory)
299
+ destination = Path(destination or directory / "apps/WeChatBridge.app").expanduser().resolve()
300
+ if not source and destination.exists():
301
+ return register(destination, directory)
302
+ downloads = private_directory(directory / "downloads")
303
+ if gadget:
304
+ gadget_path = Path(gadget).expanduser().resolve()
305
+ else:
306
+ compressed = download(GADGET_URL, downloads / "gadget-17.8.0.dylib.xz", 64 * 1024**2, GADGET_SHA256)
307
+ gadget_path = downloads / "FridaGadget.dylib"
308
+ with lzma.open(compressed) as input_file, gadget_path.open("wb") as output:
309
+ # The compressed archive is pinned to a verified hash.
310
+ shutil.copyfileobj(input_file, output)
311
+ if source:
312
+ return prepare(source, destination, gadget_path, directory)
313
+ dmg = download(wechat_url, downloads / "WeChat.dmg", 1024**3)
314
+ with mounted_app(dmg, downloads) as stock:
315
+ # Verify Tencent's signature before trusting any downloaded executable.
316
+ execute(["/usr/bin/codesign", "--verify", "--deep", "--strict", stock])
317
+ details = subprocess.run(
318
+ ["/usr/bin/codesign", "-dv", "--verbose=4", str(stock)], capture_output=True, timeout=30
319
+ )
320
+ if b"TeamIdentifier=5A4RE8SF68" not in details.stderr:
321
+ raise AppError(
322
+ "invalid_vendor_signature",
323
+ "Downloaded app is not signed by the expected WeChat developer",
324
+ 424,
325
+ )
326
+ return prepare(stock, destination, gadget_path, directory)
327
+
328
+
329
+ def doctor(directory):
330
+ require_macos()
331
+ runtime = load_runtime(directory)
332
+ check_app(runtime["app"])
333
+ import frida
334
+
335
+ return {
336
+ "ready": True,
337
+ "app": runtime["app"],
338
+ "version": check_payload(Path(runtime["app"]))["version"],
339
+ "build": check_payload(Path(runtime["app"]))["build"],
340
+ "fridaVersion": frida.__version__,
341
+ "dataDir": str(directory),
342
+ }
wechat_cli/worker.py ADDED
@@ -0,0 +1,227 @@
1
+ """Authenticated local Gadget RPC transport. No UI automation; native chat reads and text sending."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import pathlib
7
+ import subprocess
8
+ import sys
9
+ import time
10
+
11
+ import frida
12
+
13
+ from .setup import check_payload
14
+
15
+ package = pathlib.Path(__file__).resolve().parent / "assets"
16
+ app = pathlib.Path(os.environ["WECHAT_NATIVE_APP"]).resolve()
17
+ data_dir = pathlib.Path(os.environ["WECHAT_DATA_DIR"])
18
+ config = json.loads((app / "Contents/Frameworks/FridaGadget.config").read_text())
19
+
20
+ profile = check_payload(app)
21
+ source = (package / "startup-notice.js").read_text() + "\n"
22
+ source += "globalThis.WECHAT_NATIVE_PROFILE=" + json.dumps(profile) + ";\n"
23
+ support = os.environ["WECHAT_NATIVE_SUPPORT"]
24
+ if (
25
+ not support.startswith("native-reader-")
26
+ or not support.endswith(".dylib")
27
+ or pathlib.Path(support).name != support
28
+ ):
29
+ raise RuntimeError("invalid_native_runtime")
30
+ source += "globalThis.WECHAT_NATIVE_SUPPORT=" + json.dumps(str(app / "Contents/Frameworks" / support)) + ";\n"
31
+ source += (package / "native-agent.js").read_text()
32
+ endpoint = "127.0.0.1:" + str(config["interaction"]["port"])
33
+ session = device = script = child = None
34
+
35
+
36
+ def emit(value):
37
+ print(json.dumps(value), flush=True)
38
+
39
+
40
+ try:
41
+ digest = hashlib.sha256()
42
+ with (app / "Contents/Resources/wechat.dylib").open("rb") as binary:
43
+ for block in iter(lambda: binary.read(1048576), b""):
44
+ digest.update(block)
45
+ if digest.hexdigest() != profile["payloadSha256"]:
46
+ raise RuntimeError("native_payload_mismatch")
47
+ device = frida.get_device_manager().add_remote_device(endpoint, token=config["interaction"]["token"])
48
+ try:
49
+ session = device.attach("Gadget")
50
+ except frida.ServerNotRunningError:
51
+ snapshot = subprocess.run(
52
+ ["/bin/ps", "-axo", "pid=,comm="], check=True, capture_output=True, text=True, timeout=5
53
+ )
54
+ if any(
55
+ line.strip().split(None, 1)[-1] == "/Applications/WeChat.app/Contents/MacOS/WeChat"
56
+ for line in snapshot.stdout.splitlines()
57
+ ):
58
+ raise RuntimeError("stock_app_running")
59
+ child = subprocess.Popen(
60
+ [str(app / "Contents/MacOS/WeChat")],
61
+ stdin=subprocess.DEVNULL,
62
+ stdout=subprocess.DEVNULL,
63
+ stderr=subprocess.DEVNULL,
64
+ start_new_session=True,
65
+ )
66
+ for attempt in range(250):
67
+ if child.poll() is not None:
68
+ raise RuntimeError("native_copy_exited")
69
+ try:
70
+ session = device.attach("Gadget")
71
+ break
72
+ except (frida.TransportError, frida.ServerNotRunningError, frida.TimedOutError):
73
+ time.sleep(0.2)
74
+ else:
75
+ raise RuntimeError("native_bridge_timeout")
76
+ script = session.create_script(source)
77
+ script.load()
78
+ identity = script.exports_sync.startup_status()
79
+ if pathlib.Path(identity["executable"]).resolve() != (app / "Contents/MacOS/WeChat").resolve():
80
+ raise RuntimeError("native_process_identity_mismatch")
81
+ # Resume a Gadget-waiting app even after an earlier gateway startup timed out.
82
+ if child is not None or not identity.get("applicationRunning"):
83
+ try:
84
+ device.resume(identity["pid"])
85
+ except frida.InvalidOperationError:
86
+ pass
87
+ emit({"event": "ready"})
88
+ for line in sys.stdin:
89
+ request = json.loads(line)
90
+ try:
91
+ if request["method"] == "status":
92
+ result = script.exports_sync.status()
93
+ elif request["method"] == "probeTextProcessor":
94
+ result = script.exports_sync.probe_text_processor()
95
+ elif request["method"] == "prepareText":
96
+ result = script.exports_sync.prepare_text(
97
+ request["params"]["chatId"], request["params"]["text"]
98
+ )
99
+ elif request["method"] == "sendText":
100
+ result = script.exports_sync.send_text(request["params"]["chatId"], request["params"]["text"])
101
+ elif request["method"] == "selfId":
102
+ result = script.exports_sync.self_id()
103
+ elif request["method"] == "importControl":
104
+ result = script.exports_sync.import_control(request["params"]["action"])
105
+ elif request["method"] == "migrationStatus":
106
+ result = script.exports_sync.migration_status()
107
+ elif request["method"] == "microphoneStatus":
108
+ result = script.exports_sync.microphone_status()
109
+ elif request["method"] == "armRecording":
110
+ result = script.exports_sync.arm_recording(request["params"]["chatId"])
111
+ elif request["method"] == "recordingResult":
112
+ result = script.exports_sync.recording_result()
113
+ elif request["method"] == "discardRecording":
114
+ result = script.exports_sync.discard_recording()
115
+ elif request["method"] in ["sendVoice", "prepareVoice"]:
116
+ result = script.exports_sync.send_voice(
117
+ request["params"]["chatId"],
118
+ request["params"]["dataBase64"],
119
+ request["params"]["durationMs"],
120
+ request["method"] == "prepareVoice",
121
+ )
122
+ elif request["method"] == "attachmentData":
123
+ result = script.exports_sync.attachment_data(
124
+ request["params"]["chatId"], request["params"]["id"]
125
+ )
126
+ elif request["method"] == "voiceData":
127
+ result = script.exports_sync.voice_data(request["params"]["chatId"], request["params"]["id"])
128
+ elif request["method"] == "transcribeVoice":
129
+ result = script.exports_sync.transcribe_voice(
130
+ request["params"]["chatId"], request["params"]["id"]
131
+ )
132
+ elif request["method"] == "extractImageText":
133
+ result = script.exports_sync.extract_image_text(request["params"]["dataBase64"])
134
+ elif request["method"] == "voiceTranscriptionResult":
135
+ result = script.exports_sync.voice_transcription_result(
136
+ request["params"]["chatId"], request["params"]["id"]
137
+ )
138
+ elif request["method"] == "cachedMedia":
139
+ result = script.exports_sync.cached_media(request["params"]["hash"])
140
+ elif request["method"] == "listChats":
141
+ result = script.exports_sync.list_chats()
142
+ elif request["method"] in ["listMessages", "rawMessages"]:
143
+ result = script.exports_sync.list_messages(
144
+ request["params"]["chatId"], request["params"]["limit"]
145
+ )
146
+ else:
147
+ raise RuntimeError("native_method_unimplemented")
148
+ emit({"id": request["id"], "result": result})
149
+ except Exception as error:
150
+ # Private diagnostic for native call failures. Never sent to callers
151
+ # and never logged with chat/secret values.
152
+ if isinstance(error, frida.core.RPCException):
153
+ description = str(error).split("\n")[0]
154
+ if request["method"] == "prepareVoice" or description.startswith(
155
+ (
156
+ "Error: access violation",
157
+ "Error: invalid address",
158
+ "TypeError:",
159
+ "Error: native_",
160
+ "Error: recursive_mutex",
161
+ )
162
+ ):
163
+ diagnostic = data_dir / "native-rpc-error.json"
164
+ diagnostic.write_text(
165
+ json.dumps({"method": request["method"], "description": description}) + "\n"
166
+ )
167
+ diagnostic.chmod(0o600)
168
+ code = "native_read_failed"
169
+ if isinstance(error, frida.core.RPCException):
170
+ for known in [
171
+ "native_feature_unavailable",
172
+ "native_invalid_media_id",
173
+ "native_migration_busy",
174
+ "native_migration_failed",
175
+ "native_recording_unavailable",
176
+ "native_recording_invalid",
177
+ "native_recording_recipient_changed",
178
+ "native_send_outcome_unknown",
179
+ "native_voice_layout_unverified",
180
+ "invalid_voice",
181
+ "native_voice_prepare_failed",
182
+ "native_ocr_not_ready",
183
+ "native_ocr_failed",
184
+ "native_invalid_ocr_image",
185
+ "native_ocr_layout_unverified",
186
+ "native_message_not_found",
187
+ "invalid_media_type",
188
+ "native_transcription_failed",
189
+ "native_conversations_not_observed",
190
+ "native_account_not_ready",
191
+ "native_signature_mismatch",
192
+ "native_module_missing",
193
+ "native_chat_not_found",
194
+ "native_message_layout_unverified",
195
+ "native_services_not_ready",
196
+ "native_message_service_not_ready",
197
+ "native_task_context_unavailable",
198
+ "native_send_failed",
199
+ "native_invalid_recipient",
200
+ "invalid_chat_id",
201
+ "invalid_text",
202
+ "native_bridge_busy",
203
+ "native_session_service_not_ready",
204
+ "native_session_layout_unverified",
205
+ ]:
206
+ if known in str(error):
207
+ code = known
208
+ break
209
+ emit({"id": request["id"], "error": code})
210
+ except Exception as error:
211
+ code = str(error) if isinstance(error, RuntimeError) else "native_bridge_unavailable"
212
+ emit({"event": "failure", "error": code})
213
+ finally:
214
+ if script is not None:
215
+ try:
216
+ script.unload()
217
+ except Exception:
218
+ pass
219
+ if session is not None:
220
+ try:
221
+ session.detach()
222
+ except Exception:
223
+ pass
224
+ if device is not None:
225
+ frida.get_device_manager().remove_remote_device(endpoint)
226
+ # Keep the native app running for its normal login/session lifecycle.
227
+ # This transport never logs out or terminates the user's app on API close.