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
wechat_cli/config.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Private machine configuration; never store runtime state in uv's disposable cache."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .errors import AppError
|
|
10
|
+
|
|
11
|
+
ASSETS = Path(__file__).parent / "assets"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def data_directory(value=None):
|
|
15
|
+
return (
|
|
16
|
+
Path(
|
|
17
|
+
value
|
|
18
|
+
or os.environ.get("WECHAT_CLI_DATA_DIR")
|
|
19
|
+
or Path.home() / "Library/Application Support/wechat-cli"
|
|
20
|
+
)
|
|
21
|
+
.expanduser()
|
|
22
|
+
.resolve()
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def private_directory(path):
|
|
27
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
28
|
+
path.chmod(0o700)
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def private_write(path, value):
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
34
|
+
fd, name = tempfile.mkstemp(dir=path.parent, prefix=".write-")
|
|
35
|
+
try:
|
|
36
|
+
with os.fdopen(fd, "w") as output:
|
|
37
|
+
output.write(value)
|
|
38
|
+
os.replace(name, path)
|
|
39
|
+
finally:
|
|
40
|
+
Path(name).unlink(missing_ok=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_json(path, value):
|
|
44
|
+
private_write(path, json.dumps(value, indent=2) + "\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def api_token(directory):
|
|
48
|
+
private_directory(directory)
|
|
49
|
+
path = directory / "api-token"
|
|
50
|
+
# Exclusive creation keeps concurrent CLI invocations on the same token.
|
|
51
|
+
try:
|
|
52
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
53
|
+
except FileExistsError:
|
|
54
|
+
pass
|
|
55
|
+
else:
|
|
56
|
+
with os.fdopen(fd, "w") as output:
|
|
57
|
+
output.write(secrets.token_hex(32) + "\n")
|
|
58
|
+
token = path.read_text().strip()
|
|
59
|
+
if not 16 <= len(token) <= 512 or any(c.isspace() for c in token):
|
|
60
|
+
raise AppError("invalid_api_token", f"Invalid API token in {path}", 424)
|
|
61
|
+
path.chmod(0o600)
|
|
62
|
+
return token
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_runtime(directory):
|
|
66
|
+
try:
|
|
67
|
+
value = json.loads((directory / "native-runtime.json").read_text())
|
|
68
|
+
app = Path(value["app"])
|
|
69
|
+
library = value["supportLibrary"]
|
|
70
|
+
if not app.is_absolute() or not isinstance(library, str):
|
|
71
|
+
raise ValueError()
|
|
72
|
+
if (
|
|
73
|
+
Path(library).name != library
|
|
74
|
+
or not library.startswith("native-reader-")
|
|
75
|
+
or not library.endswith(".dylib")
|
|
76
|
+
):
|
|
77
|
+
raise ValueError()
|
|
78
|
+
if not (app / "Contents/Frameworks" / library).is_file():
|
|
79
|
+
raise ValueError()
|
|
80
|
+
return {"app": str(app.resolve()), "supportLibrary": library}
|
|
81
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
82
|
+
raise AppError(
|
|
83
|
+
"native_setup_required", "Run wechat-cli setup --app /path/to/patched/WeChat.app", 424
|
|
84
|
+
) from None
|
wechat_cli/controls.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""macOS Accessibility operations scoped to the configured WeChat process."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import unicodedata
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .config import ASSETS, load_runtime, private_directory
|
|
15
|
+
from .errors import AppError
|
|
16
|
+
from .media import decode_upload
|
|
17
|
+
from .setup import execute
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Controls:
|
|
21
|
+
def __init__(self, directory):
|
|
22
|
+
self.directory = directory
|
|
23
|
+
self.lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
def invoke(self, action, **extra):
|
|
26
|
+
runtime = load_runtime(self.directory)
|
|
27
|
+
source = ASSETS / "controls.swift"
|
|
28
|
+
digest = hashlib.sha256(source.read_bytes()).hexdigest()[:16]
|
|
29
|
+
binary = self.directory / f"native-controls-{digest}"
|
|
30
|
+
if not binary.exists():
|
|
31
|
+
temporary = binary.with_suffix(f".{os.getpid()}.tmp")
|
|
32
|
+
try:
|
|
33
|
+
execute(
|
|
34
|
+
[
|
|
35
|
+
"/usr/bin/swiftc",
|
|
36
|
+
"-module-cache-path",
|
|
37
|
+
self.directory / "swift-cache",
|
|
38
|
+
source,
|
|
39
|
+
"-o",
|
|
40
|
+
temporary,
|
|
41
|
+
]
|
|
42
|
+
)
|
|
43
|
+
os.replace(temporary, binary)
|
|
44
|
+
finally:
|
|
45
|
+
temporary.unlink(missing_ok=True)
|
|
46
|
+
processes = execute(["/bin/ps", "-axo", "pid=,comm="], timeout=5).decode()
|
|
47
|
+
expected = Path(runtime["app"]) / "Contents/MacOS/WeChat"
|
|
48
|
+
matches = matching_processes(processes, expected)
|
|
49
|
+
if len(matches) != 1:
|
|
50
|
+
raise AppError("native_process_identity_mismatch", "Configured WeChat is not running", 409)
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
[str(binary)],
|
|
54
|
+
input=json.dumps({"pid": int(matches[0]), "app": runtime["app"], "action": action, **extra}),
|
|
55
|
+
text=True,
|
|
56
|
+
capture_output=True,
|
|
57
|
+
timeout=20,
|
|
58
|
+
check=True,
|
|
59
|
+
)
|
|
60
|
+
value = json.loads(result.stdout)
|
|
61
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
62
|
+
raise AppError(
|
|
63
|
+
"native_control_failed", "Native control failed; inspect WeChat before retrying", 502
|
|
64
|
+
) from None
|
|
65
|
+
if value.get("error"):
|
|
66
|
+
code = value["error"]
|
|
67
|
+
message = (
|
|
68
|
+
"Allow Accessibility for the terminal running wechat-cli in macOS System Settings."
|
|
69
|
+
if code == "native_accessibility_required"
|
|
70
|
+
else code.replace("_", " ")
|
|
71
|
+
)
|
|
72
|
+
error = AppError(code, message, 409)
|
|
73
|
+
error.possible_send = value.get("possibleSend", True)
|
|
74
|
+
raise error
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def chat_name(native, chat_id):
|
|
79
|
+
chats = native.chats()["items"]
|
|
80
|
+
chat = next((chat for chat in chats if chat["id"] == chat_id), None)
|
|
81
|
+
name = chat.get("displayName") or chat.get("displayString") if chat else None
|
|
82
|
+
if not name or sum((c.get("displayName") or c.get("displayString")) == name for c in chats) != 1:
|
|
83
|
+
raise AppError("native_chat_name_ambiguous", "This operation requires a uniquely named chat", 409)
|
|
84
|
+
return name
|
|
85
|
+
|
|
86
|
+
def download_attachment(self, native, chat_id, identifier):
|
|
87
|
+
if not self.lock.acquire(blocking=False):
|
|
88
|
+
raise AppError("native_control_busy", "Another control action is running", 409)
|
|
89
|
+
try:
|
|
90
|
+
name = self.chat_name(native, chat_id)
|
|
91
|
+
from .media import find_message
|
|
92
|
+
|
|
93
|
+
message = find_message(native, chat_id, identifier)
|
|
94
|
+
if message["kind"] != "file":
|
|
95
|
+
raise AppError("invalid_media_type", "Choose a file attachment")
|
|
96
|
+
state = self.invoke("status")
|
|
97
|
+
if state.get("chatName") != name or state.get("fileUiReady") is False:
|
|
98
|
+
raise AppError(
|
|
99
|
+
"native_active_chat_mismatch", "Open the requested chat and close other dialogs", 409
|
|
100
|
+
)
|
|
101
|
+
self.invoke("attachment.download", chatName=name, fileName=message["details"]["fileName"])
|
|
102
|
+
for _ in range(40):
|
|
103
|
+
result = native.request("attachmentData", {"chatId": chat_id, "id": identifier})
|
|
104
|
+
if len(result.get("files", [])) == 1:
|
|
105
|
+
return {"source": "native", "chatId": chat_id, "id": identifier, "status": "available"}
|
|
106
|
+
self.invoke("attachment.status", fileName=message["details"]["fileName"])
|
|
107
|
+
time.sleep(0.5)
|
|
108
|
+
return {"source": "native", "chatId": chat_id, "id": identifier, "status": "pending"}
|
|
109
|
+
finally:
|
|
110
|
+
self.lock.release()
|
|
111
|
+
|
|
112
|
+
def send_file(self, native, input):
|
|
113
|
+
if set(input) != {"chatId", "name", "dataBase64"}:
|
|
114
|
+
raise AppError("invalid_input", "Use chatId, name and dataBase64")
|
|
115
|
+
name = input["name"]
|
|
116
|
+
if (
|
|
117
|
+
not isinstance(name, str)
|
|
118
|
+
or not name.strip()
|
|
119
|
+
or name != name.strip()
|
|
120
|
+
or len(name.encode()) > 200
|
|
121
|
+
or re.search(r"[\\/\x00-\x1f\x7f]", name)
|
|
122
|
+
or name in {".", ".."}
|
|
123
|
+
):
|
|
124
|
+
raise AppError("invalid_input", "Use a single filename of at most 200 UTF-8 bytes")
|
|
125
|
+
data = decode_upload(input["dataBase64"])
|
|
126
|
+
if not self.lock.acquire(blocking=False):
|
|
127
|
+
raise AppError("native_control_busy", "Another control action is running", 409)
|
|
128
|
+
invoked, directory = False, None
|
|
129
|
+
try:
|
|
130
|
+
chat_id = input["chatId"]
|
|
131
|
+
chat_name = self.chat_name(native, chat_id)
|
|
132
|
+
state = self.invoke("status")
|
|
133
|
+
if state.get("chatName") != chat_name or state.get("fileUiReady") is False:
|
|
134
|
+
raise AppError(
|
|
135
|
+
"native_active_chat_mismatch", "Open the requested chat and close other dialogs", 409
|
|
136
|
+
)
|
|
137
|
+
if state.get("draft"):
|
|
138
|
+
raise AppError("native_draft_not_empty", "Clear or save the existing WeChat draft first", 409)
|
|
139
|
+
self_id = native.request("selfId")["id"]
|
|
140
|
+
before = {m["id"] for m in native.messages(chat_id, 100)["items"]}
|
|
141
|
+
root = private_directory(self.directory / "outgoing")
|
|
142
|
+
retained = []
|
|
143
|
+
for path in root.glob("file-*"):
|
|
144
|
+
if path.is_dir() and not path.is_symlink():
|
|
145
|
+
if time.time() - path.stat().st_mtime > 600:
|
|
146
|
+
import shutil
|
|
147
|
+
|
|
148
|
+
shutil.rmtree(path)
|
|
149
|
+
else:
|
|
150
|
+
retained.append(path)
|
|
151
|
+
if len(retained) >= 16:
|
|
152
|
+
raise AppError("native_file_staging_full", "Wait for retained uploads to expire", 429)
|
|
153
|
+
directory = Path(tempfile.mkdtemp(prefix="file-", dir=root))
|
|
154
|
+
path = directory / name
|
|
155
|
+
path.write_bytes(data)
|
|
156
|
+
path.chmod(0o600)
|
|
157
|
+
invoked = True
|
|
158
|
+
try:
|
|
159
|
+
self.invoke("file.send", chatName=chat_name, filePath=str(path), fileName=name)
|
|
160
|
+
except AppError as error:
|
|
161
|
+
if getattr(error, "possible_send", True) is False:
|
|
162
|
+
invoked = False
|
|
163
|
+
raise
|
|
164
|
+
deadline, accepted = time.monotonic() + 45, None
|
|
165
|
+
while time.monotonic() < deadline:
|
|
166
|
+
matches = [
|
|
167
|
+
m
|
|
168
|
+
for m in native.messages(chat_id, 100)["items"]
|
|
169
|
+
if m["id"] not in before
|
|
170
|
+
and m.get("from") == self_id
|
|
171
|
+
and m.get("to") == chat_id
|
|
172
|
+
and m.get("kind") == "file"
|
|
173
|
+
and m["details"].get("sizeBytes") == len(data)
|
|
174
|
+
and unicodedata.normalize("NFC", m["details"].get("fileName", ""))
|
|
175
|
+
== unicodedata.normalize("NFC", name)
|
|
176
|
+
]
|
|
177
|
+
if len(matches) > 1:
|
|
178
|
+
raise AppError(
|
|
179
|
+
"native_send_outcome_unknown", "Multiple matching file messages observed", 502
|
|
180
|
+
)
|
|
181
|
+
if matches:
|
|
182
|
+
message = matches[0]
|
|
183
|
+
accepted = {
|
|
184
|
+
"source": "native",
|
|
185
|
+
"chatId": chat_id,
|
|
186
|
+
"requestId": message["id"],
|
|
187
|
+
"messageId": message["id"],
|
|
188
|
+
"name": name,
|
|
189
|
+
"size": len(data),
|
|
190
|
+
"acceptedByClient": True,
|
|
191
|
+
"acknowledgedByServer": bool(re.fullmatch(r"[1-9]\d*", message.get("serverId", ""))),
|
|
192
|
+
}
|
|
193
|
+
if accepted["acknowledgedByServer"]:
|
|
194
|
+
return accepted
|
|
195
|
+
time.sleep(0.25)
|
|
196
|
+
if accepted:
|
|
197
|
+
return accepted
|
|
198
|
+
raise AppError(
|
|
199
|
+
"native_send_outcome_unknown", "File selection ran; check WeChat history before retrying", 504
|
|
200
|
+
)
|
|
201
|
+
except Exception as error:
|
|
202
|
+
if invoked and not (isinstance(error, AppError) and error.code == "native_send_outcome_unknown"):
|
|
203
|
+
raise AppError(
|
|
204
|
+
"native_send_outcome_unknown", "File send outcome is unknown; inspect WeChat", 502
|
|
205
|
+
) from None
|
|
206
|
+
raise
|
|
207
|
+
finally:
|
|
208
|
+
if directory and not invoked:
|
|
209
|
+
import shutil
|
|
210
|
+
|
|
211
|
+
shutil.rmtree(directory)
|
|
212
|
+
self.lock.release()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def matching_processes(snapshot, expected):
|
|
216
|
+
expected = expected.resolve()
|
|
217
|
+
matches = []
|
|
218
|
+
for line in snapshot.splitlines():
|
|
219
|
+
fields = line.strip().split(None, 1)
|
|
220
|
+
if len(fields) == 2 and fields[0].isdigit() and fields[1].endswith("/Contents/MacOS/WeChat"):
|
|
221
|
+
if Path(fields[1]).resolve() == expected:
|
|
222
|
+
matches.append(fields[0])
|
|
223
|
+
return matches
|
wechat_cli/errors.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Durable send deduplication, including uncertain results after a server restart."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .config import private_directory
|
|
10
|
+
from .errors import AppError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Idempotency:
|
|
14
|
+
def __init__(self, directory):
|
|
15
|
+
private_directory(directory)
|
|
16
|
+
path = directory / "requests.sqlite3"
|
|
17
|
+
self.connection = sqlite3.connect(path, check_same_thread=False)
|
|
18
|
+
path.chmod(0o600)
|
|
19
|
+
self.lock = threading.Lock()
|
|
20
|
+
self.connection.execute(
|
|
21
|
+
"CREATE TABLE IF NOT EXISTS requests "
|
|
22
|
+
"(route TEXT, key TEXT, digest TEXT, created REAL, status INTEGER, response TEXT, "
|
|
23
|
+
"PRIMARY KEY(route,key))"
|
|
24
|
+
)
|
|
25
|
+
self.connection.commit()
|
|
26
|
+
|
|
27
|
+
def once(self, route, key, payload, action):
|
|
28
|
+
signature = hashlib.sha256(
|
|
29
|
+
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
30
|
+
).hexdigest()
|
|
31
|
+
# Serialize mutations through completion; concurrent retries see the same result.
|
|
32
|
+
with self.lock:
|
|
33
|
+
db = self.connection
|
|
34
|
+
db.execute("DELETE FROM requests WHERE created < ?", (time.time() - 86400,))
|
|
35
|
+
old = db.execute(
|
|
36
|
+
"SELECT digest,status,response FROM requests WHERE route=? AND key=?", (route, key)
|
|
37
|
+
).fetchone()
|
|
38
|
+
if old:
|
|
39
|
+
if old[0] != signature:
|
|
40
|
+
raise AppError("idempotency_conflict", "This key was used with a different request", 409)
|
|
41
|
+
if old[2] is None:
|
|
42
|
+
raise AppError(
|
|
43
|
+
"native_send_outcome_unknown",
|
|
44
|
+
"An earlier attempt was interrupted. Inspect WeChat; do not resend with another key.",
|
|
45
|
+
409,
|
|
46
|
+
)
|
|
47
|
+
response = json.loads(old[2])
|
|
48
|
+
if old[1] >= 400:
|
|
49
|
+
raise AppError(response["error"], response["message"], old[1])
|
|
50
|
+
return response
|
|
51
|
+
if db.execute("SELECT COUNT(*) FROM requests").fetchone()[0] >= 10000:
|
|
52
|
+
raise AppError("idempotency_capacity", "Request retention capacity reached", 503)
|
|
53
|
+
db.execute(
|
|
54
|
+
"INSERT INTO requests VALUES (?,?,?,?,NULL,NULL)", (route, key, signature, time.time())
|
|
55
|
+
)
|
|
56
|
+
db.commit() # Commit intent before a native operation can enqueue anything.
|
|
57
|
+
try:
|
|
58
|
+
response = action()
|
|
59
|
+
except AppError as error:
|
|
60
|
+
db.execute(
|
|
61
|
+
"UPDATE requests SET status=?,response=? WHERE route=? AND key=?",
|
|
62
|
+
(error.status, json.dumps(error.as_dict()), route, key),
|
|
63
|
+
)
|
|
64
|
+
db.commit()
|
|
65
|
+
raise
|
|
66
|
+
except Exception:
|
|
67
|
+
# Leave the durable intent unresolved: retry must never send again.
|
|
68
|
+
raise AppError(
|
|
69
|
+
"native_send_outcome_unknown",
|
|
70
|
+
"Operation was interrupted; inspect WeChat before retrying",
|
|
71
|
+
502,
|
|
72
|
+
) from None
|
|
73
|
+
db.execute(
|
|
74
|
+
"UPDATE requests SET status=200,response=? WHERE route=? AND key=?",
|
|
75
|
+
(json.dumps(response), route, key),
|
|
76
|
+
)
|
|
77
|
+
db.commit()
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
def close(self):
|
|
81
|
+
with self.lock:
|
|
82
|
+
self.connection.close()
|
wechat_cli/lifecycle.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""One persistent Python service per data directory; no PID-based killing."""
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
|
|
13
|
+
from .client import ApiClient
|
|
14
|
+
from .config import private_directory, save_json
|
|
15
|
+
from .errors import AppError
|
|
16
|
+
from .native import NativeBridge
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def lock_file(path, blocking=True):
|
|
21
|
+
private_directory(path.parent)
|
|
22
|
+
with path.open("a+") as handle:
|
|
23
|
+
path.chmod(0o600)
|
|
24
|
+
try:
|
|
25
|
+
fcntl.flock(handle, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
|
|
26
|
+
except BlockingIOError:
|
|
27
|
+
raise AppError(
|
|
28
|
+
"server_already_running", "A service is already running for this data directory", 409
|
|
29
|
+
) from None
|
|
30
|
+
try:
|
|
31
|
+
yield
|
|
32
|
+
finally:
|
|
33
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def configured_port(directory, explicit=None):
|
|
37
|
+
if explicit is not None:
|
|
38
|
+
return explicit
|
|
39
|
+
try:
|
|
40
|
+
value = json.loads((directory / "server.json").read_text())
|
|
41
|
+
return int(value["port"])
|
|
42
|
+
except (OSError, ValueError, KeyError):
|
|
43
|
+
return 8787
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def health(directory, port):
|
|
47
|
+
value = ApiClient(directory, port, timeout=1).request("/api/health")
|
|
48
|
+
if value.get("service") != "wechat-cli":
|
|
49
|
+
raise AppError("port_in_use", "Port belongs to another service", 409)
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_server(directory, port, timeout=75):
|
|
54
|
+
with lock_file(directory / "launch.lock"):
|
|
55
|
+
try:
|
|
56
|
+
return health(directory, port)
|
|
57
|
+
except AppError as error:
|
|
58
|
+
if error.code != "api_unavailable":
|
|
59
|
+
raise
|
|
60
|
+
# Wait for a shutting-down service to finish releasing its worker and socket.
|
|
61
|
+
with lock_file(directory / "server.lock"):
|
|
62
|
+
pass
|
|
63
|
+
# Validate before spawning so configuration errors are immediately actionable.
|
|
64
|
+
from .config import load_runtime
|
|
65
|
+
|
|
66
|
+
load_runtime(directory)
|
|
67
|
+
log = directory / "server.log"
|
|
68
|
+
fd = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
69
|
+
with os.fdopen(fd, "w") as output:
|
|
70
|
+
child = subprocess.Popen(
|
|
71
|
+
[
|
|
72
|
+
sys.executable,
|
|
73
|
+
"-m",
|
|
74
|
+
"wechat_cli",
|
|
75
|
+
"serve",
|
|
76
|
+
"--data-dir",
|
|
77
|
+
str(directory),
|
|
78
|
+
"--port",
|
|
79
|
+
str(port),
|
|
80
|
+
],
|
|
81
|
+
stdin=subprocess.DEVNULL,
|
|
82
|
+
stdout=output,
|
|
83
|
+
stderr=output,
|
|
84
|
+
start_new_session=True,
|
|
85
|
+
)
|
|
86
|
+
deadline = time.monotonic() + timeout
|
|
87
|
+
while time.monotonic() < deadline:
|
|
88
|
+
if child.poll() is not None:
|
|
89
|
+
try:
|
|
90
|
+
error = json.loads(log.read_text().splitlines()[-1])
|
|
91
|
+
raise AppError(error["error"], error["message"], 424)
|
|
92
|
+
except (ValueError, KeyError, IndexError):
|
|
93
|
+
raise AppError(
|
|
94
|
+
"server_start_failed", f"Service failed to start; inspect {log}", 424
|
|
95
|
+
) from None
|
|
96
|
+
try:
|
|
97
|
+
return health(directory, port)
|
|
98
|
+
except AppError as error:
|
|
99
|
+
if error.code != "api_unavailable":
|
|
100
|
+
raise
|
|
101
|
+
time.sleep(0.1)
|
|
102
|
+
# This is our own child, never a process selected from a stale PID file.
|
|
103
|
+
child.terminate()
|
|
104
|
+
raise AppError("server_start_timeout", f"Service startup timed out; inspect {log}", 504)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def serve(directory, port):
|
|
108
|
+
from .server import ApiServer
|
|
109
|
+
|
|
110
|
+
with lock_file(directory / "server.lock", blocking=False):
|
|
111
|
+
native = NativeBridge(directory)
|
|
112
|
+
server = None
|
|
113
|
+
try:
|
|
114
|
+
# Connect eagerly: start really launches WeChat, rather than waiting for the first API call.
|
|
115
|
+
native.connect()
|
|
116
|
+
server = ApiServer(("127.0.0.1", port), directory, native=native)
|
|
117
|
+
save_json(
|
|
118
|
+
directory / "server.json",
|
|
119
|
+
{"port": server.server_address[1], "instance": server.instance, "pid": os.getpid()},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def stop(_signal, _frame):
|
|
123
|
+
threading.Thread(target=server.shutdown, daemon=True).start()
|
|
124
|
+
|
|
125
|
+
previous = {sig: signal.signal(sig, stop) for sig in [signal.SIGINT, signal.SIGTERM]}
|
|
126
|
+
print(json.dumps({"listening": f"http://127.0.0.1:{server.server_address[1]}"}), flush=True)
|
|
127
|
+
try:
|
|
128
|
+
server.serve_forever(poll_interval=0.1)
|
|
129
|
+
finally:
|
|
130
|
+
for sig, handler in previous.items():
|
|
131
|
+
signal.signal(sig, handler)
|
|
132
|
+
except OSError as error:
|
|
133
|
+
raise AppError(
|
|
134
|
+
"server_bind_failed", f"Cannot bind 127.0.0.1:{port}; choose another --port", 409
|
|
135
|
+
) from error
|
|
136
|
+
finally:
|
|
137
|
+
if server:
|
|
138
|
+
server.server_close()
|
|
139
|
+
else:
|
|
140
|
+
native.close()
|
|
141
|
+
(directory / "server.json").unlink(missing_ok=True)
|