Files
sedona_editor/app_backup.py
T
ArdaandClaude Opus 5.5 e070bd0f1e 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 <noreply@anthropic.com>
2026-10-11 10:37:08 +03:00

909 lines
37 KiB
Python

"""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/<name>.bin
device/fonts/<font file>
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]/<name>.png the sources of the device's images and fonts, taken from
fontBank[XL]/<font file> 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-<device name>-<app name>-<yyyyMMdd-HHmmss>.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 '<name>.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)