From e070bd0f1edcde81862a28356b03c24f02ee6980 Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Sun, 11 Oct 2026 10:37:08 +0300 Subject: [PATCH] Put with Files: choose kits, as Manage Kits does, and rebuild kits.scode A kits step after the zip is chosen, laid out as the Manage Kits panel: installed kits (what the app needs, greyed), installed but not used, kits available on the computer, not supported; the Action cell offers Keep at / Upgrade to / Downgrade to / Add at / REMOVE; Upgrade all kits to latest version. Kits not in the backup can be added, with their dependencies. The ticked kits make app.sab's schema (encode_sax takes the kit list, as Ontrol's PutApp rewrites it). When they are not the device's kits, or Rebuild kits.scode is ticked, kits.scode is built for them with Ontrol's sedonac from the Niagara install, after checking the natives the kits call against the device's platform manifest (PutApp.checkNativeCompatibility). - kit_build.py: platform manifest lookup, native check, sedonac run - palette.kit_versions: a checksum released more than once stands for its highest version (ontrolPlatform 1.0.1, not 1.0.0) - palette.unsupported_kits takes the kit list to judge against - sab_app: a type from a kit the app does not list is a clear error Tested on a P-ION: a rebuild for its own kits is byte-identical to the one it runs; adding and then removing the logic kit both come back running. Co-Authored-By: Claude Opus 5.5 --- app_backup.py | 350 ++++++++++++++++++++++++++++++++++----------- app_state.py | 2 +- kit_build.py | 190 ++++++++++++++++++++++++ main.py | 70 ++++++--- palette.py | 23 ++- put_kits_dialog.py | 242 +++++++++++++++++++++++++++++++ sab_app.py | 18 ++- 7 files changed, 776 insertions(+), 119 deletions(-) create mode 100644 kit_build.py create mode 100644 put_kits_dialog.py diff --git a/app_backup.py b/app_backup.py index df304db..759fe1d 100644 --- a/app_backup.py +++ b/app_backup.py @@ -26,7 +26,10 @@ import re import zipfile import app_state +import kit_build +import palette import sab_app +import schema import sox from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, ORION_PLATFORM_TYPE, RION_KIT @@ -64,8 +67,16 @@ _PLATFORMS = (_ORION, _RION, _PION) def platform_of(app): - kits = app.kit_names() - return next((p for p in _PLATFORMS if p.kit in kits), None) + return platform_of_kits(app.kit_names()) + + +def platform_of_kits(kit_names): + kit_names = set(kit_names) + return next((p for p in _PLATFORMS if p.kit in kit_names), None) + + +def platform_label(platform): + return platform.name if platform else "no platform this editor knows" def backup_folder(): @@ -278,7 +289,8 @@ def get_with_file(client, path, status=None): # --- Put with Files --------------------------------------------------------------------- # # Follows Ontrol's Workbench tool (ontrolSedonaOx BPutWithFilesTool, ontrolSedonanet -# PutApp): the backup's app.sax is encoded against the device's kits; app.sab goes as +# PutApp): the backup's app.sax is encoded for the chosen kit builds, kits.scode built +# for them with sedonac when they are not the device's (kit_build); both go as # '.writing' renamed to '.stage'; the platform service's deleteFile clears fonts/* and # images/*; then the images, the fonts and the boot logo go. Fonts come from Sedona # home's bank in the cut the device draws with, images in the form its screen takes. @@ -302,25 +314,13 @@ def kits_fit(app_kits, device_kits): return all(any(d == (n, _checksum(c)) for d in device) for n, c in app_kits) -def kit_differences(app_kits, device_kits): - """Lines telling why an app's kits do not fit the device's.""" - on_device = {n: _checksum(c) for n, c in device_kits} - lines = [] - for name, cks in app_kits: - cks = _checksum(cks) - if name not in on_device: - lines.append(f"{name}: not on the device") - elif on_device[name] != cks: - lines.append(f"{name}: {on_device[name]:08x} on the device, {cks:08x} in the backup") - return lines or ["The same kits, in a different order"] - - class DeviceTarget: """What a put needs to know of the connected device.""" def __init__(self, platform, kits, name=None, portrait=False, - platform_comp_id=None, delete_slot_id=None): + platform_comp_id=None, delete_slot_id=None, platform_id=""): self.platform = platform # one of _PLATFORMS, or None + self.platform_id = platform_id # e.g. "ontrol-pion-b427a5b9" self.kits = kits # [(name, checksum)] in kit-id order self.name = name # the app's deviceName self.portrait = portrait # Orion's screenOrientation @@ -353,7 +353,7 @@ def device_target(ds): platform = (next((p for p in _PLATFORMS if platform_id.startswith(p.platform_id)), None) or next((p for p in _PLATFORMS if p.kit in kit_names), None)) - target = DeviceTarget(platform, kits) + target = DeviceTarget(platform, kits, platform_id=client.read_version().platform_id) comp = client.cache_get(0) if comp is not None: client.update([comp], sox.CONFIG) @@ -450,8 +450,8 @@ class PutPlan: self.problems = [] # any of these stops the put self.warnings = [] # worth confirming, but the put can go on self.app_source = "" # how app.sab was come by, for the summary - self.reencoded = [] # kits app.sax was encoded against other builds of - self.kit_changes = [] # set when the backup's kits.scode has to go too + self.kit_changes = [] # each chosen kit build that is not the device's + self.scode_source = "" # where kits.scode comes from, when it goes self.app_files = [] # PutFile to '.writing', renamed to '.stage' self.media = [] # PutFile: images, fonts, logo self.converted = 0 # images made from the backup's image bank @@ -466,39 +466,176 @@ class PutPlan: return not self.problems -def _label(platform): - return platform.name if platform else "no platform this editor knows" - - def _app_portrait(app): comp = next((c for c in app.comps.values() if c["type"] == ORION_PLATFORM_TYPE), None) return bool(comp and app.prop(comp, "screenOrientation", False)) -def _sax_kits(sax): +class KitRow: + """One kit in the kits step of a put: the device's build, the backup's, and what to put. + + `included` rows are what goes: into app.sab's schema and, when it is rebuilt, into + kits.scode. A `required` row -- a kit the app's components name, or one an included + kit depends on -- is always included. + """ + + def __init__(self, name, device, backup, builds, used=False, locked=False, + unsupported=False): + self.name = name + self.device = device # checksum hex the device runs, or None + self.backup = backup # checksum hex the backup's schema names, or None + self.builds = builds # [(version, checksum hex)] Sedona home has, newest first + self.used = used # the app's components name a type of it + self.locked = locked # the backup holds only app.sab: nothing can change + self.unsupported = unsupported # cannot run on the device's platform + self.required = used + self.included = used or (device is not None and not unsupported) or \ + (backup is not None and not unsupported) + self.use = self._default() # the chosen checksum hex + + def _default(self): + """The device's build, when Sedona home can describe it; then the backup's; then + the newest.""" + known = {c for _, c in self.builds} + if self.locked: + return self.backup + for checksum in (self.device, self.backup): + if checksum in known: + return checksum + return self.builds[0][1] if self.builds else self.device or self.backup + + @property + def latest(self): + return self.builds[0][1] if self.builds else None + + +def _hex(checksum): + return f"{_checksum(checksum):08x}" + + +def _app_source(z, names): + """(sax entry or None, sab entry or None) of the backup's app.""" + sax = "app.sax" if "app.sax" in names else next( + (n for n in sorted(names) if n.endswith(".sax") and "/" not in n), None) + sab = "device/" + APP_SAB + return sax, sab if sab in names else None + + +def _backup_kits(path): + """([(kit, checksum or None)] the backup's schema names, {kits its components use}, + whether it holds only app.sab). Raises ValueError when the zip holds no app.""" import xml.etree.ElementTree as ET - schema_elem = ET.fromstring(sax).find("schema") - return [] if schema_elem is None else [k.get("name") for k in schema_elem.findall("kit")] + with zipfile.ZipFile(path) as z: + names = {n for n in z.namelist() if not n.endswith("/")} + sax_name, sab_name = _app_source(z, names) + if sax_name: + root = ET.fromstring(z.read(sax_name)) + schema_elem = root.find("schema") + kits = [(k.get("name"), k.get("checksum")) + for k in (schema_elem.findall("kit") if schema_elem is not None else ())] + used = {c.get("type", "").split("::")[0] for c in root.iter("comp")} + return kits, used | {"sys"}, False + if sab_name: + app = sab_app.SabApp(z.read(sab_name)) + used = {c["type"].split("::")[0] for c in app.comps.values()} + return app.kit_parts, used, True + raise ValueError("The zip holds no app: neither app.sax nor device/app.sab.") -def plan_put(path, target): - """Reads the zip at `path` and checks it against the device `target`.""" +def kit_rows(path, target): + """Every kit the kits step lists, in Manage Kits' groups (see KitRow.group). + + The kits the backup's app needs, those its schema or the device holds without the + app needing them, and every other kit Sedona home has. Each defaults to the device's + build. Raises ValueError when the zip holds no readable app. + """ + kits, used, locked = _backup_kits(path) + device = {n: _hex(c) for n, c in target.kits} + backup = {n: _hex(c) if c else None for n, c in kits} + names = list(dict.fromkeys([n for n, _ in kits] + list(device) + palette.list_kits())) + unsupported = palette.unsupported_kits(set(backup) | set(device)) + rows = [] + for name in names: + builds = [(v, _hex(c)) for v, c in palette.kit_builds(name)] + if locked and name not in backup: + continue # nothing can be added to a bare app.sab + rows.append(KitRow(name, device.get(name), backup.get(name), builds, + used=name in used, locked=locked, + unsupported=name in unsupported and name not in used)) + settle_required(rows) + return rows + + +def settle_required(rows): + """Marks every kit an included kit depends on as required -- and so included, at its + default build -- all the way down, as Manage Kits counts dependencies.""" + by_name = {r.name: r for r in rows} + for row in rows: + row.required = row.used + pending = [r for r in rows if r.included or r.used] + while pending: + row = pending.pop() + for dep in schema.read_kit_dependencies(row.name, row.use): + dep_row = by_name.get(dep) + if dep_row is not None and not dep_row.required: + dep_row.required = dep_row.included = True + pending.append(dep_row) + for row in rows: + if row.used: + row.included = True + + +KIT_GROUPS = ("Installed kits", "Installed but not used kits", + "Kits available on the computer", "Not supported") + + +def group(row): + """The Manage Kits section a row is listed under: the app needs it; the device or + the backup holds it without the app needing it; Sedona home has it; or it cannot run + on the device.""" + if row.unsupported: + return KIT_GROUPS[3] + if row.required: + return KIT_GROUPS[0] + if row.device or row.backup: + return KIT_GROUPS[1] + return KIT_GROUPS[2] + + +def chosen_kits(rows): + """[(name, checksum hex)] of the included rows.""" + return [(r.name, r.use) for r in rows if r.included] + + +def plan_put(path, target, rows=None, rebuild=False): + """Reads the zip at `path` and checks it against the device `target`. + + rows are kit_rows() as the kits step left them; None takes the defaults. The + included kits make app.sab's schema; kits.scode is built for them when they are not + the device's kits, or when `rebuild` asks for it anyway. + """ plan = PutPlan(path) try: z = zipfile.ZipFile(path) except (OSError, zipfile.BadZipFile) as e: plan.problems.append(f"Not a readable zip: {e}") return plan + if rows is None: + try: + rows = kit_rows(path, target) + except Exception as e: + plan.problems.append(str(e)) + return plan with z: names = {n for n in z.namelist() if not n.endswith("/")} - app, backup_portrait = _plan_app(plan, z, names, target) + app, backup_portrait = _plan_app(plan, z, names, target, rows, rebuild) if app is None: return plan platform = platform_of(app) if platform is None or platform is not target.platform: - plan.problems.append(f"The backup is from {_label(platform)}; the device is " - f"{_label(target.platform)}.") + plan.problems.append(f"The backup is from {platform_label(platform)}; the device " + f"is {platform_label(target.platform)}.") return plan zip_name = app.prop(app.app, "deviceName", "") or "" @@ -524,63 +661,108 @@ def plan_put(path, target): return plan -def _plan_app(plan, z, names, target): - """Settles app.sab, Ontrol's way first: the backup's app.sax encoded against the - device's kits. Failing that, the backup's own app.sab when the device's kits can - load it, or with the backup's kits.scode. Returns (decoded app, its portrait).""" - sax_name = "app.sax" if "app.sax" in names else next( - (n for n in sorted(names) if n.endswith(".sax") and "/" not in n), None) - sab_name, scode_name = "device/" + APP_SAB, "device/" + KITS_SCODE - device_cks = {n: f"{_checksum(c):08x}" for n, c in target.kits} - sax_problem = None +def _plan_app(plan, z, names, target, rows, rebuild): + """Settles app.sab and kits.scode, as Ontrol's PutApp does. + + The backup's app.sax is encoded for the chosen kit builds (a backup with only + device/app.sab goes as it is). When the device's kits.scode cannot load that app, or + a rebuild is asked for, kits.scode is built for the chosen builds with sedonac after + a check of their natives against the device's VM; without sedonac, the backup's own + kits.scode can stand in when the chosen builds are the backup's. Returns (decoded + app, whether the backup came from a portrait screen). + """ + sax_name, sab_name = _app_source(z, names) + chosen = chosen_kits(rows) + original = None + if sab_name: + try: + original = sab_app.SabApp(z.read(sab_name)) + except Exception as e: + if not sax_name: + plan.problems.append(f"{sab_name} cannot be read: {e}") + return None, False if sax_name: - sax = z.read(sax_name) try: - missing = [k for k in _sax_kits(sax) if k not in device_cks] - if missing: - raise sab_app.SabError("the device lacks the kits " + ", ".join(missing)) - sab = sab_app.encode_sax(sax, device_cks) - app = sab_app.SabApp(sab) + sab = sab_app.encode_sax(z.read(sax_name), kits=chosen) except Exception as e: - sax_problem = f"{sax_name} cannot be encoded for this device: {e}" - else: - original = sab_app.SabApp(z.read(sab_name)) if sab_name in names else None - if original is not None: - plan.reencoded = [line for line in kit_differences(original.kit_parts, - target.kits) - if "on the device," in line] - plan.app_source = f"{sax_name}, encoded for the device's kits" - plan.app_files.append(PutFile(APP_SAB, sab, sax_name)) - return app, _app_portrait(original or app) - - if sab_name not in names: - plan.problems.append(sax_problem or "The zip holds no app: neither app.sax nor " - f"{sab_name}.") - return None, False - sab = z.read(sab_name) - try: - app = sab_app.SabApp(sab) - except Exception as e: - plan.problems += [p for p in (sax_problem,) if p] - plan.problems.append(f"{sab_name} cannot be read: {e}") - return None, False - if kits_fit(app.kit_parts, target.kits): - plan.app_source = sab_name - elif scode_name in names: - plan.kit_changes = kit_differences(app.kit_parts, target.kits) - plan.app_source = f"{sab_name}, with the backup's {KITS_SCODE}" - plan.app_files.append(PutFile(KITS_SCODE, z.read(scode_name), scode_name)) + plan.problems.append(f"{sax_name} cannot be encoded for the chosen kits: {e}") + return None, False + plan.app_source = f"{sax_name}, encoded for the chosen kits" + source = sax_name else: - plan.problems += [p for p in (sax_problem,) if p] - plan.problems.append(f"The device's kits cannot load {sab_name}, and the backup " - f"has no {scode_name}:") - plan.problems += [" " + line for line in kit_differences(app.kit_parts, target.kits)] - return None, False - if sax_problem: - plan.warnings.append(sax_problem) - plan.app_files.append(PutFile(APP_SAB, sab, sab_name)) - return app, _app_portrait(app) + sab = z.read(sab_name) + plan.app_source = sab_name + source = sab_name + app = sab_app.SabApp(sab) + app_kits = app.kit_parts + + plan.kit_changes = [line for line in map(kit_change, rows) if line] + device_kits = {(n, _checksum(c)) for n, c in target.kits} + if sax_name: + same = {(n, _checksum(c)) for n, c in app_kits} == device_kits + else: + same = kits_fit(app_kits, target.kits) + if rebuild or not same: + scode = _plan_scode(plan, z, names, target, app_kits) + if scode is None: + return None, False + plan.app_files.append(scode) + plan.app_files.append(PutFile(APP_SAB, sab, source)) + return app, _app_portrait(original or app) + + +def kit_change(row): + """How a row changes the device's kits, or None.""" + def label(checksum): + version = palette.version_for_checksum(row.name, checksum) + return f"{version} ({checksum})" if version else checksum + if row.device is None: + return f"{row.name}: added at {label(row.use)}" if row.included else None + if not row.included: + return f"{row.name}: removed" + if row.use != row.device: + return f"{row.name}: {label(row.device)} -> {label(row.use)}" + return None + + +def _plan_scode(plan, z, names, target, app_kits): + """kits.scode for the app's kits: built by sedonac, or the backup's own.""" + manifest = kit_build.platform_manifest(target.platform_id) + backup_scode = "device/" + KITS_SCODE + backup_kits = None + if "device/" + APP_SAB in names: + try: + backup_kits = sab_app.SabApp(z.read("device/" + APP_SAB)).kit_parts + except Exception: + pass + why = None + if manifest is None: + why = (f"Sedona home has no platform manifest for {target.platform_id or 'the device'}" + " (platforms/db), so kits.scode cannot be built or checked for it.") + else: + problems = kit_build.native_problems(app_kits, manifest) + if problems: + plan.problems.append("The chosen kits call native methods the device's VM " + "does not have:") + plan.problems += [" " + p for p in problems] + return None + try: + data = kit_build.build_kits_scode(app_kits, manifest) + except kit_build.BuildError as e: + why = str(e) + else: + plan.scode_source = "built by sedonac for the chosen kits" + return PutFile(KITS_SCODE, data, "sedonac") + + if backup_kits is not None and backup_scode in names and \ + [(n, _checksum(c)) for n, c in backup_kits] == \ + [(n, _checksum(c)) for n, c in app_kits]: + plan.warnings.append(why + " The backup's own kits.scode goes instead.") + plan.scode_source = "the backup's, unchecked against the device's VM" + return PutFile(KITS_SCODE, z.read(backup_scode), backup_scode) + plan.problems.append(why) + return None def _plan_images(plan, z, names, platform, target, same_screen): diff --git a/app_state.py b/app_state.py index df71690..ff6b27a 100644 --- a/app_state.py +++ b/app_state.py @@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it. import configparser import os -VERSION = "0.0.0.166" +VERSION = "0.0.0.167" PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") DEFAULT_GRID_SIZE = 10 DEFAULT_ADD_TIMESTAMP = True diff --git a/kit_build.py b/kit_build.py new file mode 100644 index 0000000..47f6fe2 --- /dev/null +++ b/kit_build.py @@ -0,0 +1,190 @@ +"""Building a device's kits.scode, as Ontrol's PutApp does it with sedonac. + +The editor carries no Sedona compiler: Ontrol's own sedonac is run through Java, from +the Niagara install next to Sedona home (Niagara 4's ontrolSedonanet jars, or Niagara +AX's sedona/sedonac jars -- both build the same image). Its input is a kits.xml naming +each kit at its version, with the target platform's endian, block and ref sizes taken +from the platform manifest in Sedona home's platform db. + +Before a build the kits' calls into native code are checked against the platform's +SVM, as PutApp.checkNativeCompatibility does: an scode calling a native the device's +VM lacks would not start. +""" + +import glob +import os +import re +import shutil +import subprocess +import tempfile +import xml.etree.ElementTree as ET +import zipfile + +import app_state +import palette + + +class BuildError(Exception): + pass + + +def _home(): + return app_state.SEDONA_HOME + + +# --- kits ------------------------------------------------------------------------------- + +def kit_file(name, checksum): + """(path, version) of the .kit for a kit build; the highest version when one checksum + was released more than once. None when Sedona home has no such .kit.""" + want = int(checksum, 16) if isinstance(checksum, str) else checksum & 0xFFFFFFFF + pattern = re.compile(rf"^{re.escape(name)}-([0-9a-fA-F]+)-(.+)\.kit$") + found = [] + for path in glob.glob(os.path.join(_home(), "kits", name, f"{name}-*.kit")): + match = pattern.match(os.path.basename(path)) + if match and int(match.group(1), 16) == want: + found.append((palette.version_sort_key(match.group(2)), path, match.group(2))) + if not found: + return None + _, path, version = max(found) + return path, version + + +# --- the target platform ---------------------------------------------------------------- + +def platform_manifest(platform_id): + """The platformManifest.xml in Sedona home's platform db for a platform id, or None.""" + if not platform_id: + return None + # os.walk, not glob: the manifests sit in hidden .par folders glob skips. + for folder, _, files in os.walk(os.path.join(_home(), "platforms", "db")): + if "platformManifest.xml" not in files: + continue + path = os.path.join(folder, "platformManifest.xml") + try: + if ET.parse(path).getroot().get("platformId") == platform_id: + return path + except ET.ParseError: + continue + return None + + +_CLASS = re.compile(r"^\s*(?:[a-z]+\s+)*class\s+(\w+)", re.M) +_NATIVE = re.compile(r"\bnative\b[^=\n]*?\b(\w+)\s*\([^)]*\)\s*=\s*(\d+::\d+)") +_CALL = re.compile(r"\bCallNative\w*\s+(\w+::\w+\.\w+)") + + +def native_problems(kits, manifest_path): + """Lines naming each native the kits call that the platform's SVM does not provide. + + kits is [(name, checksum)]. Read from the kits' .ir files: a native's id from its + declaration, the natives in use from the CallNative ops. + """ + svm = {e.get("id"): e.get("qname") + for e in ET.parse(manifest_path).getroot().iter("nativeMethod")} + declared, called = {}, set() + for name, checksum in kits: + found = kit_file(name, checksum) + if found is None: + continue # reported by the build + with zipfile.ZipFile(found[0]) as z: + for entry in z.namelist(): + if not entry.endswith(".ir"): + continue + text = z.read(entry).decode("utf-8", "replace") + classes = [(m.start(), m.group(1)) for m in _CLASS.finditer(text)] + for m in _NATIVE.finditer(text): + owner = [c for pos, c in classes if pos < m.start()] + if owner: + declared[f"{name}::{owner[-1]}.{m.group(1)}"] = m.group(2) + called.update(_CALL.findall(text)) + problems = [] + for qname in sorted(called): + nid = declared.get(qname) + if nid is None or nid not in svm: + problems.append(f"{qname} ({nid or 'not declared'}) is not in the device's VM") + elif svm[nid] != qname: + problems.append(f"{qname} ({nid}) is {svm[nid]} in the device's VM") + return problems + + +# --- the compiler ----------------------------------------------------------------------- + +def _java(): + found = shutil.which("java") + if found: + return found + candidates = [] + if os.environ.get("JAVA_HOME"): + candidates.append(os.path.join(os.environ["JAVA_HOME"], "bin", "java.exe")) + niagara_root = os.path.dirname(os.path.abspath(_home())) + candidates += sorted(glob.glob(os.path.join(niagara_root, "Niagara-*", "jre", "bin", + "java.exe")), reverse=True) + for root in (os.environ.get("ProgramFiles", r"C:\Program Files"),): + candidates += sorted(glob.glob(os.path.join(root, "*", "*", "bin", "java.exe")), + reverse=True) + return next((c for c in candidates if os.path.isfile(c)), None) + + +def _classpath(): + """sedonac and the sedona runtime from the Niagara install beside Sedona home.""" + niagara_root = os.path.dirname(os.path.abspath(_home())) + for niagara in sorted(glob.glob(os.path.join(niagara_root, "Niagara-*")), reverse=True): + modules = os.path.join(niagara, "modules") + for jars in (("ontrolSedonanet-rt.jar", "ontrolSedonanet-wb.jar"), + ("sedona.jar", "sedonac.jar")): + paths = [os.path.join(modules, j) for j in jars] + if all(os.path.isfile(p) for p in paths): + return os.pathsep.join(paths) + return None + + +def toolchain(): + """(java, classpath) for running sedonac; raises BuildError saying what is missing.""" + java, classpath = _java(), _classpath() + if not java: + raise BuildError("No Java runtime was found (java on the PATH, JAVA_HOME, or " + "Niagara's jre).") + if not classpath: + raise BuildError("sedonac was not found: no Niagara install beside Sedona home " + f"({os.path.dirname(os.path.abspath(_home()))}) has Ontrol's " + "ontrolSedonanet jars or sedona.jar + sedonac.jar.") + return java, classpath + + +def build_kits_scode(kits, manifest_path): + """Compiles kits.scode for [(name, checksum)] on the platform; returns its bytes.""" + plat = ET.parse(manifest_path).getroot() + lines = [""] + missing = [] + for name, checksum in kits: + found = kit_file(name, checksum) + if found is None: + missing.append(f"{name} {checksum}") + else: + lines.append(f' ') + if missing: + raise BuildError("Sedona home has no .kit file for " + ", ".join(missing)) + lines.append("") + + java, classpath = toolchain() + with tempfile.TemporaryDirectory(prefix="sedona_editor_scode_") as work: + with open(os.path.join(work, "kits.xml"), "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + try: + run = subprocess.run( + [java, f"-Dsedona.home={_home()}", "-cp", classpath, "sedonac.Main", + "kits.xml"], + cwd=work, capture_output=True, text=True, timeout=180, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except (OSError, subprocess.TimeoutExpired) as e: + raise BuildError(f"sedonac could not run: {e}") from e + scode = os.path.join(work, "kits.scode") + if run.returncode != 0 or not os.path.isfile(scode): + tail = "\n".join((run.stdout + run.stderr).strip().splitlines()[-12:]) + raise BuildError(f"sedonac failed:\n{tail}") + with open(scode, "rb") as f: + return f.read() diff --git a/main.py b/main.py index fb82387..2ae40e3 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,7 @@ import live_hooks import manage_kits import palette import properties +import put_kits_dialog import sax_file import schema import sox @@ -781,10 +782,11 @@ def _report_backup(result): def do_put_with_files(): """App > Put with Files: puts a Get with Files zip back onto the connected device. - The zip is checked against the device first: the same platform, and an app the - device's kits can run -- the backup's app.sax encoded for them, as Ontrol's tool - does, or its app.sab. The app goes staged, so the device takes it up when - restarted, which is offered once the files are in. + As Ontrol's tool does: the kits the app needs are listed to choose builds for (the + device's by default), the backup's app.sax is encoded for them, and kits.scode is + rebuilt with sedonac when they are not the device's. The zip is checked against the + device on the way. The app goes staged, so the device takes it up when restarted, + which is offered once the files are in. """ ds = app_state.device_session if not app_state.live_mode or ds is None or not ds.is_connected: @@ -808,19 +810,51 @@ def do_put_with_files(): except Exception as e: messagebox.showerror("Put with Files", f"Could not read the device:{NL}{NL}{e}") return - plan = app_backup.plan_put(path, target) - if not plan.ok: - app_state.set_status(f"{os.path.basename(path)} does not match the device", warn=True) + try: + rows = app_backup.kit_rows(path, target) + except Exception as e: + messagebox.showerror("Put with Files", f"{os.path.basename(path)}:{NL}{NL}{e}") + return + backup_platform = app_backup.platform_of_kits(r.name for r in rows if r.backup or r.used) + if backup_platform is None or backup_platform is not target.platform: messagebox.showerror( "Put with Files", - f"{os.path.basename(path)} cannot be put on this device:{NL}{NL}" + - NL.join(plan.problems)) + f"{os.path.basename(path)} cannot be put on this device:{NL}{NL}" + f"The backup is from {app_backup.platform_label(backup_platform)}; the device " + f"is {app_backup.platform_label(target.platform)}.") return - if not messagebox.askokcancel("Put with Files", _describe_put(plan), - icon=messagebox.WARNING, default=messagebox.CANCEL): + rebuild = put_kits_dialog.ask(app_state.window, rows) + if rebuild is None: return - def finish(_result, error): + def planned(plan, error): + if error is not None: + messagebox.showerror("Put with Files", f"Preparing the put failed:{NL}{NL}{error}") + return + if not plan.ok: + app_state.set_status(f"{os.path.basename(path)} cannot be put on the device", + warn=True) + messagebox.showerror( + "Put with Files", + f"{os.path.basename(path)} cannot be put on this device:{NL}{NL}" + + NL.join(plan.problems)) + return + if messagebox.askokcancel("Put with Files", _describe_put(plan), + icon=messagebox.WARNING, default=messagebox.CANCEL): + _put(plan) + + def prepare(status): + status("Preparing the app" + (" and building kits.scode" if rebuild or any( + map(app_backup.kit_change, rows)) else "") + " ...", None) + return app_backup.plan_put(path, target, rows, rebuild) + + def _put(plan): + _run_with_progress("Put with Files", + lambda status: app_backup.put_with_files(ds.sox_client, plan, + target, status), + lambda result, error: finish(plan, result, error)) + + def finish(plan, _result, error): if error is not None: app_state.set_status(f"Put with Files failed: {error}", warn=True) messagebox.showerror( @@ -839,10 +873,7 @@ def do_put_with_files(): if _invoke_app_action("restart"): app_state.set_status(f"Put {os.path.basename(path)}; the device is restarting") - _run_with_progress("Put with Files", - lambda status: app_backup.put_with_files(ds.sox_client, plan, target, - status), - finish) + _run_with_progress("Put with Files", prepare, planned) def _describe_put(plan): @@ -854,12 +885,9 @@ def _describe_put(plan): (" Its fonts and images are deleted and replaced by the backup's." if plan.has_screen else ""), "", f" app.sab, from {plan.app_source}"] - lines += [f" {line}" for line in plan.reencoded] - if plan.kit_changes: - lines += [" kits.scode, since the device's kits cannot load the app:"] + if plan.scode_source: + lines.append(f" kits.scode, {plan.scode_source}") lines += [f" {line}" for line in plan.kit_changes] - lines += [" The device's firmware must support these kits, or the app will not " - "start."] if not plan.has_screen: return NL.join(lines + ([""] + plan.warnings if plan.warnings else [])) lines.append(f" {images} images" + diff --git a/palette.py b/palette.py index 3cac533..d025d3f 100644 --- a/palette.py +++ b/palette.py @@ -67,7 +67,12 @@ def kit_versions(kit_name): for entry in os.listdir(kit_dir): match = pattern.match(entry) if match: - versions[_norm_checksum(match.group(1))] = match.group(2) + # A checksum released more than once (ontrolPlatform 1.0.0 and 1.0.1) + # stands for its highest version, as the devices report it. + key, version = _norm_checksum(match.group(1)), match.group(2) + if (key not in versions or + version_sort_key(version) > version_sort_key(versions[key])): + versions[key] = version _versions_cache[kit_name] = versions return versions @@ -269,7 +274,7 @@ def _platforms_db(): return os.path.join(app_state.SEDONA_HOME, "platforms", "db") -def _platform_native_kits(): +def _platform_native_kits(sax_names=None): """Return (matching, all_platforms) — two sets of kit names. *matching* is the union of nativeKit names from platform builds that include @@ -278,8 +283,10 @@ def _platform_native_kits(): A kit that appears in *all_platforms* but not in *matching* has native code compiled for a different device and cannot work with this SAX. + + sax_names stands in for the open file's kits when given. """ - sax_names = set(app_state.active_schema_kits.keys()) + sax_names = set(app_state.active_schema_kits.keys() if sax_names is None else sax_names) db = _platforms_db() if not os.path.isdir(db): return None, None @@ -335,8 +342,9 @@ def _platform_native_kits(): return (matching if found else None), all_platforms -def unsupported_kits(): - """Kit names that cannot work with the open file's platform. +def unsupported_kits(sax_names=None): + """Kit names that cannot work with the open file's platform -- or with the platform + of the kits in `sax_names`, when given. A kit is unsupported when: - it has native code (hasNatives or listed as nativeKit on some platform) @@ -345,11 +353,12 @@ def unsupported_kits(): Kits that depend on an unsupported kit are also flagged. """ - sax_names = frozenset(app_state.active_schema_kits.keys()) + sax_names = frozenset(app_state.active_schema_kits.keys() if sax_names is None + else sax_names) if sax_names in _unsupported_cache: return _unsupported_cache[sax_names] - matching_natives, all_natives = _platform_native_kits() + matching_natives, all_natives = _platform_native_kits(sax_names) if matching_natives is None: _unsupported_cache[sax_names] = set() return set() diff --git a/put_kits_dialog.py b/put_kits_dialog.py new file mode 100644 index 0000000..5ed7cf3 --- /dev/null +++ b/put_kits_dialog.py @@ -0,0 +1,242 @@ +"""The kits step of App > Put with Files, laid out as the Manage Kits panel is. + +The same groups (kits the app needs, kits declared without being used, kits available, +kits not supported), the same tick column and the same Action cell: "Keep at", +"Upgrade to", "Downgrade to", "Add at", "REMOVE". Installed is what the device runs; +Backup is what the backup's app was saved with. + +What is ticked goes: into app.sab's schema and, when that is not the device's kits, +into a kits.scode built for them (Ontrol's PutApp does the same). "Rebuild kits.scode" +forces the build when nothing changed. +""" + +import tkinter as tk +from tkinter import messagebox, ttk + +import app_backup +import manage_kits +from manage_kits import NOT_DECLARED, SECTION_TAG, column_label, make_checkbox_image + +REMOVE_ACTION = "REMOVE from device" + + +def _options(row): + """The Action choices for a row, as (label, checksum) pairs.""" + if row.device: + return [(label, checksum or row.device) for label, checksum in + manage_kits.action_options(row.name, row.device, True, row.builds)] + return [(f"Add at {manage_kits.build_label(v, c)}", c) for v, c in row.builds] + + +def _build_text(row, checksum): + if not checksum: + return NOT_DECLARED + version = next((v for v, c in row.builds if c == checksum), "") + return column_label(version, checksum) + + +class _Dialog: + def __init__(self, parent, rows, title): + self.rows = rows + self.result = None + self.original = [(r.included, r.use) for r in rows] + + self.top = top = tk.Toplevel(parent) + top.title(f"{title} - Kits") + top.transient(parent) + top.grab_set() + top.geometry(f"+{parent.winfo_x() + 60}+{parent.winfo_y() + 40}") + frame = ttk.Frame(top, padding=10) + frame.pack(fill=tk.BOTH, expand=True) + + columns = ("name", "installed", "backup", "latest", "action") + table_frame = ttk.Frame(frame) + table_frame.pack(fill=tk.BOTH, expand=True) + self.table = ttk.Treeview(table_frame, columns=columns, show="tree headings", + selectmode="browse", height=20) + self.table.heading("#0", text="") + self.table.column("#0", width=32, minwidth=32, stretch=False, anchor="center") + for column, heading, width in [("name", "Kit name", 190), + ("installed", "Installed", 105), + ("backup", "Backup", 105), + ("latest", "Latest", 105), + ("action", "Action", 215)]: + self.table.heading(column, text=heading, anchor="w") + self.table.column(column, width=width, minwidth=width, anchor="w", stretch=False) + vscroll = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.table.yview) + self.table.configure(yscrollcommand=vscroll.set) + vscroll.pack(side=tk.RIGHT, fill=tk.Y) + self.table.pack(fill=tk.BOTH, expand=True) + self.table.tag_configure(SECTION_TAG, background="#dcdcdc") + + self.boxes = {(checked, enabled): make_checkbox_image(checked, enabled) + for checked in (True, False) for enabled in (True, False)} + self.cross_box = make_checkbox_image(False, False, cross=True) + + locked = any(r.locked for r in rows) + hint = ("The backup holds no app.sax, so its app goes with the kits it was saved " + "with." if locked else + "A tick means the kit goes to the device. Grey ticks are needed by the app " + "and cannot be removed; untick another to remove it, tick one to add it. " + "Click the Action cell to choose a build. Installed is the device's build. " + "Kits other than the device's mean a new kits.scode.") + ttk.Label(frame, text=hint, foreground="#666666", wraplength=780 + ).pack(anchor="w", pady=(8, 0)) + + self.rebuild = tk.BooleanVar(value=False) + self.rebuild_check = ttk.Checkbutton(frame, text="Rebuild kits.scode", + variable=self.rebuild) + self.rebuild_check.pack(anchor="w", pady=(6, 0)) + + button_row = ttk.Frame(frame) + button_row.pack(fill=tk.X, pady=(10, 0)) + upgrade = ttk.Button(button_row, text="Upgrade all kits to latest version", + command=self._upgrade_all) + upgrade.pack(side=tk.LEFT) + if locked: + upgrade.state(["disabled"]) + ttk.Button(button_row, text="Cancel", command=lambda: self._close(False) + ).pack(side=tk.RIGHT) + ttk.Button(button_row, text="Next", command=lambda: self._close(True) + ).pack(side=tk.RIGHT, padx=6) + + self.row_by_item = {} + self._editor = None + self.table.bind("", self._on_click) + self.table.bind("", lambda _e: self._close_editor(), add="+") + top.protocol("WM_DELETE_WINDOW", lambda: self._close(False)) + self._rebuild_table() + + # --- the table --- + + def _action_text(self, row): + if row.unsupported: + return "Not supported" + if not row.included: + return REMOVE_ACTION if row.device else NOT_DECLARED + return next((label for label, c in _options(row) if c == row.use), + _build_text(row, row.use)) + + def _values(self, row): + latest = row.builds[0] if row.builds else None + return (row.name, _build_text(row, row.device), _build_text(row, row.backup), + column_label(*latest) if latest else NOT_DECLARED, self._action_text(row)) + + def _image(self, row): + if row.unsupported: + return self.cross_box + return self.boxes[(row.included, not (row.required or row.locked))] + + def _rebuild_table(self): + self._close_editor() + top = self.table.yview()[0] + self.table.delete(*self.table.get_children()) + self.row_by_item.clear() + for title in app_backup.KIT_GROUPS: + members = [r for r in self.rows if app_backup.group(r) == title] + if not members: + continue + self.table.insert("", "end", values=(title, "", "", "", ""), tags=(SECTION_TAG,)) + for row in members: + item = self.table.insert("", "end", image=self._image(row), + values=self._values(row)) + self.row_by_item[item] = row + self.table.yview_moveto(top) + + # Kits other than the device's cannot run on its kits.scode. + changed = any(app_backup.kit_change(r) for r in self.rows) + if changed: + self.rebuild.set(True) + self.rebuild_check.state(["disabled"]) + else: + self.rebuild_check.state(["!disabled"]) + + def _changed(self): + app_backup.settle_required(self.rows) + self._rebuild_table() + + # --- editing --- + + def _close_editor(self): + if self._editor is not None: + self._editor.destroy() + self._editor = None + + def _on_click(self, event): + self._close_editor() + if self.table.identify_region(event.x, event.y) not in ("tree", "cell"): + return + item = self.table.identify_row(event.y) + row = self.row_by_item.get(item) + if row is None or row.locked or row.unsupported: + return + column = self.table.identify_column(event.x) + if column == "#0": + self._toggle(row) + elif column == "#5": + self._edit_action(item, row) + + def _toggle(self, row): + if row.required: + return + if not row.included and not row.builds: + messagebox.showwarning("Put with Files", f"Sedona home holds no build of " + f"'{row.name}' to add it at.", parent=self.top) + return + row.included = not row.included + if row.included and not row.use: + row.use = row.latest + self._changed() + + def _edit_action(self, item, row): + options = _options(row) + if not options: + return + cell = self.table.bbox(item, "action") + if not cell: + return + labels = [label for label, _ in options] + variable = tk.StringVar(value=self._action_text(row)) + combo = ttk.Combobox(self.table, textvariable=variable, values=labels, + state="readonly") + combo.place(x=cell[0], y=cell[1], width=cell[2], height=cell[3]) + self._editor = combo + + def commit(_event=None): + if variable.get() in labels: + row.use = options[labels.index(variable.get())][1] + row.included = True + self._close_editor() + self._changed() + + combo.bind("<>", commit) + combo.bind("", lambda _e: self._close_editor()) + combo.focus_set() + combo.after_idle(lambda: combo.event_generate("")) + + def _upgrade_all(self): + for row in self.rows: + if row.included and row.latest and not row.locked: + row.use = row.latest + self._changed() + + def _close(self, ok): + self._close_editor() + if ok: + self.result = bool(self.rebuild.get()) + else: + for row, (included, use) in zip(self.rows, self.original): + row.included, row.use = included, use + app_backup.settle_required(self.rows) + self.top.grab_release() + self.top.destroy() + + +def ask(parent, rows, title="Put with Files"): + """Shows the kits step; returns whether to rebuild kits.scode, or None when cancelled. + + The rows are updated in place with what was chosen. + """ + dialog = _Dialog(parent, rows, title) + dialog.top.wait_window() + return dialog.result diff --git a/sab_app.py b/sab_app.py index 0b7d5d4..b8fc89b 100644 --- a/sab_app.py +++ b/sab_app.py @@ -80,6 +80,9 @@ def _resolve_slots(qname, types_by_q, cache): """The type's slots, base chain first, indexed by slot id.""" if qname in cache: return cache[qname] + if qname not in types_by_q: + raise SabError(f"type {qname} is not in the app's kits -- a kit build that " + f"needs kit '{qname.split('::')[0]}', which the app does not list?") t = types_by_q[qname] base = t.get("base") out = list(_resolve_slots(base, types_by_q, cache)) if base else [] @@ -505,21 +508,24 @@ def _encode_value(out, slot, raw): raise SabError(f"unhandled slot type {vtype}") -def encode_sax(sax, checksums=None): +def encode_sax(sax, checksums=None, kits=None): """app.sax (text or bytes) -> app.sab bytes, as `sedonac app.sax` writes it. checksums, {kit name: checksum hex}, stands in for the sax's own -- how an app goes - to a device whose kits are other builds than the ones it was saved with. Every - kit's manifest must be in Sedona home under the checksum used. + to a device whose kits are other builds than the ones it was saved with. kits, + [(name, checksum hex)], replaces the schema altogether, as Ontrol's PutApp rewrites + it for the kits chosen to put. Every kit's manifest must be in Sedona home under + the checksum used. """ root = ET.fromstring(sax) schema_elem, app_elem = root.find("schema"), root.find("app") if schema_elem is None or app_elem is None: raise SabError("not a Sedona app: no or ") + if kits is None: + kits = [(k.get("name"), (checksums or {}).get(k.get("name")) or k.get("checksum")) + for k in schema_elem.findall("kit")] parts = [] - for k in schema_elem.findall("kit"): - name = k.get("name") - cks = (checksums or {}).get(name) or k.get("checksum") + for name, cks in kits: if not cks: raise SabError(f"no checksum for kit '{name}'") parts.append((name, f"{int(cks, 16):08x}"))