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}"))