fluxgram-plugin-devkit 1.0.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.
@@ -0,0 +1,6 @@
1
+ JAVA_API_VERSION = 1
2
+ JAVA_API_MINOR = 19
3
+ SDK_VERSION = 2
4
+ SDK_MINOR = 9
5
+
6
+ __all__ = ["JAVA_API_VERSION", "JAVA_API_MINOR", "SDK_VERSION", "SDK_MINOR"]
@@ -0,0 +1,10 @@
1
+ {
2
+ "api_version": 1,
3
+ "api_minor": 19,
4
+ "min_sdk": 24,
5
+ "compile_sdk": 36,
6
+ "sha256": "a2856a6ffa4ce33fb7617224fd911e411032ad68200d25779ab1d809176bde62",
7
+ "source_sha256": "163945fc56726f8b35b1f83951bcf13b454a9adb41850ff09e8a6224fe5a6258",
8
+ "host_validator_sha256": "5eb46b9c16c0b306d9ab20154dd01cc3037b18f5c71725e9bda7ce4f45dff066",
9
+ "source_commit": "8147531c0"
10
+ }
fluxgram_devkit/cli.py ADDED
@@ -0,0 +1,703 @@
1
+ import argparse
2
+ import hashlib
3
+ import io
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import shutil
9
+ import stat
10
+ import struct
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import tomllib
15
+ import zipfile
16
+ import zlib
17
+
18
+ from . import JAVA_API_MINOR, JAVA_API_VERSION, SDK_MINOR, SDK_VERSION
19
+
20
+
21
+ MAX_ARCHIVE_BYTES = 8 * 1024 * 1024
22
+ MAX_UNPACKED_BYTES = 32 * 1024 * 1024
23
+ MAX_FILE_BYTES = 8 * 1024 * 1024
24
+ MAX_MANIFEST_BYTES = 64 * 1024
25
+ MAX_ENTRIES = 512
26
+ DEX_MAGICS = {b"dex\n035\0", b"dex\n037\0", b"dex\n038\0", b"dex\n039\0"}
27
+ PATH_PART = re.compile(r"[A-Za-z0-9_.-]+")
28
+ PLUGIN_ID = re.compile(r"[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+")
29
+ PLUGIN_VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)")
30
+ PYTHON_ENTRYPOINT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*:PluginEntry")
31
+ JAVA_ENTRYPOINT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_$]*)+")
32
+ HASH = re.compile(r"[a-f0-9]{64}")
33
+ FIXED_TIME = (2020, 1, 1, 0, 0, 0)
34
+
35
+
36
+ class DevkitError(Exception):
37
+ pass
38
+
39
+
40
+ def require(condition, message):
41
+ if not condition:
42
+ raise DevkitError(message)
43
+
44
+
45
+ def strict_object(pairs):
46
+ value = {}
47
+
48
+ for key, item in pairs:
49
+ require(key not in value, f"Duplicate JSON field: {key}")
50
+ value[key] = item
51
+
52
+ return value
53
+
54
+
55
+ def read_json(data):
56
+ try:
57
+ return json.loads(data.decode("utf-8"), object_pairs_hook=strict_object)
58
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
59
+ raise DevkitError("Invalid manifest JSON") from error
60
+
61
+
62
+ def validate_string(value, name, limit):
63
+ require(isinstance(value, str), f"{name} must be a string")
64
+ require(value.strip() and len(value) <= limit, f"Invalid {name}")
65
+
66
+ for character in value:
67
+ code = ord(character)
68
+ require(character in "\n\t" or code >= 32 and code != 127, f"Invalid control character in {name}")
69
+ require(not 0x202A <= code <= 0x202E and not 0x2066 <= code <= 0x2069,
70
+ f"Bidirectional controls are not allowed in {name}")
71
+
72
+ return value
73
+
74
+
75
+ def validate_integer(value, name):
76
+ require(type(value) is int and 0 <= value <= 2_147_483_647,
77
+ f"{name} must be a non-negative 32-bit integer")
78
+ return value
79
+
80
+
81
+ def validate_path(name, directory=False):
82
+ require(isinstance(name, str) and 0 < len(name) <= 240, "Invalid ZIP path length")
83
+ require("\\" not in name and not name.startswith("/"), f"Unsafe ZIP path: {name}")
84
+ path = name[:-1] if directory and name.endswith("/") else name
85
+ parts = path.split("/")
86
+ require(path and len(parts) <= 16, f"Unsafe ZIP path: {name}")
87
+
88
+ for part in parts:
89
+ require(PATH_PART.fullmatch(part) is not None and part not in {".", ".."}
90
+ and not part.endswith("."), f"Unsafe ZIP path: {name}")
91
+
92
+ require(parts[0].lower() not in {"fluxgram", "fluxgram.py", ".fluxgram-source.fgplugin"},
93
+ f"Reserved package path: {name}")
94
+ return path
95
+
96
+
97
+ def validate_manifest(manifest):
98
+ require(isinstance(manifest, dict), "Manifest must be an object")
99
+ base = {
100
+ "schema_version", "sdk_version", "min_sdk_minor", "id", "name", "version",
101
+ "description", "author", "entrypoint", "access", "declared_capabilities",
102
+ }
103
+ optional = {"icon", "java_module"}
104
+ require(set(manifest).issubset(base | optional), "Unknown manifest field")
105
+ require(base.issubset(manifest), "Missing manifest field")
106
+ schema = validate_integer(manifest["schema_version"], "schema_version")
107
+ require(schema in {2, 3}, "Only manifest schema 2 and 3 are supported")
108
+ require(validate_integer(manifest["sdk_version"], "sdk_version") == SDK_VERSION,
109
+ f"SDK major must be {SDK_VERSION}")
110
+ require(validate_integer(manifest["min_sdk_minor"], "min_sdk_minor") <= SDK_MINOR,
111
+ f"Plugin requires a newer SDK than {SDK_VERSION}.{SDK_MINOR}")
112
+ plugin_id = validate_string(manifest["id"], "id", 100)
113
+ require(PLUGIN_ID.fullmatch(plugin_id) is not None and not plugin_id.startswith("fluxgram."),
114
+ "Invalid or reserved plugin id")
115
+ validate_string(manifest["name"], "name", 80)
116
+ version = validate_string(manifest["version"], "version", 64)
117
+ require(PLUGIN_VERSION.fullmatch(version) is not None, "Version must have the form major.minor.patch")
118
+ validate_string(manifest["description"], "description", 2000)
119
+ validate_string(manifest["author"], "author", 100)
120
+ entrypoint = validate_string(manifest["entrypoint"], "entrypoint", 128)
121
+ require(PYTHON_ENTRYPOINT.fullmatch(entrypoint) is not None
122
+ and entrypoint != "fluxgram:PluginEntry", "Invalid Python entrypoint")
123
+ require(manifest["access"] == "full", "Only full access is supported")
124
+ capabilities = manifest["declared_capabilities"]
125
+ require(isinstance(capabilities, list) and len(capabilities) <= 32,
126
+ "declared_capabilities must be an array of at most 32 strings")
127
+ seen = set()
128
+
129
+ for capability in capabilities:
130
+ value = validate_string(capability, "declared capability", 160)
131
+ require(value not in seen, "Duplicate declared capability")
132
+ seen.add(value)
133
+
134
+ if "icon" in manifest:
135
+ icon = validate_path(validate_string(manifest["icon"], "icon", 240))
136
+ require(icon.lower().endswith((".png", ".webp")), "Icon must be PNG or WebP")
137
+
138
+ java = manifest.get("java_module")
139
+ require((schema == 3) == (java is not None), "java_module is required only for schema 3")
140
+
141
+ if java is not None:
142
+ require(isinstance(java, dict), "java_module must be an object")
143
+ fields = {"path", "entrypoint", "sha256", "api_version", "min_api_minor"}
144
+ require(set(java) == fields, "Incomplete or unknown java_module field")
145
+ dex_path = validate_path(validate_string(java["path"], "Java module path", 240))
146
+ require(dex_path.endswith(".dex"), "Java module must be a DEX file")
147
+ java_entrypoint = validate_string(java["entrypoint"], "Java entrypoint", 240)
148
+ require(JAVA_ENTRYPOINT.fullmatch(java_entrypoint) is not None, "Invalid Java entrypoint")
149
+ require(HASH.fullmatch(validate_string(java["sha256"], "DEX SHA-256", 64)) is not None,
150
+ "Invalid DEX SHA-256")
151
+ require(validate_integer(java["api_version"], "Java API version") == JAVA_API_VERSION,
152
+ f"Java API major must be {JAVA_API_VERSION}")
153
+ require(validate_integer(java["min_api_minor"], "Java API minor") <= JAVA_API_MINOR,
154
+ f"Plugin requires a newer Java API than {JAVA_API_VERSION}.{JAVA_API_MINOR}")
155
+
156
+ return manifest
157
+
158
+
159
+ def validate_dex(data, java):
160
+ require(len(data) >= 112 and data[:8] in DEX_MAGICS, "Unsupported or truncated DEX")
161
+ require(hashlib.sha256(data).hexdigest() == java["sha256"], "DEX SHA-256 mismatch")
162
+ file_size, header_size, endian = struct.unpack_from("<III", data, 32)
163
+ require(file_size == len(data) and header_size == 112 and endian == 0x12345678,
164
+ "Invalid DEX header")
165
+ strings, strings_offset = struct.unpack_from("<II", data, 56)
166
+ types, types_offset = struct.unpack_from("<II", data, 64)
167
+ classes, classes_offset = struct.unpack_from("<II", data, 96)
168
+
169
+ def table(offset, count, width):
170
+ require(offset >= 112 and count > 0 and offset + count * width <= len(data),
171
+ "Invalid DEX table")
172
+
173
+ table(strings_offset, strings, 4)
174
+ table(types_offset, types, 4)
175
+ table(classes_offset, classes, 32)
176
+ expected = "L" + java["entrypoint"].replace(".", "/") + ";"
177
+ found = False
178
+
179
+ for index in range(classes):
180
+ type_index = struct.unpack_from("<I", data, classes_offset + index * 32)[0]
181
+ require(type_index < types, "Invalid DEX class type")
182
+ string_index = struct.unpack_from("<I", data, types_offset + type_index * 4)[0]
183
+ require(string_index < strings, "Invalid DEX type descriptor")
184
+ offset = struct.unpack_from("<I", data, strings_offset + string_index * 4)[0]
185
+ require(112 <= offset < len(data), "Invalid DEX string offset")
186
+
187
+ for _ in range(5):
188
+ require(offset < len(data), "Invalid DEX string length")
189
+ value = data[offset]
190
+ offset += 1
191
+
192
+ if value & 128 == 0:
193
+ break
194
+ else:
195
+ raise DevkitError("Invalid DEX string length")
196
+
197
+ end = data.find(b"\0", offset, min(len(data), offset + 1025))
198
+ require(end >= 0, "Unterminated or oversized DEX descriptor")
199
+
200
+ try:
201
+ descriptor = data[offset:end].decode("utf-8")
202
+ except UnicodeDecodeError as error:
203
+ raise DevkitError("Invalid DEX class descriptor") from error
204
+
205
+ require(descriptor.startswith("L") and descriptor.endswith(";"),
206
+ "Invalid DEX class descriptor")
207
+ forbidden = ("Lorg/telegram/", "Landroid/", "Ljava/", "Ljavax/", "Ldalvik/", "Lcom/chaquo/")
208
+ require(not descriptor.startswith(forbidden), "DEX bundles host/platform classes")
209
+ found |= descriptor == expected
210
+
211
+ require(found, "Java entrypoint is not defined in DEX")
212
+
213
+
214
+ def read_number(data, offset, length, byteorder):
215
+ require(0 <= offset and offset + length <= len(data), "Truncated image data")
216
+ return int.from_bytes(data[offset:offset + length], byteorder)
217
+
218
+
219
+ def validate_icon(data, path):
220
+ require(20 <= len(data) <= 512 * 1024, "Invalid icon size")
221
+ width = 0
222
+ height = 0
223
+
224
+ if path.lower().endswith(".png"):
225
+ require(data[:8] == b"\x89PNG\r\n\x1a\n", "Invalid PNG icon")
226
+ offset = 8
227
+ end = False
228
+ pixels = False
229
+
230
+ while offset + 12 <= len(data):
231
+ length = read_number(data, offset, 4, "big")
232
+ require(length <= len(data) - offset - 12, "Truncated PNG icon")
233
+ kind = data[offset + 4:offset + 8]
234
+ checksum = read_number(data, offset + 8 + length, 4, "big")
235
+ require(zlib.crc32(data[offset + 4:offset + 8 + length]) == checksum,
236
+ "Invalid PNG checksum")
237
+ require(kind not in {b"acTL", b"fcTL", b"fdAT"}, "Animated icons are not supported")
238
+
239
+ if offset == 8:
240
+ require(kind == b"IHDR" and length == 13, "Invalid PNG header")
241
+ width = read_number(data, offset + 8, 4, "big")
242
+ height = read_number(data, offset + 12, 4, "big")
243
+ else:
244
+ require(kind != b"IHDR", "Duplicate PNG header")
245
+
246
+ pixels |= kind == b"IDAT"
247
+ offset += length + 12
248
+
249
+ if kind == b"IEND":
250
+ require(length == 0 and offset == len(data), "Invalid PNG end")
251
+ end = True
252
+ break
253
+
254
+ require(end and pixels, "Incomplete PNG icon")
255
+ else:
256
+ require(data[:4] == b"RIFF" and data[8:12] == b"WEBP", "Invalid WebP icon")
257
+ require(read_number(data, 4, 4, "little") == len(data) - 8, "Invalid WebP length")
258
+ offset = 12
259
+ pixels = False
260
+
261
+ while offset + 8 <= len(data):
262
+ kind = data[offset:offset + 4]
263
+ length = read_number(data, offset + 4, 4, "little")
264
+ require(length <= len(data) - offset - 8, "Truncated WebP icon")
265
+ start = offset + 8
266
+ require(kind not in {b"ANIM", b"ANMF"}, "Animated icons are not supported")
267
+
268
+ if kind == b"VP8X":
269
+ require(length == 10 and data[start] & 2 == 0, "Animated or invalid WebP icon")
270
+ check_icon_dimensions(read_number(data, start + 4, 3, "little") + 1,
271
+ read_number(data, start + 7, 3, "little") + 1)
272
+ elif kind == b"VP8 ":
273
+ require(not pixels and length >= 10 and data[start + 3:start + 6] == b"\x9d\x01\x2a",
274
+ "Invalid WebP frame")
275
+ width = read_number(data, start + 6, 2, "little") & 0x3FFF
276
+ height = read_number(data, start + 8, 2, "little") & 0x3FFF
277
+ pixels = True
278
+ elif kind == b"VP8L":
279
+ require(not pixels and length >= 5 and data[start] == 0x2F, "Invalid WebP frame")
280
+ bits = read_number(data, start + 1, 4, "little")
281
+ width = (bits & 0x3FFF) + 1
282
+ height = ((bits >> 14) & 0x3FFF) + 1
283
+ pixels = True
284
+
285
+ offset += 8 + length + (length & 1)
286
+
287
+ require(pixels and offset == len(data), "Incomplete WebP icon")
288
+
289
+ check_icon_dimensions(width, height)
290
+
291
+
292
+ def check_icon_dimensions(width, height):
293
+ require(0 < width <= 1024 and 0 < height <= 1024,
294
+ "Icon dimensions must be between 1 and 1024 pixels")
295
+
296
+
297
+ def inspect_archive(path):
298
+ path = Path(path)
299
+ require(path.is_file(), f"Package does not exist: {path}")
300
+ require(path.stat().st_size <= MAX_ARCHIVE_BYTES, "Archive exceeds 8 MiB")
301
+ spellings = {}
302
+ kinds = {}
303
+ contents = {}
304
+ total = 0
305
+
306
+ try:
307
+ archive = zipfile.ZipFile(path)
308
+ except (OSError, zipfile.BadZipFile) as error:
309
+ raise DevkitError("Invalid ZIP archive") from error
310
+
311
+ with archive:
312
+ entries = archive.infolist()
313
+ require(len(entries) <= MAX_ENTRIES, "Too many ZIP entries")
314
+
315
+ for entry in entries:
316
+ require(entry.flag_bits & 1 == 0, "Encrypted ZIP entries are not supported")
317
+ require(entry.compress_type in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED},
318
+ "Unsupported ZIP compression")
319
+ directory = entry.is_dir()
320
+ name = validate_path(entry.filename, directory)
321
+ require(name not in kinds, f"Duplicate ZIP path: {name}")
322
+ kinds[name] = directory
323
+ prefix = ""
324
+
325
+ for part in name.split("/"):
326
+ prefix = part if not prefix else prefix + "/" + part
327
+ key = prefix.lower()
328
+ require(key not in spellings or spellings[key] == prefix, "Case-conflicting ZIP path")
329
+ spellings[key] = prefix
330
+
331
+ mode = entry.external_attr >> 16
332
+
333
+ if mode:
334
+ require(not stat.S_ISLNK(mode), "Symbolic links are not supported")
335
+ require(directory or stat.S_ISREG(mode), "Special files are not supported")
336
+
337
+ limit = MAX_MANIFEST_BYTES if name == "manifest.json" else MAX_FILE_BYTES
338
+ require(0 <= entry.file_size <= limit, f"Oversized file: {name}")
339
+ require(not directory or entry.file_size == 0, "Directory contains file data")
340
+ data = archive.read(entry)
341
+ require(len(data) == entry.file_size, f"Corrupt ZIP entry: {name}")
342
+ total += len(data)
343
+ require(total <= MAX_UNPACKED_BYTES, "Unpacked size exceeds 32 MiB")
344
+
345
+ if not directory:
346
+ contents[name] = data
347
+
348
+ for name in kinds:
349
+ parts = name.split("/")
350
+
351
+ for index in range(1, len(parts)):
352
+ require(kinds.get("/".join(parts[:index])) is not False, "File used as a directory")
353
+
354
+ require("manifest.json" in contents, "Missing root manifest.json")
355
+ manifest = validate_manifest(read_json(contents["manifest.json"]))
356
+ python_file = manifest["entrypoint"].split(":", 1)[0] + ".py"
357
+ require(python_file in contents, "Missing Python entrypoint file")
358
+
359
+ if manifest.get("java_module") is not None:
360
+ java = manifest["java_module"]
361
+ require(java["path"] in contents, "Missing Java module")
362
+ validate_dex(contents[java["path"]], java)
363
+
364
+ if "icon" in manifest:
365
+ require(manifest["icon"] in contents, "Missing plugin icon")
366
+ validate_icon(contents[manifest["icon"]], manifest["icon"])
367
+
368
+ return {
369
+ "id": manifest["id"],
370
+ "version": manifest["version"],
371
+ "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
372
+ "files": len(contents),
373
+ "bytes": total,
374
+ }
375
+
376
+
377
+ def load_project(project_dir):
378
+ project_dir = Path(project_dir).resolve()
379
+ config_path = project_dir / "fluxgram-plugin.toml"
380
+ require(config_path.is_file(), f"Missing project file: {config_path}")
381
+
382
+ try:
383
+ config = tomllib.loads(config_path.read_text("utf-8"))
384
+ except (OSError, tomllib.TOMLDecodeError) as error:
385
+ raise DevkitError("Invalid fluxgram-plugin.toml") from error
386
+
387
+ require(set(config).issubset({"plugin", "python", "java", "assets"}), "Unknown project section")
388
+ require("plugin" in config and "python" in config, "Project requires plugin and python sections")
389
+ plugin = config["plugin"]
390
+ python = config["python"]
391
+ require(isinstance(plugin, dict) and isinstance(python, dict), "Invalid project section")
392
+ plugin_fields = {
393
+ "id", "name", "version", "description", "author", "min_sdk_minor",
394
+ "declared_capabilities", "icon",
395
+ }
396
+ require(set(plugin).issubset(plugin_fields), "Unknown plugin project field")
397
+ required_plugin = plugin_fields - {"icon"}
398
+ require(required_plugin.issubset(plugin), "Missing plugin project field")
399
+ require(set(python) == {"root", "entrypoint", "files"}, "Invalid python project fields")
400
+ java = config.get("java")
401
+ assets = config.get("assets", {"root": "assets", "files": []})
402
+ require(isinstance(assets, dict) and set(assets) == {"root", "files"}, "Invalid assets section")
403
+
404
+ if java is not None:
405
+ require(isinstance(java, dict)
406
+ and set(java) == {"sources", "entrypoint", "min_api_minor"},
407
+ "Invalid java project fields")
408
+
409
+ manifest = {
410
+ "schema_version": 3 if java is not None else 2,
411
+ "sdk_version": SDK_VERSION,
412
+ "min_sdk_minor": plugin["min_sdk_minor"],
413
+ "id": plugin["id"],
414
+ "name": plugin["name"],
415
+ "version": plugin["version"],
416
+ "description": plugin["description"],
417
+ "author": plugin["author"],
418
+ "entrypoint": python["entrypoint"],
419
+ "access": "full",
420
+ "declared_capabilities": plugin["declared_capabilities"],
421
+ }
422
+
423
+ if "icon" in plugin:
424
+ manifest["icon"] = plugin["icon"]
425
+
426
+ if java is not None:
427
+ manifest["java_module"] = {
428
+ "path": "java/module.dex",
429
+ "entrypoint": java["entrypoint"],
430
+ "sha256": "0" * 64,
431
+ "api_version": JAVA_API_VERSION,
432
+ "min_api_minor": java["min_api_minor"],
433
+ }
434
+
435
+ validate_manifest(manifest)
436
+ manifest.pop("java_module", None)
437
+ return project_dir, config, manifest
438
+
439
+
440
+ def list_files(project_dir, root_name, names):
441
+ require(isinstance(root_name, str), "Project root must be a string")
442
+ require(isinstance(names, list) and all(isinstance(name, str) for name in names),
443
+ "Project files must be an array of strings")
444
+ root = (project_dir / root_name).resolve()
445
+ require(root == project_dir or project_dir in root.parents, "Project root escapes the project")
446
+ result = []
447
+
448
+ for name in names:
449
+ destination = validate_path(name)
450
+ source = (root / name).resolve()
451
+ require(root in source.parents and source.is_file(), f"Missing project file: {source}")
452
+ require(source.stat().st_size <= MAX_FILE_BYTES, f"Project file exceeds 8 MiB: {source}")
453
+ result.append((destination, source.read_bytes()))
454
+
455
+ return result
456
+
457
+
458
+ def find_java(java_home):
459
+ homes = []
460
+
461
+ if java_home:
462
+ homes.append(Path(java_home))
463
+
464
+ if os.environ.get("JAVA_HOME"):
465
+ homes.append(Path(os.environ["JAVA_HOME"]))
466
+
467
+ for home in homes:
468
+ executable = home / "bin" / ("javac.exe" if os.name == "nt" else "javac")
469
+
470
+ if executable.is_file():
471
+ return executable
472
+
473
+ executable = shutil.which("javac")
474
+ require(executable is not None, "javac was not found; pass --java-home or set JAVA_HOME")
475
+ return Path(executable)
476
+
477
+
478
+ def find_android(android_sdk, build_tools):
479
+ candidates = []
480
+
481
+ if android_sdk:
482
+ candidates.append(Path(android_sdk))
483
+
484
+ for key in ("ANDROID_SDK_ROOT", "ANDROID_HOME"):
485
+ if os.environ.get(key):
486
+ candidates.append(Path(os.environ[key]))
487
+
488
+ candidates.extend([
489
+ Path.home() / "Library/Android/sdk",
490
+ Path.home() / "Android/Sdk",
491
+ Path(os.environ.get("LOCALAPPDATA", "")) / "Android/Sdk",
492
+ ])
493
+ sdk = next((item for item in candidates if (item / "platforms/android-36/android.jar").is_file()), None)
494
+ require(sdk is not None, "Android SDK platform 36 was not found; pass --android-sdk")
495
+ android_jar = sdk / "platforms/android-36/android.jar"
496
+
497
+ if build_tools:
498
+ tools = sdk / "build-tools" / build_tools
499
+ require(tools.is_dir(), f"Android build-tools {build_tools} were not found")
500
+ else:
501
+ roots = [item for item in (sdk / "build-tools").iterdir()
502
+ if item.is_dir() and all(part.isdigit() for part in item.name.split("."))
503
+ and item.name.split(".", 1)[0] in {"35", "36"}]
504
+ require(roots, "Android build-tools 35 or 36 were not found")
505
+ tools = sorted(roots, key=lambda item: tuple(int(part) for part in item.name.split(".")))[-1]
506
+
507
+ d8 = tools / ("d8.bat" if os.name == "nt" else "d8")
508
+ require(d8.is_file(), f"D8 was not found in {tools}")
509
+ return sdk, android_jar, tools, d8
510
+
511
+
512
+ def run(command, label):
513
+ process = subprocess.run([str(item) for item in command], text=True,
514
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
515
+
516
+ if process.returncode != 0:
517
+ raise DevkitError(f"{label} failed:\n{process.stdout.strip()}")
518
+
519
+
520
+ def api_artifact():
521
+ root = Path(__file__).with_name("artifacts")
522
+ artifact = root / f"fluxgram-java-api-{JAVA_API_VERSION}.{JAVA_API_MINOR}.jar"
523
+ metadata_path = root / "api.json"
524
+ require(artifact.is_file() and metadata_path.is_file(), "Bundled Java API artifact is missing")
525
+
526
+ try:
527
+ metadata = json.loads(metadata_path.read_text("utf-8"))
528
+ except (OSError, json.JSONDecodeError) as error:
529
+ raise DevkitError("Invalid Java API metadata") from error
530
+
531
+ fields = {
532
+ "api_version", "api_minor", "min_sdk", "compile_sdk", "sha256",
533
+ "source_sha256", "host_validator_sha256", "source_commit",
534
+ }
535
+ require(isinstance(metadata, dict) and set(metadata) == fields, "Invalid Java API metadata")
536
+ require(metadata["api_version"] == JAVA_API_VERSION and metadata["api_minor"] == JAVA_API_MINOR,
537
+ "Java API metadata version mismatch")
538
+ require(metadata["min_sdk"] == 24 and metadata["compile_sdk"] == 36,
539
+ "Java API Android metadata mismatch")
540
+ require(HASH.fullmatch(metadata["sha256"]) is not None
541
+ and HASH.fullmatch(metadata["source_sha256"]) is not None
542
+ and HASH.fullmatch(metadata["host_validator_sha256"]) is not None
543
+ and isinstance(metadata["source_commit"], str) and metadata["source_commit"],
544
+ "Invalid Java API metadata values")
545
+ require(hashlib.sha256(artifact.read_bytes()).hexdigest() == metadata["sha256"],
546
+ "Java API artifact digest mismatch")
547
+ return artifact, metadata
548
+
549
+
550
+ def zip_bytes(files):
551
+ output = io.BytesIO()
552
+
553
+ with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
554
+ for name, data in sorted(files):
555
+ info = zipfile.ZipInfo(name, FIXED_TIME)
556
+ info.create_system = 3
557
+ info.external_attr = (stat.S_IFREG | 0o644) << 16
558
+ info.compress_type = zipfile.ZIP_DEFLATED
559
+ archive.writestr(info, data)
560
+
561
+ output.seek(0)
562
+ return output.read()
563
+
564
+
565
+ def compile_java(project_dir, java_config, java_home, android_sdk, build_tools):
566
+ javac = find_java(java_home)
567
+ sdk, android_jar, tools, d8 = find_android(android_sdk, build_tools)
568
+ source_roots = java_config["sources"]
569
+ require(isinstance(source_roots, list) and source_roots, "java.sources must not be empty")
570
+ sources = []
571
+
572
+ for root_name in source_roots:
573
+ require(isinstance(root_name, str), "Java source root must be a string")
574
+ root = (project_dir / root_name).resolve()
575
+ require(project_dir in root.parents and root.is_dir(), f"Missing Java source root: {root}")
576
+ sources.extend(sorted(root.rglob("*.java")))
577
+
578
+ require(sources, "No Java sources were found")
579
+ api_jar, _ = api_artifact()
580
+
581
+ with tempfile.TemporaryDirectory(prefix="fluxgram-plugin-") as temporary:
582
+ temporary = Path(temporary)
583
+ classes = temporary / "classes"
584
+ classes.mkdir()
585
+ run([javac, "--release", "8", "-cp", os.pathsep.join([str(android_jar), str(api_jar)]),
586
+ "-d", classes, *sources], "javac")
587
+ class_files = [(str(path.relative_to(classes)).replace(os.sep, "/"), path.read_bytes())
588
+ for path in classes.rglob("*.class")]
589
+ require(class_files, "javac produced no classes")
590
+ input_jar = temporary / "plugin.jar"
591
+ input_jar.write_bytes(zip_bytes(class_files))
592
+ dex_dir = temporary / "dex"
593
+ dex_dir.mkdir()
594
+ run([d8, "--min-api", "24", "--lib", android_jar, "--classpath", api_jar,
595
+ "--output", dex_dir, input_jar], "D8")
596
+ dex_files = sorted(dex_dir.glob("*.dex"))
597
+ require([item.name for item in dex_files] == ["classes.dex"], "D8 produced unsupported multidex output")
598
+ data = dex_files[0].read_bytes()
599
+ require(data[:8] in DEX_MAGICS, "D8 produced a DEX version unsupported by FluxGram")
600
+ return data, javac, sdk, tools
601
+
602
+
603
+ def build_project(project_dir, output=None, java_home=None, android_sdk=None, build_tools=None):
604
+ project_dir, config, manifest = load_project(project_dir)
605
+ files = list_files(project_dir, config["python"]["root"], config["python"]["files"])
606
+ assets = config.get("assets", {"root": "assets", "files": []})
607
+ files.extend(list_files(project_dir, assets["root"], assets["files"]))
608
+ java = config.get("java")
609
+
610
+ if java is not None:
611
+ dex, _, _, _ = compile_java(project_dir, java, java_home, android_sdk, build_tools)
612
+ manifest["java_module"] = {
613
+ "path": "java/module.dex",
614
+ "entrypoint": java["entrypoint"],
615
+ "sha256": hashlib.sha256(dex).hexdigest(),
616
+ "api_version": JAVA_API_VERSION,
617
+ "min_api_minor": java["min_api_minor"],
618
+ }
619
+ validate_manifest(manifest)
620
+ files.append(("java/module.dex", dex))
621
+
622
+ manifest_data = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
623
+ files.append(("manifest.json", manifest_data))
624
+ names = [name for name, _ in files]
625
+ require(len(names) == len(set(names)), "Project maps multiple files to the same package path")
626
+ package = zip_bytes(files)
627
+ require(len(package) <= MAX_ARCHIVE_BYTES, "Archive exceeds 8 MiB")
628
+
629
+ if output is None:
630
+ output = project_dir / "dist" / f"{manifest['id']}-{manifest['version']}.fgplugin"
631
+ else:
632
+ output = Path(output).resolve()
633
+
634
+ output.parent.mkdir(parents=True, exist_ok=True)
635
+ output.write_bytes(package)
636
+ inspect_archive(output)
637
+ return output
638
+
639
+
640
+ def initialize_project(destination):
641
+ destination = Path(destination).resolve()
642
+ require(not destination.exists(), f"Destination already exists: {destination}")
643
+ template = Path(__file__).with_name("templates") / "python-java"
644
+ require(template.is_dir(), "Bundled project template is missing")
645
+ shutil.copytree(template, destination)
646
+ return destination
647
+
648
+
649
+ def doctor(java_home=None, android_sdk=None, build_tools=None):
650
+ javac = find_java(java_home)
651
+ sdk, android_jar, tools, d8 = find_android(android_sdk, build_tools)
652
+ artifact, _ = api_artifact()
653
+ print(f"javac: {javac}")
654
+ print(f"android.jar: {android_jar}")
655
+ print(f"build-tools: {tools}")
656
+ print(f"d8: {d8}")
657
+ print(f"java-api: {JAVA_API_VERSION}.{JAVA_API_MINOR} {artifact}")
658
+ print(f"android-sdk: {sdk}")
659
+
660
+
661
+ def parser():
662
+ value = argparse.ArgumentParser(prog="fluxgram-plugin")
663
+ commands = value.add_subparsers(dest="command", required=True)
664
+ init = commands.add_parser("init")
665
+ init.add_argument("destination")
666
+ build = commands.add_parser("build")
667
+ build.add_argument("project", nargs="?", default=".")
668
+ build.add_argument("--output")
669
+ validate = commands.add_parser("validate")
670
+ validate.add_argument("archive")
671
+ doctor_command = commands.add_parser("doctor")
672
+
673
+ for command in (build, doctor_command):
674
+ command.add_argument("--java-home")
675
+ command.add_argument("--android-sdk")
676
+ command.add_argument("--build-tools")
677
+
678
+ return value
679
+
680
+
681
+ def main(argv=None):
682
+ arguments = parser().parse_args(argv)
683
+
684
+ try:
685
+ if arguments.command == "init":
686
+ print(initialize_project(arguments.destination))
687
+ elif arguments.command == "build":
688
+ print(build_project(arguments.project, arguments.output, arguments.java_home,
689
+ arguments.android_sdk, arguments.build_tools))
690
+ elif arguments.command == "validate":
691
+ result = inspect_archive(arguments.archive)
692
+ print(f"{result['id']} {result['version']} {result['sha256']}")
693
+ else:
694
+ doctor(arguments.java_home, arguments.android_sdk, arguments.build_tools)
695
+ except DevkitError as error:
696
+ print(f"error: {error}", file=sys.stderr)
697
+ return 2
698
+
699
+ return 0
700
+
701
+
702
+ if __name__ == "__main__":
703
+ raise SystemExit(main())
@@ -0,0 +1,22 @@
1
+ [plugin]
2
+ id = "example.hello_plugin"
3
+ name = "Hello Plugin"
4
+ version = "1.0.0"
5
+ description = "Minimal FluxGram Python and Java plugin"
6
+ author = "Plugin Author"
7
+ min_sdk_minor = 9
8
+ declared_capabilities = []
9
+
10
+ [python]
11
+ root = "plugin"
12
+ entrypoint = "main:PluginEntry"
13
+ files = ["main.py"]
14
+
15
+ [java]
16
+ sources = ["java/src"]
17
+ entrypoint = "example.plugin.Module"
18
+ min_api_minor = 19
19
+
20
+ [assets]
21
+ root = "assets"
22
+ files = []
@@ -0,0 +1,26 @@
1
+ package example.plugin;
2
+
3
+ import org.telegram.messenger.plugins.api.JavaPluginContext;
4
+ import org.telegram.messenger.plugins.api.JavaPluginModule;
5
+
6
+ public final class Module implements JavaPluginModule {
7
+ private JavaPluginContext context;
8
+
9
+ @Override
10
+ public void onLoad(JavaPluginContext context) {
11
+ this.context = context;
12
+ context.log("Hello Plugin Java module loaded");
13
+ }
14
+
15
+ @Override
16
+ public void onSettingChanged(String key, Object value) {
17
+ if (context != null && "enabled".equals(key)) {
18
+ context.log("enabled=" + value);
19
+ }
20
+ }
21
+
22
+ @Override
23
+ public void onUnload() {
24
+ context = null;
25
+ }
26
+ }
@@ -0,0 +1,11 @@
1
+ from fluxgram import Plugin, SwitchSetting
2
+
3
+
4
+ class PluginEntry(Plugin):
5
+
6
+ settings_schema = (
7
+ SwitchSetting("enabled", "Enabled", default=True),
8
+ )
9
+
10
+ def on_load(self):
11
+ self.log("Hello Plugin loaded")
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: fluxgram-plugin-devkit
3
+ Version: 1.0.0
4
+ Summary: Build and validate FluxGram plugin packages
5
+ License-Expression: GPL-2.0-only
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+
9
+ # FluxGram Plugin Devkit
10
+
11
+ Installable command-line tools and Java API artifacts for building external FluxGram plugins.
12
+
13
+ ```sh
14
+ python3 -m pip install fluxgram-plugin-devkit
15
+ fluxgram-plugin doctor
16
+ ```
17
+
18
+ See the [developer kit guide](https://github.com/mrhexvel/fluxgram-plugin/blob/main/docs/developer-kit.md) for requirements, trust boundaries, project format, commands, and testing guidance.
@@ -0,0 +1,12 @@
1
+ fluxgram_devkit/__init__.py,sha256=ecmeC91h8032LinFnCCUhOQsovtgls_lnjsCkLTNqc4,149
2
+ fluxgram_devkit/cli.py,sha256=ulgJetXEuoYiLGCoe9GnWe9QWYkxy7GckJbIb4lE5HI,29116
3
+ fluxgram_devkit/artifacts/api.json,sha256=qPTYhm6QRAvix6MvPHEkIG8NCMgRdSsBkhtj5sQqNbs,374
4
+ fluxgram_devkit/artifacts/fluxgram-java-api-1.19.jar,sha256=ooVqb_pM4z-3YXIk_ZEeQRAyrWggDSV3mrHYCRdr3mI,25519
5
+ fluxgram_devkit/templates/python-java/fluxgram-plugin.toml,sha256=en3_sOPg2YDiZAAZU4l2X9-ME0tOUpoilZXRouAm83s,405
6
+ fluxgram_devkit/templates/python-java/java/src/example/plugin/Module.java,sha256=RH27sYlvOGgf4x60rIKIEg-CoaB4CkRDgqPut_b0wDo,672
7
+ fluxgram_devkit/templates/python-java/plugin/main.py,sha256=B087lAiXdqjTFRUJN9UOdQRAJHSSuE7YI6jgBh1JR7Q,226
8
+ fluxgram_plugin_devkit-1.0.0.dist-info/METADATA,sha256=09d4dLypC_-pe7ZTfvAJ6mXXURCIcEenomMRJi3zTGY,609
9
+ fluxgram_plugin_devkit-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ fluxgram_plugin_devkit-1.0.0.dist-info/entry_points.txt,sha256=Il1Sh5iqIS8qckBO0Lwqi7_EcFenbjWqhXL6CbXDYFU,61
11
+ fluxgram_plugin_devkit-1.0.0.dist-info/top_level.txt,sha256=WY5pDo5Gtg8QEp3cFbkryJj-JkXio6KUqJJ4zaQdm3I,16
12
+ fluxgram_plugin_devkit-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fluxgram-plugin = fluxgram_devkit.cli:main
@@ -0,0 +1 @@
1
+ fluxgram_devkit