openappx 0.6.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.
openappx/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """openappx — Linux-first Appx/MSIX packing toolkit."""
2
+
3
+ __version__ = "0.6.0"
openappx/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from openappx.cli import main
2
+
3
+ raise SystemExit(main())
openappx/blockmap.py ADDED
@@ -0,0 +1,240 @@
1
+ """AppxBlockMap generation (64 KiB SHA-256 blocks)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import os
8
+ import struct
9
+ import zlib
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ BLOCK_SIZE = 64 * 1024
15
+ HASH_METHOD = "http://www.w3.org/2001/04/xmlenc#sha256"
16
+ NS = "http://schemas.microsoft.com/appx/2010/blockmap"
17
+
18
+ # Files generated by the packer; never read from the layout as payload.
19
+ SKIP_NAMES = frozenset(
20
+ {
21
+ "[Content_Types].xml",
22
+ "AppxBlockMap.xml",
23
+ "AppxSignature.p7x",
24
+ "AppxMetadata\\CodeIntegrity.cat",
25
+ }
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class FileBlocks:
31
+ """One <File> entry: hashes over uncompressed blocks, sizes over compressed ones.
32
+
33
+ `compressed_sizes` is None for stored parts, where the format omits
34
+ Block/@Size entirely.
35
+ """
36
+
37
+ name: str
38
+ size: int
39
+ block_hashes: Sequence[bytes]
40
+ compressed_sizes: Sequence[int] | None = None
41
+
42
+
43
+ def package_path(rel: Path) -> str:
44
+ return rel.as_posix().lstrip("/").replace("/", "\\")
45
+
46
+
47
+ def hash_file_blocks(data: bytes) -> tuple[list[bytes], list[int]]:
48
+ """SHA-256 over each uncompressed 64 KiB block.
49
+
50
+ An empty file has no blocks at all — `<File Name="…" Size="0" LfhSize="…"/>`
51
+ with no children, as produced by Microsoft's own packer.
52
+ """
53
+ hashes: list[bytes] = []
54
+ sizes: list[int] = []
55
+ offset = 0
56
+ while offset < len(data):
57
+ chunk = data[offset : offset + BLOCK_SIZE]
58
+ hashes.append(hashlib.sha256(chunk).digest())
59
+ sizes.append(len(chunk))
60
+ offset += BLOCK_SIZE
61
+ return hashes, sizes
62
+
63
+
64
+ def deflate_blocks(data: bytes, level: int = 6) -> tuple[bytes, list[int]]:
65
+ """Deflate `data` one 64 KiB block at a time.
66
+
67
+ Returns the raw deflate stream and the compressed length of each block.
68
+ Z_FULL_FLUSH terminates every block so its compressed extent is known — that
69
+ length is what Block/@Size reports. The final `flush()` emits a 2-byte
70
+ end-of-stream marker belonging to no block, which is why the block sizes sum
71
+ to slightly less than the entry's compressed size.
72
+ """
73
+ compressor = zlib.compressobj(level, zlib.DEFLATED, -zlib.MAX_WBITS)
74
+ stream = bytearray()
75
+ sizes: list[int] = []
76
+ for offset in range(0, len(data), BLOCK_SIZE):
77
+ block = compressor.compress(data[offset : offset + BLOCK_SIZE])
78
+ block += compressor.flush(zlib.Z_FULL_FLUSH)
79
+ stream += block
80
+ sizes.append(len(block))
81
+ stream += compressor.flush()
82
+ return bytes(stream), sizes
83
+
84
+
85
+ def zip_local_header_size(name_utf8: bytes, extra: bytes = b"") -> int:
86
+ return 30 + len(name_utf8) + len(extra)
87
+
88
+
89
+ LFH_SIGNATURE = 0x04034B50
90
+ LFH_STRUCT = "<IHHHHHIIIHH"
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class LocalHeader:
95
+ """A ZIP local file header as actually written to the archive."""
96
+
97
+ name: bytes
98
+ flag_bits: int
99
+ size: int # 30 + name length + extra length, i.e. the blockmap's LfhSize
100
+ extra_len: int
101
+
102
+
103
+ def read_local_header(archive: bytes, offset: int) -> LocalHeader:
104
+ """Parse the local file header at `offset` in a raw ZIP archive.
105
+
106
+ Used to check declared LfhSize against reality: `zip_local_header_size`
107
+ assumes no extra fields, and nothing in `zipfile` guarantees that.
108
+ """
109
+ fields = struct.unpack(LFH_STRUCT, archive[offset : offset + 30])
110
+ sig, _ver, flag, _comp, _time, _date, _crc, _csize, _size, name_len, extra_len = (
111
+ fields
112
+ )
113
+ if sig != LFH_SIGNATURE:
114
+ raise ValueError(f"no local file header at offset {offset}")
115
+ return LocalHeader(
116
+ name=archive[offset + 30 : offset + 30 + name_len],
117
+ flag_bits=flag,
118
+ size=30 + name_len + extra_len,
119
+ extra_len=extra_len,
120
+ )
121
+
122
+
123
+ def collect_files(root: Path) -> list[Path]:
124
+ files: list[Path] = []
125
+ for dirpath, _, filenames in os.walk(root):
126
+ for fn in filenames:
127
+ p = Path(dirpath) / fn
128
+ name = package_path(p.relative_to(root))
129
+ if name in SKIP_NAMES:
130
+ continue
131
+ files.append(p)
132
+ files.sort(key=lambda p: package_path(p.relative_to(root)).lower())
133
+ return files
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class PreparedFile:
138
+ """A payload file ready to be written: blockmap entry plus archive bytes."""
139
+
140
+ blocks: FileBlocks
141
+ payload: bytes # what goes into the archive (deflated or verbatim)
142
+ deflated: bool
143
+
144
+
145
+ def prepare_file(name: str, data: bytes, level: int = 6) -> PreparedFile:
146
+ """Hash and compress one payload file.
147
+
148
+ Deflate is used only when it actually pays off; otherwise the part is stored
149
+ and Block/@Size is omitted, matching what Microsoft's packer does for
150
+ already-compressed assets.
151
+ """
152
+ hashes, _ = hash_file_blocks(data)
153
+ stream, compressed_sizes = deflate_blocks(data, level)
154
+
155
+ if data and len(stream) < len(data):
156
+ return PreparedFile(
157
+ blocks=FileBlocks(name, len(data), hashes, compressed_sizes),
158
+ payload=stream,
159
+ deflated=True,
160
+ )
161
+ return PreparedFile(
162
+ blocks=FileBlocks(name, len(data), hashes, None),
163
+ payload=data,
164
+ deflated=False,
165
+ )
166
+
167
+
168
+ def _xml_escape(s: str) -> str:
169
+ return (
170
+ s.replace("&", "&amp;")
171
+ .replace("<", "&lt;")
172
+ .replace(">", "&gt;")
173
+ .replace('"', "&quot;")
174
+ )
175
+
176
+
177
+ def render_blockmap_xml(entries: Sequence[FileBlocks], lfh_sizes: dict) -> bytes:
178
+ lines = [
179
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
180
+ f'<BlockMap xmlns="{NS}" HashMethod="{HASH_METHOD}">',
181
+ ]
182
+ for e in entries:
183
+ lfh = lfh_sizes[e.name]
184
+ head = f' <File Name="{_xml_escape(e.name)}" Size="{e.size}" LfhSize="{lfh}"'
185
+ if not e.block_hashes: # empty file: no blocks at all
186
+ lines.append(head + "/>")
187
+ continue
188
+
189
+ lines.append(head + ">")
190
+ for i, h in enumerate(e.block_hashes):
191
+ b64 = base64.b64encode(h).decode("ascii")
192
+ if e.compressed_sizes is None: # stored part: no compressed length
193
+ lines.append(f' <Block Hash="{b64}"/>')
194
+ else:
195
+ lines.append(
196
+ f' <Block Hash="{b64}" Size="{e.compressed_sizes[i]}"/>'
197
+ )
198
+ lines.append(" </File>")
199
+ lines.append("</BlockMap>")
200
+ return ("\r\n".join(lines) + "\r\n").encode("utf-8")
201
+
202
+
203
+ def content_types_xml(package_paths: Sequence[str]) -> bytes:
204
+ defaults = {
205
+ "xml": "application/vnd.ms-appx.manifest+xml",
206
+ "png": "image/png",
207
+ "jpg": "image/jpeg",
208
+ "jpeg": "image/jpeg",
209
+ "json": "application/json",
210
+ "txt": "text/plain",
211
+ "dll": "application/x-msdownload",
212
+ "exe": "application/x-msdownload",
213
+ "winmd": "application/octet-stream",
214
+ "p7x": "application/vnd.ms-appx.signature",
215
+ }
216
+ for p in package_paths:
217
+ ext = Path(p.replace("\\", "/")).suffix.lstrip(".").lower()
218
+ if ext and ext not in defaults:
219
+ defaults[ext] = "application/octet-stream"
220
+
221
+ lines = [
222
+ '<?xml version="1.0" encoding="utf-8"?>',
223
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
224
+ ]
225
+ for ext, ctype in sorted(defaults.items()):
226
+ lines.append(f' <Default Extension="{ext}" ContentType="{ctype}"/>')
227
+ lines.append(
228
+ ' <Override PartName="/AppxManifest.xml" '
229
+ 'ContentType="application/vnd.ms-appx.manifest+xml"/>'
230
+ )
231
+ lines.append(
232
+ ' <Override PartName="/AppxBlockMap.xml" '
233
+ 'ContentType="application/vnd.ms-appx.blockmap+xml"/>'
234
+ )
235
+ lines.append(
236
+ ' <Override PartName="/AppxSignature.p7x" '
237
+ 'ContentType="application/vnd.ms-appx.signature"/>'
238
+ )
239
+ lines.append("</Types>")
240
+ return ("\r\n".join(lines) + "\r\n").encode("utf-8")
openappx/bundle.py ADDED
@@ -0,0 +1,346 @@
1
+ """Build an `.msixbundle` from finished `.msix` packages.
2
+
3
+ A bundle is the same OPC ZIP container as a package, and this module reuses the
4
+ writer in `pack_core` unchanged. What differs is what goes inside, and every
5
+ point below was read off Microsoft's own reference bundles
6
+ (`src/test/testData/unpack/bundles` upstream), not derived from the schema:
7
+
8
+ - The manifest is `AppxMetadata/AppxBundleManifest.xml`, root element `<Bundle>`
9
+ in the 2013 namespace — not `AppxManifest.xml`.
10
+ - **`AppxBlockMap.xml` covers only that manifest.** A package blockmap hashes
11
+ every payload file; a bundle blockmap has exactly one `<File>`. The payload
12
+ packages carry their own blockmaps and their own signatures.
13
+ - Payload packages are **stored**, never deflated. Their `Offset` in the bundle
14
+ manifest points at the file *data* — the local header offset plus `LfhSize` —
15
+ so it can only be filled in after the archive is laid out.
16
+ - `[Content_Types].xml` maps `appx` and declares the `xml` default as
17
+ `bundlemanifest+xml`, where a package says `manifest+xml`.
18
+
19
+ Deliberately not copied: the reference bundles write data descriptors after each
20
+ entry (24 bytes, `PK\\x07\\x08`). They are optional, our writer emits none, and
21
+ `pack_core.append_stored_part` refuses archives that have them.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ import zipfile
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+
31
+ from openappx.blockmap import (
32
+ _xml_escape,
33
+ prepare_file,
34
+ render_blockmap_xml,
35
+ zip_local_header_size,
36
+ )
37
+ from openappx.pack_core import (
38
+ _METHOD_DEFLATE,
39
+ _METHOD_STORE,
40
+ _Entry,
41
+ _write_central_directory,
42
+ _write_entry,
43
+ zip64_local_extra,
44
+ )
45
+
46
+ BUNDLE_MANIFEST = "AppxMetadata/AppxBundleManifest.xml"
47
+ BUNDLE_NS = "http://schemas.microsoft.com/appx/2013/bundle"
48
+ BLOCKMAP = "AppxBlockMap.xml"
49
+ CONTENT_TYPES = "[Content_Types].xml"
50
+
51
+ # The bundle version is the packages' version with the revision zeroed, which is
52
+ # what MakeAppx does when it is not told otherwise.
53
+ _VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)\.(\d+)$")
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class PackageEntry:
58
+ """One `.msix` about to go into a bundle, as its own manifest describes it."""
59
+
60
+ path: Path
61
+ name: str
62
+ publisher: str
63
+ version: str
64
+ architecture: str | None # absent for a resource package
65
+ languages: tuple[str, ...]
66
+ resource_id: str
67
+ kind: str # "application" or "resource"
68
+ signed: bool
69
+
70
+
71
+ def _identity(manifest: str, attribute: str) -> str | None:
72
+ element = re.search(r"<Identity\b([^>]*)>", manifest)
73
+ if not element:
74
+ return None
75
+ found = re.search(rf'\b{attribute}\s*=\s*"([^"]*)"', element.group(1))
76
+ return found.group(1) if found else None
77
+
78
+
79
+ def read_package(path: Path) -> PackageEntry:
80
+ """Describe a packed `.msix` well enough to list it in a bundle manifest."""
81
+ path = Path(path).resolve()
82
+ if not zipfile.is_zipfile(path):
83
+ raise ValueError(f"not a ZIP/MSIX container: {path}")
84
+ with zipfile.ZipFile(path) as zf:
85
+ names = zf.namelist()
86
+ if "AppxManifest.xml" not in names:
87
+ raise ValueError(f"{path.name} has no AppxManifest.xml")
88
+ manifest = zf.read("AppxManifest.xml").decode("utf-8", errors="replace")
89
+ signed = "AppxSignature.p7x" in names
90
+ # Comments in these manifests describe the very attributes being read.
91
+ manifest = re.sub(r"<!--.*?-->", "", manifest, flags=re.DOTALL)
92
+
93
+ missing = [
94
+ attribute
95
+ for attribute in ("Name", "Publisher", "Version")
96
+ if not _identity(manifest, attribute)
97
+ ]
98
+ if missing:
99
+ raise ValueError(f"{path.name}: Identity/@{', @'.join(missing)} missing")
100
+
101
+ languages = tuple(re.findall(r'<Resource\b[^>]*Language="([^"]+)"', manifest))
102
+ # `Identity/@ResourceId` is what makes a package a resource package — not the
103
+ # absence of <Applications>, which was the obvious guess and is wrong: a
104
+ # device answers 0x80080204 "its package type doesn't match the value found
105
+ # in the bundle manifest". Microsoft's own resource packages pair it with
106
+ # <Properties><ResourcePackage>true, and carry no ProcessorArchitecture.
107
+ resource_id = _identity(manifest, "ResourceId")
108
+ kind = "resource" if resource_id else "application"
109
+ architecture = _identity(manifest, "ProcessorArchitecture")
110
+ return PackageEntry(
111
+ path=path,
112
+ name=_identity(manifest, "Name") or "",
113
+ publisher=_identity(manifest, "Publisher") or "",
114
+ version=_identity(manifest, "Version") or "",
115
+ architecture=None if kind == "resource" else (architecture or "neutral"),
116
+ languages=languages or ("en-us",),
117
+ resource_id=resource_id or "",
118
+ kind=kind,
119
+ signed=signed,
120
+ )
121
+
122
+
123
+ def bundle_version(packages: list[PackageEntry]) -> str:
124
+ """Highest package version with the revision zeroed, as MakeAppx does."""
125
+ parsed = [
126
+ tuple(int(n) for n in m.groups())
127
+ for m in (_VERSION_RE.match(p.version) for p in packages)
128
+ if m
129
+ ]
130
+ if not parsed:
131
+ return "1.0.0.0"
132
+ major, minor, build, _ = max(parsed)
133
+ return f"{major}.{minor}.{build}.0"
134
+
135
+
136
+ def bundle_problems(packages: list[PackageEntry]) -> list[str]:
137
+ """Reasons these packages cannot go in one bundle (empty list => they can).
138
+
139
+ Identity has to agree: the bundle names one application, and a device
140
+ resolves the packages inside it against that single identity. Two packages
141
+ claiming the same architecture and resource are a mistake for the same
142
+ reason — nothing could choose between them.
143
+
144
+ Deliberately not required: an application package. A bundle of nothing but
145
+ resource packages is legal — upstream ships one as `ContainsOnlyResourcePackages`
146
+ — and is how language packs are distributed.
147
+ """
148
+ problems = []
149
+ names = {p.name for p in packages}
150
+ if len(names) > 1:
151
+ problems.append(
152
+ f"packages disagree on Identity/@Name: {', '.join(sorted(names))}"
153
+ )
154
+ publishers = {p.publisher for p in packages}
155
+ if len(publishers) > 1:
156
+ problems.append(
157
+ f"packages disagree on Identity/@Publisher: {', '.join(sorted(publishers))}"
158
+ )
159
+ seen: dict[tuple, Path] = {}
160
+ for p in packages:
161
+ key = (p.kind, p.architecture, p.resource_id)
162
+ if key in seen:
163
+ problems.append(
164
+ f"{p.path.name} and {seen[key].name} describe the same "
165
+ f"architecture and resource: "
166
+ f"{p.architecture or 'neutral'}/{p.resource_id}"
167
+ )
168
+ seen[key] = p.path
169
+ if not packages:
170
+ problems.append("a bundle needs at least one package")
171
+ return problems
172
+
173
+
174
+ def unsigned_payloads(packages: list[PackageEntry]) -> list[str]:
175
+ """Payload packages carrying no signature of their own.
176
+
177
+ Not a `bundle_problems` entry, on purpose. Signing needs the optional
178
+ `[sign]` extra, and bundling must keep working without it — assembling the
179
+ container is mechanical. It is still a certain install failure, so the CLI
180
+ warns and `inspect` reports it on the finished bundle, which is where this
181
+ codebase puts checks that need the artefact to exist.
182
+
183
+ Signing the bundle is not enough: a device answers 0x800B0100 against the
184
+ *bundle*, which reads as if the bundle itself were unsigned. Measured by
185
+ signing one and not the other.
186
+ """
187
+ return sorted(p.path.name for p in packages if not p.signed)
188
+
189
+
190
+ def render_bundle_manifest(
191
+ packages: list[PackageEntry], offsets: dict[str, int], version: str
192
+ ) -> bytes:
193
+ """The `<Bundle>` document, with each package's offset inside the archive.
194
+
195
+ Rendered as bytes for the same reason the blockmap is: reference tooling
196
+ compares byte for byte, and a serialiser would reorder attributes.
197
+ """
198
+ first = packages[0]
199
+ lines = [
200
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
201
+ f'<Bundle SchemaVersion="2.0" xmlns="{BUNDLE_NS}">',
202
+ f' <Identity Name="{_xml_escape(first.name)}" '
203
+ f'Publisher="{_xml_escape(first.publisher)}" Version="{version}"/>',
204
+ " <Packages>",
205
+ ]
206
+ for p in packages:
207
+ attributes = [f'Type="{p.kind}"', f'Version="{p.version}"']
208
+ if p.architecture:
209
+ attributes.append(f'Architecture="{p.architecture}"')
210
+ # Only resource packages carry a ResourceId. A real Store bundle omits it
211
+ # on application packages, and a device that finds one there stops
212
+ # matching them by architecture — reporting that the bundle "does not
213
+ # have an appropriate application package for x64", measured.
214
+ if p.kind == "resource":
215
+ attributes.append(f'ResourceId="{_xml_escape(p.resource_id)}"')
216
+ attributes += [
217
+ f'FileName="{_xml_escape(p.path.name)}"',
218
+ f'Offset="{offsets[p.path.name]}"',
219
+ f'Size="{p.path.stat().st_size}"',
220
+ ]
221
+ lines.append(f" <Package {' '.join(attributes)}>")
222
+ lines.append(" <Resources>")
223
+ for language in p.languages:
224
+ lines.append(f' <Resource Language="{_xml_escape(language)}"/>')
225
+ lines.append(" </Resources>")
226
+ lines.append(" </Package>")
227
+ lines += [" </Packages>", "</Bundle>"]
228
+ return ("\r\n".join(lines) + "\r\n").encode("utf-8")
229
+
230
+
231
+ def bundle_content_types() -> bytes:
232
+ """`xml` defaults to bundlemanifest+xml here, where a package says manifest+xml."""
233
+ return (
234
+ "\r\n".join(
235
+ [
236
+ '<?xml version="1.0" encoding="utf-8"?>',
237
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/'
238
+ 'content-types">',
239
+ ' <Default Extension="appx" ContentType="application/vnd.ms-appx"/>',
240
+ ' <Default Extension="msix" ContentType="application/vnd.ms-appx"/>',
241
+ ' <Default Extension="xml" '
242
+ 'ContentType="application/vnd.ms-appx.bundlemanifest+xml"/>',
243
+ ' <Override PartName="/AppxBlockMap.xml" '
244
+ 'ContentType="application/vnd.ms-appx.blockmap+xml"/>',
245
+ ' <Override PartName="/AppxSignature.p7x" '
246
+ 'ContentType="application/vnd.ms-appx.signature"/>',
247
+ "</Types>",
248
+ ]
249
+ )
250
+ + "\r\n"
251
+ ).encode("utf-8")
252
+
253
+
254
+ def main(argv: list[str] | None = None) -> int:
255
+ import argparse
256
+ import sys
257
+
258
+ ap = argparse.ArgumentParser(
259
+ prog="openappx bundle",
260
+ description="Combine .msix packages into one .msixbundle",
261
+ )
262
+ ap.add_argument(
263
+ "--package",
264
+ action="append",
265
+ required=True,
266
+ type=Path,
267
+ dest="packages",
268
+ help="a packed .msix to include; repeat for each architecture",
269
+ )
270
+ ap.add_argument("--out", required=True, type=Path)
271
+ args = ap.parse_args(argv)
272
+
273
+ missing = [p for p in args.packages if not p.is_file()]
274
+ if missing:
275
+ for p in missing:
276
+ print(f"error: no such package: {p}", file=sys.stderr)
277
+ return 2
278
+ unsigned = unsigned_payloads([read_package(p) for p in args.packages])
279
+ if unsigned:
280
+ print(
281
+ "warning: these payload packages are unsigned, and a device will "
282
+ "refuse the bundle: " + ", ".join(unsigned),
283
+ file=sys.stderr,
284
+ )
285
+ try:
286
+ out = build_bundle(args.packages, args.out)
287
+ except ValueError as e:
288
+ print(f"error: {e}", file=sys.stderr)
289
+ return 2
290
+ print(
291
+ f"Wrote {out} ({out.stat().st_size} bytes) from {len(args.packages)} packages"
292
+ )
293
+ print("Reminder: sign the bundle itself — the packages inside are not enough.")
294
+ return 0
295
+
296
+
297
+ def build_bundle(packages: list[Path], out: Path) -> Path:
298
+ """Write `out` as a bundle carrying `packages`, each stored verbatim."""
299
+ entries = [read_package(p) for p in packages]
300
+ problems = bundle_problems(entries)
301
+ if problems:
302
+ raise ValueError("; ".join(problems))
303
+
304
+ archive = bytearray()
305
+ written: list[_Entry] = []
306
+ offsets: dict[str, int] = {}
307
+ for entry in entries:
308
+ data = entry.path.read_bytes()
309
+ header_offset = len(archive)
310
+ written.append(
311
+ _write_entry(archive, entry.path.name, data, data, _METHOD_STORE)
312
+ )
313
+ # Offset is where the data starts, not where the record does.
314
+ offsets[entry.path.name] = header_offset + zip_local_header_size(
315
+ entry.path.name.encode("utf-8"), zip64_local_extra(len(data), len(data))
316
+ )
317
+
318
+ version = bundle_version(entries)
319
+ manifest = render_bundle_manifest(entries, offsets, version)
320
+ prepared = prepare_file(BUNDLE_MANIFEST.replace("/", "\\"), manifest)
321
+ lfh_size = zip_local_header_size(
322
+ BUNDLE_MANIFEST.encode("utf-8"),
323
+ zip64_local_extra(len(manifest), len(prepared.payload)),
324
+ )
325
+ # The blockmap covers this one file, so the package renderer does the job
326
+ # unchanged — the difference between a bundle and a package here is what is
327
+ # in the list, not how it is written.
328
+ blockmap = render_blockmap_xml([prepared.blocks], {prepared.blocks.name: lfh_size})
329
+
330
+ written.append(
331
+ _write_entry(
332
+ archive,
333
+ BUNDLE_MANIFEST,
334
+ prepared.payload,
335
+ manifest,
336
+ _METHOD_DEFLATE if prepared.deflated else _METHOD_STORE,
337
+ )
338
+ )
339
+ for name, data in ((CONTENT_TYPES, bundle_content_types()), (BLOCKMAP, blockmap)):
340
+ written.append(_write_entry(archive, name, data, data, _METHOD_STORE))
341
+
342
+ _write_central_directory(archive, written)
343
+ out = Path(out).resolve()
344
+ out.parent.mkdir(parents=True, exist_ok=True)
345
+ out.write_bytes(bytes(archive))
346
+ return out
openappx/cli.py ADDED
@@ -0,0 +1,56 @@
1
+ """Unified `openappx` console entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main(argv: list[str] | None = None) -> int:
9
+ argv = list(sys.argv[1:] if argv is None else argv)
10
+ if not argv or argv[0] in ("-h", "--help"):
11
+ print("usage: openappx <pack|bundle|unpack|sign|validate|inspect|deploy> …")
12
+ print(" openappx pack --root DIR --out FILE.msix")
13
+ print(" openappx bundle --package A.msix --package B.msix --out X.msixbundle")
14
+ print(" openappx unpack --package FILE.msix --out DIR")
15
+ print(" openappx sign --package FILE.msix --pfx CERT.pfx")
16
+ print(" openappx validate --root DIR")
17
+ print(" openappx inspect --package FILE.msix [--json]")
18
+ print(" openappx deploy --device URL --user NAME --package FILE.msix")
19
+ return 0 if argv else 2
20
+
21
+ cmd, rest = argv[0], argv[1:]
22
+ if cmd == "pack":
23
+ from openappx.pack import main as pack_main
24
+
25
+ return pack_main(rest)
26
+ if cmd == "bundle":
27
+ from openappx.bundle import main as bundle_main
28
+
29
+ return bundle_main(rest)
30
+ if cmd == "validate":
31
+ from openappx.validate import main as validate_main
32
+
33
+ return validate_main(rest)
34
+ if cmd == "inspect":
35
+ from openappx.inspect import main as inspect_main
36
+
37
+ return inspect_main(rest)
38
+ if cmd == "unpack":
39
+ from openappx.unpack import main as unpack_main
40
+
41
+ return unpack_main(rest)
42
+ if cmd == "sign":
43
+ from openappx.sign.cli import main as sign_main
44
+
45
+ return sign_main(rest)
46
+ if cmd == "deploy":
47
+ from openappx.deploy import main as deploy_main
48
+
49
+ return deploy_main(rest)
50
+
51
+ print(f"unknown command: {cmd}", file=sys.stderr)
52
+ return 2
53
+
54
+
55
+ if __name__ == "__main__":
56
+ raise SystemExit(main())