"""Prepare ISM-derived inputs for the existing MSIX Studio GUI/CLI. This script does not run MakeAppx, install software, or modify source files. Packaging and verification must be performed by package-builder. """ import argparse import collections import csv import hashlib import json from pathlib import Path, PureWindowsPath import re import subprocess import xml.etree.ElementTree as ET def write_json(path, value): path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def prepare_payload(out, mappings): """Copy only resolved ISM files; never hard-link or edit the build outputs.""" payload = out / "payload" payload.mkdir() for index, item in enumerate(mappings, 1): source = Path(item["source"]) target = payload / item["target"] if not target.resolve().is_relative_to(payload.resolve()): raise ValueError("Payload target escaped workspace") target.parent.mkdir(parents=True, exist_ok=True) before = source.stat() digest = hashlib.sha256() with source.open("rb") as src, target.open("xb") as dst: while chunk := src.read(1024 * 1024): digest.update(chunk) dst.write(chunk) after = source.stat() if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns): raise ValueError(f"Source changed while copying: {source}") with target.open("rb") as check: if hashlib.file_digest(check, "sha256").hexdigest() != digest.hexdigest(): raise ValueError(f"Copy mismatch: {source}") item["preparedSource"] = str(target) item["sha256"] = digest.hexdigest() if index % 500 == 0: print(f"Prepared {index}/{len(mappings)}", flush=True) return payload def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--makeappx", type=Path, required=True) args = parser.parse_args() repo, out = args.repo.resolve(), args.output.resolve() if out.exists(): raise ValueError(f"Output already exists: {out}") if not args.makeappx.is_file(): raise ValueError("MakeAppx not found") ism = repo / "InstallShield/src/EG-BIM_V1.13.1.ism" doc = ET.parse(ism).getroot() def table(name): t = doc.find(f"table[@name='{name}']") if t is None: return [] cols = [c.text for c in t.findall("col")] return [dict(zip(cols, [c.text or "" for c in row])) for row in t.findall("row")] strings = {x["ISString"]: x["Value"] for x in table("ISString") if x["ISLanguage_"] == "1042"} props = {x["Property"]: re.sub(r"##(.*?)##", lambda m: strings.get(m[1], m[0]), x["Value"]) for x in table("Property")} build = repo / "Build/EG-BIM/EG-BIM.Pro.64bit" viewer = repo / "CoWork/Import/EgViewer" engine = repo / "Source/Engine/IntelliCAD/Source" bindings = { "PATH_TO_GRM_FILES": build, "PATH_TO_EGVIEWER_FILES": viewer, "PATH_TO_RESOURCES_FILES1": viewer / "Resources", "PATH_TO_ICON_FILES": engine / "Resources/Common/brand", "PATH_TO_SPELLING_FILES": engine / "Resources/Localized/ENU/Spelling", "PATH_TO_TABLET_FILES": engine / "IntelliCAD/Installer/3rdParty/Tablet", "PATH_TO_TEXTURES_FILES": engine / "IntelliCAD/IRX/RenderEngine/Textures", } dirs = {x["Directory"]: x for x in table("Directory")} comps = {x["Component"]: x for x in table("Component")} def directory(key, stack=()): if key in stack: raise ValueError("Directory cycle") if key == "ProgramFiles64Folder": return "VFS/ProgramFilesX64" if key == "AppDataFolder": # MSIX Studio currently has no first-launch AppData migration handler. # Preserve these inputs explicitly as seeds, NOT as a fictitious VFS mapping. return "UserDataSeeds/Roaming" d = dirs[key] part = d["DefaultDir"].split(":")[0].split("|")[-1] return directory(d["Directory_Parent"], (*stack, key)) + ("/" + part if part != "." else "") groups, mappings, seen = collections.OrderedDict(), [], set() source_counts = collections.Counter() for row in table("File"): match = re.fullmatch(r"<([^>]+)>\\?(.*)", row["ISBuildSourcePath"]) if not match or match[1] not in bindings: raise ValueError(f"Unsupported ISM source: {row['File']}") source_root = bindings[match[1]] source = source_root / match[2] reason = "path-variable-remap" if not source.is_file() and match[1] == "PATH_TO_GRM_FILES" and match[2].lower().startswith("icon\\"): source_root = bindings["PATH_TO_ICON_FILES"] source = source_root / PureWindowsPath(match[2]).name reason = "brand-icon-resource-fallback" if not source.is_file() and match[2].lower() == "fonts\\kosdic.shx": source_root = repo / "Source/Resources/CadFonts" source = source_root / "KOSDIC.SHX" reason = "cad-font-resource-fallback" if not source.is_file(): raise FileNotFoundError(f"Unresolved ISM file {row['File']}: {source}") source = source.resolve() if not source.is_relative_to(repo): raise ValueError("Source escaped repository") for ancestor in (source, *source.parents): if ancestor.is_symlink() or (hasattr(ancestor, "is_junction") and ancestor.is_junction()): raise ValueError(f"Linked source: {ancestor}") target = directory(comps[row["Component_"]]["Directory_"]) + "/" + row["FileName"].split("|")[-1] relative = source.relative_to(source_root).as_posix() if not target.lower().endswith("/" + relative.lower()): # Preserve MSI destination instead of flattening files or guessing by basename. source_root = source.parent relative = source.name if not target.lower().endswith("/" + relative.lower()): raise ValueError(f"File rename requires an explicit adapter: {row['File']}") prefix = target[:-(len(relative) + 1)] actual_target = prefix + "/" + relative if actual_target.lower() in seen: raise ValueError(f"Duplicate target: {actual_target}") if any(ch in relative for ch in "[]{}*?"): raise ValueError(f"Literal glob escaping required: {relative}") seen.add(actual_target.lower()) groups.setdefault((str(source_root), prefix), []).append(relative) source_counts[match[1]] += 1 mappings.append({"ismFileId": row["File"], "ismSource": row["ISBuildSourcePath"], "source": str(source), "target": actual_target, "ismDirectoryId": comps[row["Component_"]]["Directory_"], "bytes": source.stat().st_size, "resolution": reason, "placement": "appdata-seed-only" if target.startswith("UserDataSeeds/") else "package-vfs"}) out.mkdir(parents=True) (out / "reports").mkdir() (out / "assets").mkdir() subprocess.run(["powershell.exe", "-NoProfile", "-File", str(Path(__file__).with_name("New-EgbimSampleAssets.ps1")), "-IconPath", str(engine / "Resources/Common/brand/Grimi.ico"), "-OutputFolder", str(out / "assets")], check=True) payload = prepare_payload(out, mappings) # A single directory rule avoids compiling thousands of literal glob patterns. sources = [{"alias": "ism-payload"}] rules = [{"id": "ism-payload", "source": "ism-payload", "include": ["**/*"], "exclude": [], "targetRelativePath": "", "required": True}] local_sources = {"ism-payload": str(payload)} sources.append({"alias": "sample-assets"}) local_sources["sample-assets"] = str(out / "assets") rules.append({"id": "sample-assets", "source": "sample-assets", "include": ["Logo44.png", "Logo150.png"], "exclude": [], "targetRelativePath": "Assets", "required": True}) project = { "schemaVersion": 2, "projectId": "egbim-ism-msix-sample", "product": {"displayName": "EG-BIM MSIX Sample", "manufacturer": props["Manufacturer"], "displayVersion": props["ProductVersion"]}, "sources": sources, "files": rules, "formats": {"msix": { "identity": {"name": "Baron.EGBIM.Sample", "publisher": "CN=Baron MSIX Sample", "version": props["ProductVersion"], "architecture": "x64"}, "minimumOsVersion": "10.0.19041.0", "applications": [{"id": "EGBIM", "executable": "VFS/ProgramFilesX64/Baron/EG-BIM/Grimi.exe", "entryPoint": "Windows.FullTrustApplication", "square44x44Logo": "Assets/Logo44.png", "square150x150Logo": "Assets/Logo150.png"}], "capabilities": ["runFullTrust"], "extensions": [], "dependencies": []}}, "targets": [{"id": "msix-direct", "format": "msix", "delivery": "package", "distribution": "direct", "signingProfileRef": None}]} write_json(out / "package-project.json", project) write_json(out / "package-project.local.json", {"sources": local_sources, "makeAppx": str(args.makeappx.resolve())}) write_json(out / "reports/file-mapping.json", mappings) with (out / "reports/file-mapping.csv").open("w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=list(mappings[0])); writer.writeheader(); writer.writerows(mappings) write_json(out / "reports/registry-not-converted.json", {"applied": False, "rows": table("Registry"), "properties": props}) write_json(out / "reports/legacy-actions-not-executed.json", { "customActions": table("CustomAction"), "prerequisites": table("ISSetupPrerequisites"), "features": table("Feature"), "featureComponents": table("FeatureComponents"), "conditions": table("Condition"), "shortcuts": table("Shortcut")}) summary = {"ism": str(ism), "ismSha256": hashlib.file_digest(ism.open("rb"), "sha256").hexdigest(), "buildRoot": str(build), "viewerRoot": str(viewer), "ismFileCount": len(mappings), "resolvedFileCount": len(mappings), "missingFileCount": 0, "sourceBytes": sum(x["bytes"] for x in mappings), "ruleCount": len(rules), "appDataSeedFiles": sum(x["placement"] == "appdata-seed-only" for x in mappings), "sourceCounts": dict(source_counts), "registryRowsNotApplied": len(table("Registry")), "signing": "unsignedDevelopment", "installationTest": "notRun", "deploymentReady": False, "limitations": ["No registry.dat / packaged COM / file associations generated by current builder", "EgViewer AppData files are preserved as UserDataSeeds; no migration/activation is implemented", "InstallShield CustomActions and prerequisite installers are not run", "Runtime dependencies and first launch require a separate VM compatibility test", "Sample identity/publisher are not production signing or Store identity"]} write_json(out / "reports/preparation.json", summary) print(json.dumps(summary, ensure_ascii=True)) if __name__ == "__main__": main()