@follenfang/fupload 0.0.0-bootstrap.0 → 0.0.2
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.
- package/README.md +236 -3
- package/fupload/SKILL.md +142 -0
- package/fupload/agents/openai.yaml +4 -0
- package/fupload/examples/curseforge-plugin-upload.json +21 -0
- package/fupload/examples/dd-config-delete.json +5 -0
- package/fupload/examples/dd-config-update.json +25 -0
- package/fupload/examples/dd-plugin-delete.json +5 -0
- package/fupload/examples/dd-plugin-update.json +9 -0
- package/fupload/examples/dd-wa-delete.json +5 -0
- package/fupload/examples/dd-wa-edit.json +9 -0
- package/fupload/examples/newbee-config-delete.json +5 -0
- package/fupload/examples/newbee-config-update.json +10 -0
- package/fupload/examples/newbee-plugin-create.json +14 -0
- package/fupload/examples/newbee-plugin-delete.json +5 -0
- package/fupload/examples/newbee-wa-delete.json +5 -0
- package/fupload/examples/newbee-wa-update.json +8 -0
- package/fupload/references/curseforge.md +233 -0
- package/fupload/references/dd.md +105 -0
- package/fupload/references/newbee-official-cli.md +288 -0
- package/fupload/references/newbee.md +80 -0
- package/fupload/references/workflow.md +67 -0
- package/fupload/scripts/fupload.py +17 -0
- package/fupload/scripts/fupload_cli/__init__.py +3 -0
- package/fupload/scripts/fupload_cli/cli.py +281 -0
- package/fupload/scripts/fupload_cli/curseforge.py +186 -0
- package/fupload/scripts/fupload_cli/dd.py +2406 -0
- package/fupload/scripts/fupload_cli/dd_broker.py +634 -0
- package/fupload/scripts/fupload_cli/dd_sidecar.py +860 -0
- package/fupload/scripts/fupload_cli/errors.py +94 -0
- package/fupload/scripts/fupload_cli/io.py +125 -0
- package/fupload/scripts/fupload_cli/newbee.py +1412 -0
- package/fupload/scripts/fupload_cli/newbee_auth.py +135 -0
- package/fupload/scripts/fupload_cli/schema.py +587 -0
- package/fupload/scripts/fupload_cli/transport.py +125 -0
- package/fupload/scripts/fupload_cli/trust.py +207 -0
- package/npm/bin/fupload.mjs +92 -0
- package/npm/lib/curseforge-config.mjs +36 -0
- package/npm/lib/managed-install.mjs +86 -0
- package/npm/lib/options.mjs +38 -0
- package/npm/lib/python.mjs +45 -0
- package/npm/lib/skill-installer.mjs +228 -0
- package/npm/lib/uninstall.mjs +211 -0
- package/npm/lib/update.mjs +102 -0
- package/npm/lib/versions.mjs +63 -0
- package/npm/postinstall.mjs +21 -0
- package/npm/skill-manifest.json +179 -0
- package/package.json +50 -6
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
"""Task-scoped DD broker owning one native sidecar login."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import ctypes
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from .errors import FuploadError, redact
|
|
19
|
+
from .trust import verify_dd_executable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
STATE_NAME = "broker.json"
|
|
23
|
+
STARTUP_NAME = "broker.starting.json"
|
|
24
|
+
MAX_REQUEST_BYTES = 64 * 1024 * 1024
|
|
25
|
+
IDLE_SECONDS = 10 * 60
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _dd_module():
|
|
29
|
+
from . import dd
|
|
30
|
+
|
|
31
|
+
return dd
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _state_dir() -> Path:
|
|
35
|
+
return _dd_module().state_dir()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _atomic_json(path: Path, value: Dict[str, Any]) -> None:
|
|
39
|
+
temporary = path.with_name(path.name + ".tmp.%d" % os.getpid())
|
|
40
|
+
temporary.write_text(json.dumps(value, ensure_ascii=False, sort_keys=True), encoding="utf-8")
|
|
41
|
+
os.replace(str(temporary), str(path))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_json(path: Path) -> Dict[str, Any]:
|
|
45
|
+
try:
|
|
46
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
47
|
+
except (OSError, ValueError) as exc:
|
|
48
|
+
raise FuploadError("DD broker state is unreadable", kind="session_error", stage="session") from exc
|
|
49
|
+
if not isinstance(value, dict):
|
|
50
|
+
raise FuploadError("DD broker state is invalid", kind="session_error", stage="session")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _pid_running(pid: int) -> bool:
|
|
55
|
+
if os.name != "nt" or pid <= 0:
|
|
56
|
+
return False
|
|
57
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
58
|
+
kernel32.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_bool, ctypes.c_ulong]
|
|
59
|
+
kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
60
|
+
kernel32.GetExitCodeProcess.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
|
|
61
|
+
kernel32.GetExitCodeProcess.restype = ctypes.c_bool
|
|
62
|
+
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
|
63
|
+
kernel32.CloseHandle.restype = ctypes.c_bool
|
|
64
|
+
handle = kernel32.OpenProcess(0x1000, False, pid)
|
|
65
|
+
if not handle:
|
|
66
|
+
return False
|
|
67
|
+
try:
|
|
68
|
+
code = ctypes.c_ulong()
|
|
69
|
+
return bool(kernel32.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259
|
|
70
|
+
finally:
|
|
71
|
+
kernel32.CloseHandle(handle)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _top_level_windows(pid: int) -> List[int]:
|
|
75
|
+
if os.name != "nt":
|
|
76
|
+
return []
|
|
77
|
+
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
78
|
+
user32.GetWindowThreadProcessId.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
|
|
79
|
+
user32.GetWindowThreadProcessId.restype = ctypes.c_ulong
|
|
80
|
+
windows: List[int] = []
|
|
81
|
+
callback_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
|
|
82
|
+
|
|
83
|
+
def visit(hwnd: int, _param: int) -> bool:
|
|
84
|
+
owner = ctypes.c_ulong()
|
|
85
|
+
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner))
|
|
86
|
+
if owner.value == pid:
|
|
87
|
+
windows.append(int(hwnd))
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
callback = callback_type(visit)
|
|
91
|
+
user32.EnumWindows.argtypes = [callback_type, ctypes.c_void_p]
|
|
92
|
+
user32.EnumWindows.restype = ctypes.c_bool
|
|
93
|
+
user32.EnumWindows(callback, 0)
|
|
94
|
+
return windows
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def running_dd_processes() -> List[Dict[str, Any]]:
|
|
98
|
+
if os.name != "nt":
|
|
99
|
+
return []
|
|
100
|
+
from ctypes import wintypes
|
|
101
|
+
|
|
102
|
+
class ProcessEntry(ctypes.Structure):
|
|
103
|
+
_fields_ = [
|
|
104
|
+
("dwSize", wintypes.DWORD),
|
|
105
|
+
("cntUsage", wintypes.DWORD),
|
|
106
|
+
("th32ProcessID", wintypes.DWORD),
|
|
107
|
+
("th32DefaultHeapID", ctypes.c_size_t),
|
|
108
|
+
("th32ModuleID", wintypes.DWORD),
|
|
109
|
+
("cntThreads", wintypes.DWORD),
|
|
110
|
+
("th32ParentProcessID", wintypes.DWORD),
|
|
111
|
+
("pcPriClassBase", ctypes.c_long),
|
|
112
|
+
("dwFlags", wintypes.DWORD),
|
|
113
|
+
("szExeFile", ctypes.c_wchar * 260),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
117
|
+
kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
|
|
118
|
+
kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
|
|
119
|
+
kernel32.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry)]
|
|
120
|
+
kernel32.Process32FirstW.restype = wintypes.BOOL
|
|
121
|
+
kernel32.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry)]
|
|
122
|
+
kernel32.Process32NextW.restype = wintypes.BOOL
|
|
123
|
+
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
124
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
125
|
+
kernel32.QueryFullProcessImageNameW.argtypes = [
|
|
126
|
+
wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD),
|
|
127
|
+
]
|
|
128
|
+
kernel32.QueryFullProcessImageNameW.restype = wintypes.BOOL
|
|
129
|
+
kernel32.GetProcessTimes.argtypes = [
|
|
130
|
+
wintypes.HANDLE, ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME),
|
|
131
|
+
ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME),
|
|
132
|
+
]
|
|
133
|
+
kernel32.GetProcessTimes.restype = wintypes.BOOL
|
|
134
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
135
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
136
|
+
|
|
137
|
+
snapshot = kernel32.CreateToolhelp32Snapshot(0x00000002, 0)
|
|
138
|
+
if snapshot == wintypes.HANDLE(-1).value:
|
|
139
|
+
return []
|
|
140
|
+
processes: List[Dict[str, Any]] = []
|
|
141
|
+
entry = ProcessEntry()
|
|
142
|
+
entry.dwSize = ctypes.sizeof(entry)
|
|
143
|
+
try:
|
|
144
|
+
more = bool(kernel32.Process32FirstW(snapshot, ctypes.byref(entry)))
|
|
145
|
+
while more:
|
|
146
|
+
if entry.szExeFile.casefold() == "netease_dd.exe":
|
|
147
|
+
pid = int(entry.th32ProcessID)
|
|
148
|
+
handle = kernel32.OpenProcess(0x1000, False, pid)
|
|
149
|
+
if handle:
|
|
150
|
+
try:
|
|
151
|
+
size = wintypes.DWORD(32768)
|
|
152
|
+
buffer = ctypes.create_unicode_buffer(size.value)
|
|
153
|
+
creation = wintypes.FILETIME()
|
|
154
|
+
exit_time = wintypes.FILETIME()
|
|
155
|
+
kernel = wintypes.FILETIME()
|
|
156
|
+
user = wintypes.FILETIME()
|
|
157
|
+
if kernel32.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(size)):
|
|
158
|
+
started = 0
|
|
159
|
+
if kernel32.GetProcessTimes(
|
|
160
|
+
handle,
|
|
161
|
+
ctypes.byref(creation),
|
|
162
|
+
ctypes.byref(exit_time),
|
|
163
|
+
ctypes.byref(kernel),
|
|
164
|
+
ctypes.byref(user),
|
|
165
|
+
):
|
|
166
|
+
started = (int(creation.dwHighDateTime) << 32) | int(creation.dwLowDateTime)
|
|
167
|
+
executable = Path(buffer.value).resolve()
|
|
168
|
+
signature: Optional[Dict[str, str]] = None
|
|
169
|
+
signature_error = ""
|
|
170
|
+
try:
|
|
171
|
+
signature = verify_dd_executable(executable)
|
|
172
|
+
except FuploadError as exc:
|
|
173
|
+
signature_error = str(exc)
|
|
174
|
+
process = {
|
|
175
|
+
"pid": pid,
|
|
176
|
+
"started": started,
|
|
177
|
+
"executable": str(executable),
|
|
178
|
+
"dd_dir": str(executable.parent),
|
|
179
|
+
"windows": _top_level_windows(pid),
|
|
180
|
+
"signature": signature,
|
|
181
|
+
"signature_error": signature_error,
|
|
182
|
+
}
|
|
183
|
+
if process["windows"]:
|
|
184
|
+
processes.append(process)
|
|
185
|
+
finally:
|
|
186
|
+
kernel32.CloseHandle(handle)
|
|
187
|
+
more = bool(kernel32.Process32NextW(snapshot, ctypes.byref(entry)))
|
|
188
|
+
finally:
|
|
189
|
+
kernel32.CloseHandle(snapshot)
|
|
190
|
+
return processes
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _same_process(expected: Dict[str, Any], actual: Dict[str, Any]) -> bool:
|
|
194
|
+
return (
|
|
195
|
+
int(expected.get("pid") or 0) == int(actual.get("pid") or 0)
|
|
196
|
+
and int(expected.get("started") or 0) == int(actual.get("started") or 0)
|
|
197
|
+
and os.path.normcase(str(expected.get("executable") or ""))
|
|
198
|
+
== os.path.normcase(str(actual.get("executable") or ""))
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _request_normal_close(processes: List[Dict[str, Any]]) -> None:
|
|
203
|
+
if os.name != "nt":
|
|
204
|
+
return
|
|
205
|
+
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
206
|
+
user32.PostMessageW.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p]
|
|
207
|
+
user32.PostMessageW.restype = ctypes.c_bool
|
|
208
|
+
for process in processes:
|
|
209
|
+
for hwnd in process.get("windows") or []:
|
|
210
|
+
user32.PostMessageW(int(hwnd), 0x0010, 0, 0)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _terminate_process(process: Dict[str, Any]) -> None:
|
|
214
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
215
|
+
kernel32.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_bool, ctypes.c_ulong]
|
|
216
|
+
kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
217
|
+
kernel32.TerminateProcess.argtypes = [ctypes.c_void_p, ctypes.c_uint]
|
|
218
|
+
kernel32.TerminateProcess.restype = ctypes.c_bool
|
|
219
|
+
kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
|
220
|
+
kernel32.WaitForSingleObject.restype = ctypes.c_ulong
|
|
221
|
+
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
|
222
|
+
kernel32.CloseHandle.restype = ctypes.c_bool
|
|
223
|
+
handle = kernel32.OpenProcess(0x0001 | 0x00100000, False, int(process["pid"]))
|
|
224
|
+
if not handle:
|
|
225
|
+
raise FuploadError("verified DD GUI process could not be opened for termination", kind="gui_close_failed", stage="session")
|
|
226
|
+
try:
|
|
227
|
+
if not kernel32.TerminateProcess(handle, 1):
|
|
228
|
+
raise FuploadError("verified DD GUI process could not be terminated", kind="gui_close_failed", stage="session")
|
|
229
|
+
kernel32.WaitForSingleObject(handle, 5000)
|
|
230
|
+
finally:
|
|
231
|
+
kernel32.CloseHandle(handle)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def close_verified_gui(processes: List[Dict[str, Any]]) -> None:
|
|
235
|
+
if any(not process.get("signature") for process in processes):
|
|
236
|
+
raise FuploadError("an untrusted netease_dd.exe process is running", kind="trust_boundary", stage="session")
|
|
237
|
+
if not processes:
|
|
238
|
+
return
|
|
239
|
+
_request_normal_close(processes)
|
|
240
|
+
deadline = time.time() + 5
|
|
241
|
+
while time.time() < deadline:
|
|
242
|
+
live = running_dd_processes()
|
|
243
|
+
if not any(any(_same_process(expected, current) for current in live) for expected in processes):
|
|
244
|
+
break
|
|
245
|
+
time.sleep(0.1)
|
|
246
|
+
live = running_dd_processes()
|
|
247
|
+
original_pids = {int(process["pid"]) for process in processes}
|
|
248
|
+
if any(int(process["pid"]) not in original_pids for process in live):
|
|
249
|
+
raise FuploadError("a new DD GUI process appeared while closing the confirmed instances", kind="gui_identity_changed", stage="session")
|
|
250
|
+
for expected in processes:
|
|
251
|
+
matches = [current for current in live if int(current["pid"]) == int(expected["pid"])]
|
|
252
|
+
if not matches:
|
|
253
|
+
continue
|
|
254
|
+
current = matches[0]
|
|
255
|
+
if not _same_process(expected, current) or not current.get("signature"):
|
|
256
|
+
raise FuploadError("DD GUI process identity changed while closing", kind="gui_identity_changed", stage="session")
|
|
257
|
+
_terminate_process(current)
|
|
258
|
+
if running_dd_processes():
|
|
259
|
+
raise FuploadError("DD GUI did not fully exit", kind="gui_close_failed", stage="session")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _public_process(process: Dict[str, Any]) -> Dict[str, Any]:
|
|
263
|
+
return {
|
|
264
|
+
"pid": process.get("pid"),
|
|
265
|
+
"dd_dir": process.get("dd_dir"),
|
|
266
|
+
"window_count": len(process.get("windows") or []),
|
|
267
|
+
"signature": process.get("signature"),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _load_live_state() -> Optional[Dict[str, Any]]:
|
|
272
|
+
path = _state_dir() / STATE_NAME
|
|
273
|
+
if not path.exists():
|
|
274
|
+
return None
|
|
275
|
+
value = _read_json(path)
|
|
276
|
+
if not _pid_running(int(value.get("pid") or 0)):
|
|
277
|
+
try:
|
|
278
|
+
path.unlink()
|
|
279
|
+
except OSError:
|
|
280
|
+
pass
|
|
281
|
+
return None
|
|
282
|
+
return value
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def doctor() -> Dict[str, Any]:
|
|
286
|
+
dd_dir, signature = _dd_module().discover_dd_info()
|
|
287
|
+
processes = running_dd_processes()
|
|
288
|
+
state = _load_live_state()
|
|
289
|
+
return {
|
|
290
|
+
"authenticated": False,
|
|
291
|
+
"login_performed": False,
|
|
292
|
+
"dd_dir": str(dd_dir),
|
|
293
|
+
"installation_source": "automatic-discovery",
|
|
294
|
+
"signature": signature,
|
|
295
|
+
"state_directory": str(_state_dir()),
|
|
296
|
+
"state_source": "windows-known-folder",
|
|
297
|
+
"api_origin": "https://uiapi.w.163.com",
|
|
298
|
+
"gui_running": bool(processes),
|
|
299
|
+
"gui_processes": [_public_process(process) for process in processes],
|
|
300
|
+
"untrusted_process_count": sum(1 for process in processes if not process.get("signature")),
|
|
301
|
+
"broker_running": bool(state),
|
|
302
|
+
"session_id": state.get("session_id") if state else None,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _send(value: Dict[str, Any], timeout: float = 300) -> Dict[str, Any]:
|
|
307
|
+
state = _load_live_state()
|
|
308
|
+
if not state:
|
|
309
|
+
raise FuploadError("DD task session is not running", kind="session_not_running", stage="session")
|
|
310
|
+
payload = dict(value)
|
|
311
|
+
request = {
|
|
312
|
+
"session_id": payload.pop("session_id", None),
|
|
313
|
+
"auth_key": state.get("auth_key"),
|
|
314
|
+
**payload,
|
|
315
|
+
}
|
|
316
|
+
if request["session_id"] != state.get("session_id"):
|
|
317
|
+
raise FuploadError("DD session_id does not match the active task session", kind="session_mismatch", stage="session")
|
|
318
|
+
data = (json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
|
|
319
|
+
if len(data) > MAX_REQUEST_BYTES:
|
|
320
|
+
raise FuploadError("DD broker request is too large", kind="request_too_large", stage="session")
|
|
321
|
+
write_sent = False
|
|
322
|
+
try:
|
|
323
|
+
with socket.create_connection(("127.0.0.1", int(state["port"])), timeout=10) as connection:
|
|
324
|
+
connection.settimeout(timeout)
|
|
325
|
+
connection.sendall(data)
|
|
326
|
+
write_sent = request.get("command") == "write"
|
|
327
|
+
received = bytearray()
|
|
328
|
+
while b"\n" not in received:
|
|
329
|
+
chunk = connection.recv(65536)
|
|
330
|
+
if not chunk:
|
|
331
|
+
break
|
|
332
|
+
received.extend(chunk)
|
|
333
|
+
if len(received) > MAX_REQUEST_BYTES:
|
|
334
|
+
raise FuploadError(
|
|
335
|
+
"DD broker response is too large",
|
|
336
|
+
kind="response_too_large",
|
|
337
|
+
stage="session",
|
|
338
|
+
verification_required=write_sent,
|
|
339
|
+
)
|
|
340
|
+
except FuploadError as exc:
|
|
341
|
+
if write_sent and not exc.verification_required:
|
|
342
|
+
raise FuploadError(
|
|
343
|
+
str(exc),
|
|
344
|
+
kind=exc.kind,
|
|
345
|
+
stage=exc.stage or "session",
|
|
346
|
+
endpoint=exc.endpoint,
|
|
347
|
+
http_status=exc.http_status,
|
|
348
|
+
business_code=exc.business_code,
|
|
349
|
+
verification_required=True,
|
|
350
|
+
details=exc.details,
|
|
351
|
+
) from exc
|
|
352
|
+
raise
|
|
353
|
+
except (OSError, ValueError) as exc:
|
|
354
|
+
raise FuploadError(
|
|
355
|
+
"DD broker connection failed",
|
|
356
|
+
kind="session_connection",
|
|
357
|
+
stage="session",
|
|
358
|
+
verification_required=write_sent,
|
|
359
|
+
) from exc
|
|
360
|
+
if b"\n" not in received:
|
|
361
|
+
raise FuploadError(
|
|
362
|
+
"DD broker closed without a complete response",
|
|
363
|
+
kind="session_protocol",
|
|
364
|
+
stage="session",
|
|
365
|
+
verification_required=write_sent,
|
|
366
|
+
)
|
|
367
|
+
try:
|
|
368
|
+
response = json.loads(bytes(received).split(b"\n", 1)[0].decode("utf-8"))
|
|
369
|
+
except (UnicodeError, ValueError) as exc:
|
|
370
|
+
raise FuploadError(
|
|
371
|
+
"DD broker returned invalid JSON",
|
|
372
|
+
kind="session_protocol",
|
|
373
|
+
stage="session",
|
|
374
|
+
verification_required=write_sent,
|
|
375
|
+
) from exc
|
|
376
|
+
if not isinstance(response, dict):
|
|
377
|
+
raise FuploadError("DD broker returned an invalid response", kind="session_protocol", stage="session")
|
|
378
|
+
if not response.get("ok"):
|
|
379
|
+
error = response.get("error") if isinstance(response.get("error"), dict) else {}
|
|
380
|
+
raise FuploadError.from_dict(error)
|
|
381
|
+
return response.get("data")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def start(confirm_close_gui: bool) -> Dict[str, Any]:
|
|
385
|
+
existing = _load_live_state()
|
|
386
|
+
if existing:
|
|
387
|
+
active = status(str(existing["session_id"]))
|
|
388
|
+
return {
|
|
389
|
+
"session_id": existing["session_id"],
|
|
390
|
+
"running": True,
|
|
391
|
+
"reused": True,
|
|
392
|
+
"login_count": active.get("login_count", 1),
|
|
393
|
+
}
|
|
394
|
+
processes = running_dd_processes()
|
|
395
|
+
if processes and not confirm_close_gui:
|
|
396
|
+
raise FuploadError(
|
|
397
|
+
"DD GUI is running; explicit close confirmation is required before native login",
|
|
398
|
+
kind="gui_close_confirmation_required",
|
|
399
|
+
stage="session",
|
|
400
|
+
details={"processes": [_public_process(process) for process in processes]},
|
|
401
|
+
)
|
|
402
|
+
close_verified_gui(processes)
|
|
403
|
+
root = _state_dir()
|
|
404
|
+
startup = root / STARTUP_NAME
|
|
405
|
+
startup_id = uuid.uuid4().hex
|
|
406
|
+
_atomic_json(startup, {"startup_id": startup_id, "auth_key": uuid.uuid4().hex + uuid.uuid4().hex})
|
|
407
|
+
scripts_root = Path(__file__).resolve().parents[1]
|
|
408
|
+
environment = os.environ.copy()
|
|
409
|
+
environment["PYTHONPATH"] = str(scripts_root) + os.pathsep + environment.get("PYTHONPATH", "")
|
|
410
|
+
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
411
|
+
process = subprocess.Popen(
|
|
412
|
+
[sys.executable, "-m", "fupload_cli.dd_broker", "--serve", startup_id],
|
|
413
|
+
cwd=str(scripts_root),
|
|
414
|
+
env=environment,
|
|
415
|
+
stdin=subprocess.DEVNULL,
|
|
416
|
+
stdout=subprocess.DEVNULL,
|
|
417
|
+
stderr=subprocess.DEVNULL,
|
|
418
|
+
creationflags=flags,
|
|
419
|
+
)
|
|
420
|
+
deadline = time.time() + 90
|
|
421
|
+
last_error = ""
|
|
422
|
+
while time.time() < deadline:
|
|
423
|
+
state = _load_live_state()
|
|
424
|
+
if state and state.get("startup_id") == startup_id:
|
|
425
|
+
return {
|
|
426
|
+
"session_id": state["session_id"],
|
|
427
|
+
"running": True,
|
|
428
|
+
"reused": False,
|
|
429
|
+
"login_count": 1,
|
|
430
|
+
"closed_gui_processes": len(processes),
|
|
431
|
+
}
|
|
432
|
+
if startup.exists():
|
|
433
|
+
try:
|
|
434
|
+
pending = _read_json(startup)
|
|
435
|
+
last_error = str(pending.get("error") or "")
|
|
436
|
+
except FuploadError:
|
|
437
|
+
pass
|
|
438
|
+
if process.poll() is not None:
|
|
439
|
+
break
|
|
440
|
+
time.sleep(0.1)
|
|
441
|
+
if process.poll() is None:
|
|
442
|
+
subprocess.run(
|
|
443
|
+
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
444
|
+
stdin=subprocess.DEVNULL,
|
|
445
|
+
stdout=subprocess.DEVNULL,
|
|
446
|
+
stderr=subprocess.DEVNULL,
|
|
447
|
+
check=False,
|
|
448
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
449
|
+
)
|
|
450
|
+
try:
|
|
451
|
+
process.wait(timeout=10)
|
|
452
|
+
except subprocess.TimeoutExpired:
|
|
453
|
+
pass
|
|
454
|
+
try:
|
|
455
|
+
pending = _read_json(startup)
|
|
456
|
+
if pending.get("startup_id") == startup_id:
|
|
457
|
+
startup.unlink()
|
|
458
|
+
except (FuploadError, OSError):
|
|
459
|
+
pass
|
|
460
|
+
raise FuploadError(last_error or "DD task session failed to start", kind="session_start_failed", stage="session")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def status(session_id: Optional[str] = None) -> Dict[str, Any]:
|
|
464
|
+
state = _load_live_state()
|
|
465
|
+
if not state:
|
|
466
|
+
return {"running": False, "login_performed": False}
|
|
467
|
+
chosen = session_id or str(state["session_id"])
|
|
468
|
+
data = _send({"session_id": chosen, "command": "ping"}, timeout=10)
|
|
469
|
+
return data
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def stop(session_id: str) -> Dict[str, Any]:
|
|
473
|
+
data = _send({"session_id": session_id, "command": "stop"}, timeout=30)
|
|
474
|
+
deadline = time.time() + 30
|
|
475
|
+
while time.time() < deadline and _load_live_state():
|
|
476
|
+
time.sleep(0.05)
|
|
477
|
+
if _load_live_state():
|
|
478
|
+
raise FuploadError(
|
|
479
|
+
"DD task session acknowledged stop but did not finish cleanup",
|
|
480
|
+
kind="session_stop_failed",
|
|
481
|
+
stage="session",
|
|
482
|
+
)
|
|
483
|
+
result = dict(data or {})
|
|
484
|
+
result["cleanup_complete"] = True
|
|
485
|
+
return result
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def execute(session_id: str, kind: str, resource: str, action: str, payload: Dict[str, Any]) -> Any:
|
|
489
|
+
return _send({
|
|
490
|
+
"session_id": session_id,
|
|
491
|
+
"command": kind,
|
|
492
|
+
"resource": resource,
|
|
493
|
+
"action": action,
|
|
494
|
+
"payload": payload,
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _read_request(connection: socket.socket) -> Dict[str, Any]:
|
|
499
|
+
value = bytearray()
|
|
500
|
+
while b"\n" not in value:
|
|
501
|
+
chunk = connection.recv(65536)
|
|
502
|
+
if not chunk:
|
|
503
|
+
break
|
|
504
|
+
value.extend(chunk)
|
|
505
|
+
if len(value) > MAX_REQUEST_BYTES:
|
|
506
|
+
raise FuploadError("DD broker request is too large", kind="request_too_large", stage="session")
|
|
507
|
+
decoded = json.loads(bytes(value).split(b"\n", 1)[0].decode("utf-8"))
|
|
508
|
+
if not isinstance(decoded, dict):
|
|
509
|
+
raise ValueError("request must be an object")
|
|
510
|
+
return decoded
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _serve(startup_id: str) -> int:
|
|
514
|
+
root = _state_dir()
|
|
515
|
+
startup_path = root / STARTUP_NAME
|
|
516
|
+
pending = _read_json(startup_path)
|
|
517
|
+
if pending.get("startup_id") != startup_id:
|
|
518
|
+
return 2
|
|
519
|
+
auth_key = str(pending.get("auth_key") or "")
|
|
520
|
+
session_id = uuid.uuid4().hex
|
|
521
|
+
state_path = root / STATE_NAME
|
|
522
|
+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
523
|
+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
524
|
+
listener.bind(("127.0.0.1", 0))
|
|
525
|
+
listener.listen(8)
|
|
526
|
+
listener.settimeout(1)
|
|
527
|
+
port = int(listener.getsockname()[1])
|
|
528
|
+
started_at = time.time()
|
|
529
|
+
last_activity = started_at
|
|
530
|
+
state = {
|
|
531
|
+
"schema": "fupload.dd-broker.v1",
|
|
532
|
+
"startup_id": startup_id,
|
|
533
|
+
"session_id": session_id,
|
|
534
|
+
"auth_key": auth_key,
|
|
535
|
+
"pid": os.getpid(),
|
|
536
|
+
"port": port,
|
|
537
|
+
"started_at": started_at,
|
|
538
|
+
"last_activity": last_activity,
|
|
539
|
+
}
|
|
540
|
+
sidecar = None
|
|
541
|
+
try:
|
|
542
|
+
sidecar = _dd_module().Sidecar().__enter__()
|
|
543
|
+
state["dd_dir"] = str(sidecar.dd_dir)
|
|
544
|
+
state["signature"] = sidecar.signature
|
|
545
|
+
_atomic_json(state_path, state)
|
|
546
|
+
try:
|
|
547
|
+
startup_path.unlink()
|
|
548
|
+
except OSError:
|
|
549
|
+
pass
|
|
550
|
+
should_stop = False
|
|
551
|
+
while not should_stop and time.time() - last_activity < IDLE_SECONDS:
|
|
552
|
+
try:
|
|
553
|
+
connection, _address = listener.accept()
|
|
554
|
+
except socket.timeout:
|
|
555
|
+
continue
|
|
556
|
+
with connection:
|
|
557
|
+
request: Dict[str, Any] = {}
|
|
558
|
+
try:
|
|
559
|
+
request = _read_request(connection)
|
|
560
|
+
if request.get("auth_key") != auth_key or request.get("session_id") != session_id:
|
|
561
|
+
raise FuploadError("DD broker authentication failed", kind="session_mismatch", stage="session")
|
|
562
|
+
last_activity = time.time()
|
|
563
|
+
state["last_activity"] = last_activity
|
|
564
|
+
_atomic_json(state_path, state)
|
|
565
|
+
command = request.get("command")
|
|
566
|
+
if command == "ping":
|
|
567
|
+
data = {
|
|
568
|
+
"running": True,
|
|
569
|
+
"session_id": session_id,
|
|
570
|
+
"started_at": started_at,
|
|
571
|
+
"last_activity": last_activity,
|
|
572
|
+
"login_count": 1,
|
|
573
|
+
"dd_dir": state.get("dd_dir"),
|
|
574
|
+
"signature": state.get("signature"),
|
|
575
|
+
}
|
|
576
|
+
elif command == "stop":
|
|
577
|
+
data = {"running": False, "session_id": session_id, "logout_requested": True}
|
|
578
|
+
should_stop = True
|
|
579
|
+
elif command == "write":
|
|
580
|
+
data = _dd_module().DD().execute_write_on(
|
|
581
|
+
sidecar,
|
|
582
|
+
str(request.get("resource") or ""),
|
|
583
|
+
str(request.get("action") or ""),
|
|
584
|
+
request.get("payload") if isinstance(request.get("payload"), dict) else {},
|
|
585
|
+
)
|
|
586
|
+
elif command == "read":
|
|
587
|
+
arguments = request.get("payload") if isinstance(request.get("payload"), dict) else {}
|
|
588
|
+
data = _dd_module().DD().execute_read_on(
|
|
589
|
+
sidecar,
|
|
590
|
+
str(request.get("resource") or ""),
|
|
591
|
+
str(request.get("action") or ""),
|
|
592
|
+
SimpleNamespace(**arguments),
|
|
593
|
+
)
|
|
594
|
+
else:
|
|
595
|
+
raise FuploadError("unsupported DD broker command", kind="unsupported_operation", stage="session")
|
|
596
|
+
response = {"ok": True, "data": data}
|
|
597
|
+
except FuploadError as exc:
|
|
598
|
+
response = {"ok": False, "error": exc.as_dict()}
|
|
599
|
+
except Exception as exc:
|
|
600
|
+
response = {"ok": False, "error": FuploadError(
|
|
601
|
+
"DD broker operation failed (%s)" % type(exc).__name__,
|
|
602
|
+
kind="broker_error",
|
|
603
|
+
stage="session",
|
|
604
|
+
verification_required=request.get("command") == "write",
|
|
605
|
+
).as_dict()}
|
|
606
|
+
connection.sendall((json.dumps(response, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8"))
|
|
607
|
+
return 0
|
|
608
|
+
except Exception as exc:
|
|
609
|
+
_atomic_json(startup_path, {
|
|
610
|
+
"startup_id": startup_id,
|
|
611
|
+
"error": redact(str(exc))[:400],
|
|
612
|
+
})
|
|
613
|
+
return 1
|
|
614
|
+
finally:
|
|
615
|
+
listener.close()
|
|
616
|
+
if sidecar is not None:
|
|
617
|
+
sidecar.__exit__(None, None, None)
|
|
618
|
+
try:
|
|
619
|
+
current = _read_json(state_path)
|
|
620
|
+
if current.get("session_id") == session_id:
|
|
621
|
+
state_path.unlink()
|
|
622
|
+
except (FuploadError, OSError):
|
|
623
|
+
pass
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
627
|
+
parser = argparse.ArgumentParser()
|
|
628
|
+
parser.add_argument("--serve", required=True)
|
|
629
|
+
args = parser.parse_args(argv)
|
|
630
|
+
return _serve(args.serve)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
if __name__ == "__main__":
|
|
634
|
+
raise SystemExit(main())
|