@follenfang/fupload 0.0.6 → 0.0.7

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.
@@ -82,45 +82,68 @@ function readMarker(root) {
82
82
  }
83
83
  }
84
84
 
85
- function probeRuntime(executable, run = spawnSync) {
85
+ function runtimeEnvironment(root, env) {
86
+ return {
87
+ ...env,
88
+ PLAYWRIGHT_BROWSERS_PATH: path.join(root, "browsers"),
89
+ };
90
+ }
91
+
92
+ function probeRuntime(executable, root, env, run = spawnSync) {
86
93
  if (!fs.existsSync(executable)) {
87
94
  return null;
88
95
  }
89
96
  const result = run(executable, [
90
97
  "-c",
91
- "import importlib.metadata,sys; import qcloud_cos; print('.'.join(map(str,sys.version_info[:3]))); print(importlib.metadata.version('cos-python-sdk-v5'))",
92
- ], { encoding: "utf8", shell: false, windowsHide: true });
98
+ "import importlib.metadata,json,pathlib,sys; import qcloud_cos; from playwright.sync_api import sync_playwright; p=sync_playwright().start(); executable=p.chromium.executable_path; p.stop(); print(json.dumps({'python_version': '.'.join(map(str,sys.version_info[:3])), 'cos_version': importlib.metadata.version('cos-python-sdk-v5'), 'playwright_version': importlib.metadata.version('playwright'), 'chromium_executable': executable})); sys.exit(0 if pathlib.Path(executable).is_file() else 1)",
99
+ ], {
100
+ encoding: "utf8",
101
+ env: runtimeEnvironment(root, env),
102
+ shell: false,
103
+ windowsHide: true,
104
+ });
93
105
  if (result.status !== 0) {
94
106
  return null;
95
107
  }
96
- const [pythonVersion, dependencyVersion] = result.stdout.trim().split(/\r?\n/);
97
- const match = pythonVersion?.match(/^(\d+)\.(\d+)\.(\d+)$/);
98
- if (!match || !dependencyVersion) {
108
+ let details;
109
+ try {
110
+ details = JSON.parse(result.stdout.trim());
111
+ } catch {
99
112
  return null;
100
113
  }
101
- return { version: match.slice(1).map(Number), dependencyVersion };
114
+ const match = details.python_version?.match(/^(\d+)\.(\d+)\.(\d+)$/);
115
+ if (!match || !details.cos_version || !details.playwright_version || !details.chromium_executable) {
116
+ return null;
117
+ }
118
+ return {
119
+ version: match.slice(1).map(Number),
120
+ dependencyVersion: details.cos_version,
121
+ playwrightVersion: details.playwright_version,
122
+ chromiumExecutable: details.chromium_executable,
123
+ };
102
124
  }
103
125
 
104
- function inspectRuntime({ root, platform, requirementsHash, run }) {
126
+ function inspectRuntime({ root, platform, requirementsHash, env, run }) {
105
127
  const marker = readMarker(root);
106
128
  if (marker?.schema !== PYTHON_RUNTIME_SCHEMA || marker.requirements_sha256 !== requirementsHash) {
107
129
  return null;
108
130
  }
109
131
  const command = pythonRuntimeExecutable(root, platform);
110
- const probe = probeRuntime(command, run);
132
+ const probe = probeRuntime(command, root, env, run);
111
133
  if (!probe || probe.version[0] !== 3 || probe.version[1] < 9) {
112
134
  return null;
113
135
  }
114
- return { command, args: [], version: probe.version, dependencyVersion: probe.dependencyVersion };
136
+ return { command, args: [], ...probe };
115
137
  }
116
138
 
117
139
  function bounded(value) {
118
140
  return String(value || "").trim().slice(0, 4000);
119
141
  }
120
142
 
121
- function runChecked(run, command, args, message) {
143
+ function runChecked(run, command, args, message, options = {}) {
122
144
  const result = run(command, args, {
123
145
  encoding: "utf8",
146
+ ...options,
124
147
  shell: false,
125
148
  windowsHide: true,
126
149
  });
@@ -191,7 +214,7 @@ export function ensurePythonRuntime({
191
214
  const parent = path.dirname(root);
192
215
  const lock = acquireRuntimeLock(root);
193
216
  try {
194
- const current = inspectRuntime({ root, platform, requirementsHash, run });
217
+ const current = inspectRuntime({ root, platform, requirementsHash, env, run });
195
218
  if (current) {
196
219
  return { status: "current", root, requirements, python: current };
197
220
  }
@@ -205,13 +228,19 @@ export function ensurePythonRuntime({
205
228
  const backup = path.join(parent, `.python-backup-${nonce}`);
206
229
  fs.mkdirSync(parent, { recursive: true });
207
230
  let movedOld = false;
231
+ let created;
208
232
  try {
209
233
  runChecked(run, base.command, [...base.args, "-m", "venv", staging], "Could not create the Fuploader Python runtime");
210
234
  const stagingPython = pythonRuntimeExecutable(staging, platform);
211
235
  runChecked(run, stagingPython, [
212
236
  "-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--requirement", requirements,
213
237
  ], "Could not install Fuploader Python dependencies");
214
- const installed = probeRuntime(stagingPython, run);
238
+ runChecked(run, stagingPython, [
239
+ "-m", "playwright", "install", "chromium",
240
+ ], "Could not install Fuploader Chromium", {
241
+ env: runtimeEnvironment(staging, env),
242
+ });
243
+ const installed = probeRuntime(stagingPython, staging, env, run);
215
244
  if (!installed) {
216
245
  throw new Error("The Fuploader Python runtime did not pass its dependency probe.");
217
246
  }
@@ -220,6 +249,8 @@ export function ensurePythonRuntime({
220
249
  requirements_sha256: requirementsHash,
221
250
  python_version: installed.version.join("."),
222
251
  dependency_version: installed.dependencyVersion,
252
+ playwright_version: installed.playwrightVersion,
253
+ chromium_executable: path.relative(staging, installed.chromiumExecutable),
223
254
  });
224
255
  if (fs.existsSync(root)) {
225
256
  fs.renameSync(root, backup);
@@ -234,16 +265,21 @@ export function ensurePythonRuntime({
234
265
  }
235
266
  throw error;
236
267
  }
268
+ created = inspectRuntime({ root, platform, requirementsHash, env, run });
269
+ if (!created) {
270
+ fs.rmSync(root, { recursive: true, force: true });
271
+ if (movedOld) {
272
+ fs.renameSync(backup, root);
273
+ movedOld = false;
274
+ }
275
+ throw new Error("The installed Fuploader Python runtime failed final validation.");
276
+ }
237
277
  if (movedOld) {
238
278
  fs.rmSync(backup, { recursive: true, force: true });
239
279
  }
240
280
  } finally {
241
281
  fs.rmSync(staging, { recursive: true, force: true });
242
282
  }
243
- const created = inspectRuntime({ root, platform, requirementsHash, run });
244
- if (!created) {
245
- throw new Error("The installed Fuploader Python runtime failed final validation.");
246
- }
247
283
  return { status: "installed", root, requirements, python: created };
248
284
  } finally {
249
285
  lock.release();
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schema": "fupload.npm-skill-manifest.v1",
3
3
  "package_name": "@follenfang/fupload",
4
- "package_version": "0.0.6",
5
- "skill_version": "0.0.6",
6
- "tree_sha256": "89170e9d93a44d9aa72a1c041f93d941609eb9d762375358090c5a96129405d9",
4
+ "package_version": "0.0.7",
5
+ "skill_version": "0.0.7",
6
+ "tree_sha256": "f2693bc26ecd3f9bab3ed691a2f372c5702b6ac5f72f52880fc6cee40ca90314",
7
7
  "files": [
8
8
  {
9
9
  "path": "agents/openai.yaml",
@@ -77,8 +77,8 @@
77
77
  },
78
78
  {
79
79
  "path": "references/blackbox.md",
80
- "bytes": 2357,
81
- "sha256": "022563a7611a5877761df71074ed347ef51fd27f735da7926cbb96d3b781dcc6"
80
+ "bytes": 2966,
81
+ "sha256": "ea95e8826af8e54c82343c404f9c97a611ab8ec3fb0282b561907c50169e4307"
82
82
  },
83
83
  {
84
84
  "path": "references/curseforge.md",
@@ -108,22 +108,22 @@
108
108
  {
109
109
  "path": "scripts/fupload_cli/__init__.py",
110
110
  "bytes": 49,
111
- "sha256": "acf9b050750b58742981c5b9ae836149b89bb86daad01d18dd1948e71817d2c2"
111
+ "sha256": "b44c52f9e6b2989921c93580a400ddcd3dd3e5c1cc787e9984baf0dabdfd93e7"
112
112
  },
113
113
  {
114
- "path": "scripts/fupload_cli/blackbox_auth.py",
115
- "bytes": 7211,
116
- "sha256": "797ca228c57d2b845a7105f132891b71d3e494adcdb8f44027ce835125206177"
114
+ "path": "scripts/fupload_cli/blackbox_web.py",
115
+ "bytes": 19392,
116
+ "sha256": "4a849fb297f045b81d261f4456542c165a3c0d9a77f963dbfcbc1c9dad8fec65"
117
117
  },
118
118
  {
119
119
  "path": "scripts/fupload_cli/blackbox.py",
120
- "bytes": 16666,
121
- "sha256": "c6a793cacc7d9ceb8995cac62a076c1944345eaa113bed21068c75a05b1b0ee6"
120
+ "bytes": 17089,
121
+ "sha256": "4487141898ba6a080d2120c1df9ad83c290528e9b462acb22d6c0f89dbbd117c"
122
122
  },
123
123
  {
124
124
  "path": "scripts/fupload_cli/cli.py",
125
- "bytes": 25664,
126
- "sha256": "d1327783aa0e4e27fcc1f71a80cd090f661feaf2e226688b2efa6a36b9645073"
125
+ "bytes": 25982,
126
+ "sha256": "79c5de939e3a7e7d0ed49d79fcce6c22e78c1a5abcf8eae5422dbd78f7c7bf2d"
127
127
  },
128
128
  {
129
129
  "path": "scripts/fupload_cli/curseforge.py",
@@ -167,8 +167,8 @@
167
167
  },
168
168
  {
169
169
  "path": "scripts/fupload_cli/schema.py",
170
- "bytes": 36268,
171
- "sha256": "db788d2422c0f6b50ce2795a3df75d5bf1e23934ba4211a64198de06931492a5"
170
+ "bytes": 37399,
171
+ "sha256": "5538eaf099bf9e7cae5f8142faecf827d41da698ae574b5597d17756da23e331"
172
172
  },
173
173
  {
174
174
  "path": "scripts/fupload_cli/transport.py",
@@ -187,8 +187,8 @@
187
187
  },
188
188
  {
189
189
  "path": "SKILL.md",
190
- "bytes": 22890,
191
- "sha256": "af700f4e6eb924d955c8e5ae1205934c0bc08b425c85ce5f279eb0c630a0f77a"
190
+ "bytes": 23242,
191
+ "sha256": "941f2c5f615dded9fcfe086516a9bf903f54db52351815c38d26c2691f71793b"
192
192
  }
193
193
  ]
194
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@follenfang/fupload",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Install and run the Fuploader Agent Skill and Python CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,159 +0,0 @@
1
- """Authentication helpers for the Heybox desktop Workshop client."""
2
- from __future__ import annotations
3
- import hashlib, json, platform, secrets, sqlite3, time
4
- from pathlib import Path
5
- from urllib.parse import parse_qs, urlparse
6
- from .errors import FuploadError
7
-
8
- API_BASE = "https://workshopapi.xiaoheihe.cn"
9
- CLIENT_VERSION = "1.14.1"
10
- _CONFIG_AES_KEY = bytes.fromhex("5f1d7f11e6e90dbb5c2f0c1e614a6a8c4b9e16b50fa724e4c54d6f25b1208b93")
11
- _ALPHABET = "AB45STUVWZEFGJ6CH01D237IXYPQRKLMN89"
12
-
13
- def _n8(value: str, limit: int) -> str:
14
- table = _ALPHABET[:limit]
15
- return "".join(table[ord(ch) % len(table)] for ch in value)
16
- def _i8(value: str) -> str:
17
- return "".join(_ALPHABET[ord(ch) % len(_ALPHABET)] for ch in value)
18
- def _interleave(parts):
19
- return "".join(part[i] for i in range(max(map(len, parts))) for part in parts if i < len(part))
20
- def _mix(values):
21
- def p(x): return ((x << 1) ^ 27) & 255 if x & 128 else (x << 1) & 255
22
- def hm(x): return p(x) ^ x
23
- def qg(x): return hm(p(x))
24
- def dx(x): return qg(hm(p(x)))
25
- def mw(x): return dx(x) ^ qg(x) ^ hm(x)
26
- a,b,c,d,*rest=values
27
- return [mw(a)^dx(b)^qg(c)^hm(d), hm(a)^mw(b)^dx(c)^qg(d), qg(a)^hm(b)^mw(c)^dx(d), dx(a)^qg(b)^hm(c)^mw(d), *rest]
28
- def hkey(path: str, timestamp: int, nonce: str) -> str:
29
- normalized = "/" + "/".join(x for x in path.split("/") if x) + "/"
30
- digest = hashlib.md5(_interleave([_n8(str(timestamp), -2), _i8(normalized), _i8(nonce)]).encode()).hexdigest()
31
- return _n8(digest[:5], -4) + "%02d" % (sum(_mix([ord(x) for x in digest[-6:]])) % 100)
32
-
33
- def _decrypt_user_pkey(value: str) -> str:
34
- """Decrypt the desktop config's ``iv:ciphertext`` AES-256-CBC value.
35
-
36
- Older Chromium cookie databases contain the already decrypted pkey, so this
37
- helper deliberately returns ordinary values unchanged and only attempts
38
- crypto for the new config format.
39
- """
40
- if not isinstance(value, str) or ":" not in value:
41
- return value
42
- iv_hex, ciphertext_hex = value.split(":", 1)
43
- try:
44
- iv = bytes.fromhex(iv_hex)
45
- ciphertext = bytes.fromhex(ciphertext_hex)
46
- if len(iv) != 16 or not ciphertext or len(ciphertext) % 16:
47
- return value
48
- except ValueError:
49
- return value
50
- try:
51
- from Crypto.Cipher import AES
52
- plaintext = AES.new(_CONFIG_AES_KEY, AES.MODE_CBC, iv).decrypt(ciphertext)
53
- except ImportError:
54
- try:
55
- from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
56
- decryptor = Cipher(algorithms.AES(_CONFIG_AES_KEY), modes.CBC(iv)).decryptor()
57
- plaintext = decryptor.update(ciphertext) + decryptor.finalize()
58
- except ImportError as exc:
59
- raise FuploadError(
60
- "AES support is required to read the Heybox desktop session",
61
- kind="environment_error",
62
- details={"install": "Crypto or cryptography"},
63
- ) from exc
64
- if not plaintext:
65
- return value
66
- pad = plaintext[-1]
67
- if not 1 <= pad <= 16 or plaintext[-pad:] != bytes([pad]) * pad:
68
- raise FuploadError("Heybox desktop pkey could not be decrypted", kind="authentication_error")
69
- try:
70
- return plaintext[:-pad].decode("utf-8")
71
- except UnicodeDecodeError as exc:
72
- raise FuploadError("Heybox desktop pkey is invalid", kind="authentication_error") from exc
73
-
74
-
75
- def _read_json(path: Path) -> dict:
76
- try:
77
- data = json.loads(path.read_text(encoding="utf-8"))
78
- except (OSError, UnicodeError, ValueError):
79
- return {}
80
- return data if isinstance(data, dict) else {}
81
-
82
-
83
- def _load_cookie_db(profile: Path) -> dict[str, str]:
84
- db = profile / "Network/Cookies"
85
- try:
86
- with sqlite3.connect("file:%s?mode=ro" % db, uri=True) as con:
87
- rows = con.execute("select name,value from cookies where host_key like '%xiaoheihe.cn'").fetchall()
88
- except (OSError, sqlite3.Error):
89
- return {}
90
- return {str(k): str(v) for k, v in rows if k in {"user_heybox_id", "user_pkey", "x_xhh_tokenid"} and v}
91
-
92
-
93
- def _read_sentry_identity(profile: Path) -> dict[str, str]:
94
- identity: dict[str, str] = {}
95
- scope = profile / "sentry/scope_v3.json"
96
- try:
97
- crumbs = _read_json(scope).get("scope", {}).get("breadcrumbs", [])
98
- for crumb in reversed(crumbs):
99
- raw = crumb.get("data",{}).get("url","")
100
- if "x_app=heybox_pc" in raw:
101
- query = parse_qs(urlparse(raw).query)
102
- allowed = {
103
- "app", "client_type", "device_id", "exe_version", "heybox_id",
104
- "os_type", "os_version", "version", "web_version", "x_app",
105
- "x_client_type", "x_client_version", "x_os_type",
106
- }
107
- identity = {k: v[-1] for k, v in query.items() if k in allowed and v}
108
- break
109
- except (OSError, ValueError, TypeError):
110
- pass
111
- return identity
112
-
113
-
114
- def load_session(profile: Path | None = None):
115
- """Load the current desktop session without exposing credential material.
116
-
117
- Heybox 1.14 stores its authoritative session in ``config.json``. The
118
- Chromium database is retained as a compatibility fallback because older
119
- installations and upgrade paths may not have written the new config yet.
120
- """
121
- profile = profile or Path.home() / "AppData/Roaming/heybox-pc-launcher"
122
- config = _read_json(profile / "config.json")
123
- db_cookies = _load_cookie_db(profile)
124
- cookies: dict[str, str] = dict(db_cookies)
125
- config_cookies = config.get("cookies")
126
- if isinstance(config_cookies, list):
127
- for item in config_cookies:
128
- if not isinstance(item, dict):
129
- continue
130
- name, value = item.get("name"), item.get("value")
131
- if isinstance(name, str) and isinstance(value, str) and value:
132
- if name in {"user_heybox_id", "user_pkey", "x_xhh_tokenid"}:
133
- cookies[name] = value
134
- acc_config = config.get("acc_config")
135
- if isinstance(acc_config, dict) and acc_config.get("xhh_token_id"):
136
- cookies["x_xhh_tokenid"] = str(acc_config["xhh_token_id"])
137
- if cookies.get("user_pkey"):
138
- cookies["user_pkey"] = _decrypt_user_pkey(cookies["user_pkey"])
139
- account = config.get("account") if isinstance(config.get("account"), dict) else {}
140
- if not cookies.get("user_heybox_id") and account.get("heybox_id"):
141
- cookies["user_heybox_id"] = str(account["heybox_id"])
142
- if not {"user_heybox_id", "user_pkey", "x_xhh_tokenid"} <= set(cookies):
143
- raise FuploadError("Heybox desktop login state is incomplete", kind="authentication_error")
144
-
145
- identity = _read_sentry_identity(profile)
146
- version = identity.get("version") or CLIENT_VERSION
147
- identity = {
148
- "x_client_type": "pc",
149
- "x_os_type": "Windows",
150
- "x_app": "heybox_pc",
151
- "version": version,
152
- "exe_version": identity.get("exe_version") or version,
153
- "os_version": identity.get("os_version") or platform.platform(aliased=True),
154
- **identity,
155
- }
156
- identity["version"] = version
157
- identity["exe_version"] = identity.get("exe_version") or version
158
- identity["heybox_id"] = cookies["user_heybox_id"]
159
- return cookies, identity