"""App > Get with Files / Put with Files: a full backup of the device's app as one zip, and putting such a zip back onto a device. The zip follows the layout Ontrol's tools read back on a restore: device/app.sab the files as the device holds them device/kits.scode device/images/.bin device/fonts/ device/system/logo.jpg Orion's boot logo; Rion's is system/logo.bin app.sax app.sab decoded, as sedonac would write it imageBank[XL]/.png the sources of the device's images and fonts, taken from fontBank[XL]/ Sedona home's banks (XL for Orion's 800x480 screen) system/logo.png The device cannot list its files, so which images and fonts to fetch is read from the app itself: every image name and font its Ox widgets and pages name. A put stages app.sab and kits.scode for the VM to take up on its next start; see put_with_files for the order it goes in. """ import datetime import os 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 APP_SAB = "app.sab" KITS_SCODE = "kits.scode" _IMAGE_SLOTS = ("imageFileName", "trueImageFileName", "falseImageFileName") _SOURCE_IMAGE_EXTENSIONS = tuple(e for e in IMAGE_EXTENSIONS if e != ".bin") _UNSAFE_FILE_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]') class _Platform: def __init__(self, name, kit, service_type, image_bank=None, font_bank=None, font_extensions=(), logo_uri=None, screen=None): self.name = name self.kit = kit # the kit only this platform's apps hold self.service_type = service_type # its platform service self.platform_id = "ontrol-" + name.lower() # the device's id starts so self.image_bank = image_bank self.font_bank = font_bank self.font_extensions = font_extensions self.logo_uri = logo_uri self.screen = screen # (width, height) in landscape; None: none # Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of each font, and its boot # logo as a jpg; Rion's is a native image (Ontrol's BGetWithFilesTool). A P-ION has no # screen: nothing but the app goes to it. _ORION = _Platform("ORION", ORION_KIT, ORION_PLATFORM_TYPE, "imageBankXL", "fontBankXL", (".oxfl", ".oxfp"), "system/logo.jpg", (800, 480)) _RION = _Platform("RION", RION_KIT, "ontrolRion::RionPlatformService", "imageBank", "fontBank", (".bin",), "system/logo.bin", (320, 240)) _PION = _Platform("PION", "ontrolPion", "ontrolPion::PionPlatformService") _PLATFORMS = (_ORION, _RION, _PION) def platform_of(app): 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(): return os.path.join(app_state.SEDONA_HOME, "store", "apps") def backup_file_name(device_name, app_name, when=None): """'app---.zip', made safe for Windows.""" stamp = (when or datetime.datetime.now()).strftime("%Y%m%d-%H%M%S") parts = [_UNSAFE_FILE_CHARS.sub("_", p).strip(" .") or "unnamed" for p in (device_name, app_name)] return f"app-{parts[0]}-{parts[1]}-{stamp}.zip" class MediaFile: """One image or font the app names: where it lives on the device and in the bank. `required` is False for guesses -- a bold font's plain cut, Orion's other font orientation, an indexed page image -- whose absence is not worth reporting. `group` ties the alternatives of one need together: the need is met when any of them is found. """ def __init__(self, device_uri, bank_folder, bank_name, group, required=True): self.device_uri = device_uri self.bank_folder = bank_folder self.bank_name = bank_name self.group = group self.required = required def _font_stem(app, comp): slot = app.slot(comp, "fontName") names = [n.strip() for n in (slot.facets.get("range") or "Tahoma").split(",")] if slot else ["Tahoma"] index = app.prop(comp, "fontName", 0) or 0 family = names[index] if 0 <= index < len(names) else names[0] size = app.prop(comp, "fontSize", 16) bold = app.prop(comp, "fontAttributes", 0) == 1 return f"{size}pt {family}", bold def media_files(app, platform): """The images and fonts the app names, each with its device path and bank source.""" files, seen = [], set() if platform.screen is None: return files def add(uri, bank_folder, bank_name, group, required=True): if uri not in seen: seen.add(uri) files.append(MediaFile(uri, bank_folder, bank_name, group, required)) def add_image(name, required=True): if name: add(f"images/{name}.bin", platform.image_bank, name, f"image {name}", required) def add_font(stem, group, required): for i, ext in enumerate(platform.font_extensions): add(f"fonts/{stem}{ext}", platform.font_bank, stem + ext, group, required and i == 0) for comp in app.comps.values(): for slot_name in _IMAGE_SLOTS: add_image(app.prop(comp, slot_name, "")) prefix = app.prop(comp, "imageFileNamePrefix", "") if prefix: for i in range((app.prop(comp, "maxIndex", 1) or 0) + 1): add_image(f"{prefix}{i}") page_image = app.prop(comp, "imageFileName", "") if page_image: for i in range(1, (app.prop(comp, "maxImageId", 0) or 0) + 1): add_image(f"{page_image}{i}", required=False) if app.slot(comp, "fontSize") is not None: stem, bold = _font_stem(app, comp) group = f"font {'bold ' if bold else ''}{stem}" if bold: add_font(f"bold {stem}", group, True) add_font(stem, group, not bold) add(platform.logo_uri, platform.image_bank, "logo", "logo", required=False) return files def _bank_source(bank_folder, bank_name, is_image): """The local bank file for a device file, or None.""" folder = os.path.join(app_state.SEDONA_HOME, "ontrol", bank_folder) if not is_image: path = os.path.join(folder, bank_name) return path if os.path.isfile(path) else None for ext in _SOURCE_IMAGE_EXTENSIONS + (".bin",): path = os.path.join(folder, bank_name + ext) if os.path.isfile(path): return path return None def _source_entry(media, source_path): """Where the bank file goes in the zip: next to its bank's name, the logo in system/.""" file_name = os.path.basename(source_path) if media.device_uri.startswith("system/"): return "system/logo" + os.path.splitext(file_name)[1] return f"{media.bank_folder}/{file_name}" def save_app(client): """Invokes App.save on the device and waits for it to finish. Ontrol's Niagara driver does this before every app.sab get. """ comp = client.cache_get(0) or client.load(0) slots = client.slot_registry.get((comp.kit_id, comp.type_id)) or [] slot = next((s for s in slots if s["name"] == "save" and s.get("is_action")), None) client.invoke(0, slot["id"] if slot else 0) class BackupResult: def __init__(self): self.path = None self.entries = [] self.missing_on_device = [] # media the app names that the device lacks self.missing_in_bank = [] # device files with no source in Sedona home self.sax_error = None # why app.sax could not be written def get_with_file(client, path, status=None): """Reads the app and its media off the device and writes the backup zip to `path`. The app is saved on the device first, so app.sab holds what is running. Runs off the main thread; status(text, fraction) reports progress. Returns a BackupResult; a failure to save, or to read app.sab or kits.scode, raises. """ def report(text, fraction=None): if status: status(text, fraction) def fetch(uri, base, span): def progress(done, total): report(f"Reading {uri} ...", base + span * (done / total if total else 1.0)) report(f"Reading {uri} ...", base) try: return client.get_file(uri, progress) except Exception as e: raise RuntimeError(f"Reading {uri}: {e}") from e result = BackupResult() entries = [] # (zip name, bytes or local path) report("Saving the app on the device ...", 0.0) try: save_app(client) except Exception as e: raise RuntimeError(f"Saving the app on the device: {e}") from e sab = fetch(APP_SAB, 0.0, 0.15) entries.append(("device/" + APP_SAB, sab)) entries.append(("device/" + KITS_SCODE, fetch(KITS_SCODE, 0.15, 0.35))) app = None try: app = sab_app.SabApp(sab) entries.append(("app.sax", app.to_sax().encode("ascii"))) except Exception as e: result.sax_error = str(e) platform = platform_of(app) if app else None media = media_files(app, platform) if platform else [] found_groups, wanted = set(), {} for i, m in enumerate(media): report(f"Reading {m.device_uri} ...", 0.5 + 0.45 * i / max(1, len(media))) if m.required: wanted.setdefault(m.group, m.device_uri) try: data = client.get_file(m.device_uri) except Exception: if client.is_closed: raise continue found_groups.add(m.group) entries.append(("device/" + m.device_uri, data)) source = _bank_source(m.bank_folder, m.bank_name, m.device_uri.startswith(("images/", "system/"))) if source: entries.append((_source_entry(m, source), source)) else: result.missing_in_bank.append(f"{m.bank_folder}/{m.bank_name}") result.missing_on_device = [uri for group, uri in wanted.items() if group not in found_groups] report("Writing the zip ...", 0.97) os.makedirs(os.path.dirname(path) or ".", exist_ok=True) partial = path + ".part" try: with zipfile.ZipFile(partial, "w", zipfile.ZIP_DEFLATED) as z: for name, content in entries: if isinstance(content, bytes): z.writestr(name, content) else: z.write(content, name) os.replace(partial, path) finally: if os.path.exists(partial): os.remove(partial) result.path = path result.entries = [name for name, _ in entries] report("Done", 1.0) return result # --- Put with Files --------------------------------------------------------------------- # # Follows Ontrol's Workbench tool (ontrolSedonaOx BPutWithFilesTool, ontrolSedonanet # 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. _FONT_FOLDERS = ("device/fonts/", "fonts/", "fontBank/", "fontBankXL/") _JPG = (".jpg", ".jpeg") def _checksum(value): """A kit checksum as an unsigned int, from a signed Sox int or a .sab hex string.""" return int(value, 16) if isinstance(value, str) else value & 0xFFFFFFFF def kits_fit(app_kits, device_kits): """Whether the device's kits.scode can load an app.sab of `app_kits`. App.loadSchema's rule: the app's kits must appear among the device's, in the same order, each with the same checksum; the device may hold more. """ device = iter((n, _checksum(c)) for n, c in device_kits) return all(any(d == (n, _checksum(c)) for d in device) for n, c in app_kits) 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_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 self.platform_comp_id = platform_comp_id self.delete_slot_id = delete_slot_id @property def font_extension(self): if self.platform is _ORION: return ".oxfp" if self.portrait else ".oxfl" return ".bin" @property def screen(self): """(width, height) as the device is set up, as Ontrol's ScreenInfo has it.""" w, h = self.platform.screen return (h, w) if self.portrait else (w, h) def device_target(ds): """Reads the platform, kits, deviceName and platform service off the device. The platform comes from the platform id, as Ontrol's tools read it, the kits standing in for a device that does not give one. """ client = ds.sox_client kits = [(p.name, p.checksum) for p in client.read_schema()] kit_names = {n for n, _ in kits} platform_id = (client.read_version().platform_id or "").lower() 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, platform_id=client.read_version().platform_id) comp = client.cache_get(0) if comp is not None: client.update([comp], sox.CONFIG) target.name = comp.values.get("deviceName") or None if platform is not None and platform.screen is not None: plat = ds.find_component_of_type(platform.service_type, "screenOrientation") if plat is not None: target.platform_comp_id = plat.id target.portrait = (platform is _ORION and str(plat.values.get("screenOrientation")).lower() == "true") slots = client.slot_registry.get((plat.kit_id, plat.type_id)) or [] target.delete_slot_id = next((s["id"] for s in slots if s["name"] == "deleteFile" and s.get("is_action")), None) return target # --- images in the device's form -------------------------------------------------------- def native_image(data, name, target): """A png (or any image PIL reads) as the device's native .bin. Ported from OntOxImageUtil.convertImageToNativeFormat: a 32-byte name, a 32-byte header (flags, width, height), then the pixels. Orion draws BGR(A), turned for a portrait screen, with alpha unless the image fills the screen; Rion RGB565, with alpha only for a translucent image smaller than the screen. """ import io from PIL import Image img = Image.open(io.BytesIO(data)) img.load() width, height = img.size translucent = img.mode in ("RGBA", "LA", "PA") or ( img.mode == "P" and "transparency" in img.info and any(0 < a < 255 for a in img.convert("RGBA").getchannel("A").getdata())) background = (width, height) == target.screen orion = target.platform is _ORION rgb_mode = use_alpha = orion and not background if not orion: use_alpha = translucent and not background rotate = orion and target.portrait rgba = img.convert("RGBA") if rotate: # the Java walks columns right to left: a quarter turn anticlockwise rgba = rgba.transpose(Image.Transpose.ROTATE_90) w, h = rgba.size out = bytearray(name.encode("ascii", errors="replace")) out += bytes(max(0, 32 - len(out))) out += bytes([(4 if rotate else 0) + (2 if rgb_mode else 0) + (1 if use_alpha else 0), w & 0xFF, w >> 8, h & 0xFF, h >> 8]) + bytes(27) pixels = rgba.tobytes() if rgb_mode: for i in range(0, len(pixels), 4): r, g, b, a = pixels[i:i + 4] out += bytes((b, g, r, a)) if use_alpha else bytes((b, g, r)) else: for i in range(0, len(pixels), 4): r, g, b, a = pixels[i:i + 4] rgb565 = (r >> 3) << 11 | (g >> 2) << 5 | (b >> 3) if use_alpha: out.append(a) out += bytes((rgb565 & 0xFF, rgb565 >> 8)) return bytes(out) def device_jpg(data, target): """A jpg as the device takes it: turned a quarter anticlockwise for a portrait Orion (OntOxImageUtil.rotateJpg), otherwise as it is.""" if not (target.platform is _ORION and target.portrait): return data import io from PIL import Image img = Image.open(io.BytesIO(data)) buf = io.BytesIO() img.transpose(Image.Transpose.ROTATE_90).save(buf, "JPEG", quality=75) return buf.getvalue() # --- the plan --------------------------------------------------------------------------- class PutFile: def __init__(self, device_uri, data, source): self.device_uri = device_uri self.data = data self.source = source # where the bytes come from, for the summary class PutPlan: """What a put of the zip would do, and whether the zip fits the device.""" def __init__(self, path): self.path = path 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.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 self.images_missing = [] # images neither in device form nor as a source self.fonts_from_zip = [] # fonts the bank lacks; the backup's copy goes self.fonts_missing = [] # fonts the app names that neither has self.staged = False # set once the device holds app.sab.stage self.has_screen = True # False for a P-ION: the app is all that goes @property def ok(self): return not self.problems 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)) 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 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 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, 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 {platform_label(platform)}; the device " f"is {platform_label(target.platform)}.") return plan zip_name = app.prop(app.app, "deviceName", "") or "" if target.name and zip_name and zip_name != target.name: plan.warnings.append(f"The backup was taken from '{zip_name}'; this device is " f"'{target.name}'.") if platform.screen is None: plan.has_screen = False # no images or fonts to put return plan if target.delete_slot_id is None: plan.warnings.append("The device's platform service has no deleteFile action, " "so its old fonts and images stay.") same_screen = platform is _RION or backup_portrait == target.portrait if not same_screen: plan.warnings.append( f"The backup is from a {'portrait' if backup_portrait else 'landscape'} " f"Orion; this one is {'portrait' if target.portrait else 'landscape'}. Its " "images are made again from the backup's image bank.") _plan_images(plan, z, names, platform, target, same_screen) _plan_fonts(plan, z, names, app, platform, target.font_extension) _plan_logo(plan, z, names, platform, target, same_screen) return plan 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: try: sab = sab_app.encode_sax(z.read(sax_name), kits=chosen) except Exception as e: 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: 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): """Every image the backup holds, in the form the device's screen takes. The backup's device copy goes when it was taken off the same kind of screen; otherwise the image bank's source is made over, as Ontrol's tool always does. """ native, source = {}, {} for name in names: folder, _, file_name = name.rpartition("/") stem, ext = os.path.splitext(file_name) if folder == "device/images": native[stem] = name elif folder == platform.image_bank and ext.lower() in _SOURCE_IMAGE_EXTENSIONS: source[stem] = name for stem in sorted(set(native) | set(source), key=str.lower): if same_screen and stem in native: name = native[stem] plan.media.append(PutFile("images/" + name.rpartition("/")[2], z.read(name), "backup")) elif stem in source: name = source[stem] try: if name.lower().endswith(_JPG): plan.media.append(PutFile(f"images/{stem}.jpg", device_jpg(z.read(name), target), name)) else: plan.media.append(PutFile(f"images/{stem}.bin", native_image(z.read(name), stem, target), name)) plan.converted += 1 except Exception as e: plan.images_missing.append(f"{stem} ({name}: {e})") else: plan.images_missing.append(stem) def _plan_logo(plan, z, names, platform, target, same_screen): """The boot logo: the device's copy, or the backup's source made over (Ontrol's tool sends it into images/ -- system/ is where the device looks for it).""" copy = "device/" + platform.logo_uri if same_screen and copy in names: plan.media.append(PutFile(platform.logo_uri, z.read(copy), "backup")) return source = next((n for n in sorted(names) if n.rpartition("/")[0] == "system" and os.path.splitext(n)[0].endswith("logo")), None) if source is None: return try: data = z.read(source) if platform is _ORION: if not source.lower().endswith(_JPG): return plan.media.append(PutFile(platform.logo_uri, device_jpg(data, target), source)) else: plan.media.append(PutFile(platform.logo_uri, native_image(data, "logo", target), source)) except Exception as e: plan.warnings.append(f"The boot logo could not be made from {source}: {e}") def _plan_fonts(plan, z, names, app, platform, ext): """Every font the backup holds or the app names, in the device's cut `ext`. As Ontrol's tool does, the bank's file goes rather than the backup's; the backup's goes only for a font the bank lacks. """ in_zip = {} # stem -> {extension: entry} for name in names: folder = next((f for f in _FONT_FOLDERS if name.startswith(f)), None) if folder and "/" not in name[len(folder):]: stem, file_ext = os.path.splitext(name[len(folder):]) in_zip.setdefault(stem, {}).setdefault(file_ext.lower(), name) required = {} for m in media_files(app, platform): if m.device_uri.startswith("fonts/"): stem = os.path.splitext(m.device_uri[len("fonts/"):])[0] required[stem] = required.get(stem, False) or m.required for stem in sorted(set(in_zip) | set(required)): bank = _bank_source(platform.font_bank, stem + ext, False) if bank: with open(bank, "rb") as f: plan.media.append(PutFile("fonts/" + stem + ext, f.read(), f"{platform.font_bank}/{stem}{ext}")) elif ext in in_zip.get(stem, {}): plan.media.append(PutFile("fonts/" + stem + ext, z.read(in_zip[stem][ext]), "backup")) plan.fonts_from_zip.append(stem + ext) elif required.get(stem) or stem in in_zip: plan.fonts_missing.append(stem + ext) def put_with_files(client, plan, target, status=None): """Puts the plan on the device; the device takes the app up when it restarts. Runs off the main thread; status(text, fraction) reports progress. A failure raises, naming the step. Once app.sab.stage is in place the app is committed: a later failure leaves the new app staged with some of its media missing. """ def report(text, fraction=None): if status: status(text, fraction) total = sum(len(f.data) for f in plan.app_files + plan.media) or 1 done = 0 def put(uri, f): nonlocal done base = done def progress(sent, size): report(f"Writing {uri} ...", (base + sent) / total) report(f"Writing {uri} ...", base / total) try: client.put_file(uri, f.data, progress) except Exception as e: raise RuntimeError(f"Writing {uri}: {e}") from e done += len(f.data) for f in plan.app_files: put(f.device_uri + ".writing", f) for f in plan.app_files: writing, stage = f.device_uri + ".writing", f.device_uri + ".stage" report(f"Renaming {writing} to {stage} ...", done / total) try: client.rename_file(writing, stage) except Exception as e: raise RuntimeError(f"Renaming {writing} to {stage}: {e}") from e plan.staged = True if target.delete_slot_id is not None: for pattern in ("fonts/*", "images/*"): report(f"Deleting {pattern} on the device ...", done / total) try: client.invoke(target.platform_comp_id, target.delete_slot_id, sox.STR_ID, pattern) # an asStr Buf goes as a Str except Exception as e: raise RuntimeError(f"Deleting {pattern}: {e}") from e for f in plan.media: put(f.device_uri, f) report("Done", 1.0)