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>
909 lines
37 KiB
Python
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)
|