feat: add shared Rust MSIX engine and CLI

Closes #2
This commit is contained in:
jychoi4 committed 2026-11-02 13:44:00 +09:00
1 parent 24b624301c
commit 6213fec7d2
29 files changed
+4058 -5

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
"""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()