Add App > Get with File: full app backup zip from the device

Reads app.sab and kits.scode over Sox file transfer, decodes app.sab to
app.sax (byte-identical to sedonac), and on Orion/Rion also fetches the
images and fonts the app names, with their sources from Sedona home's
banks. Offers to save the app on the device first when it is dirty, and
asks where to save with <Sedona home>/store/apps and
app-<device>-<app>-<timestamp>.zip filled in.

DASP trace lines are now printed by their own thread: printing from the
network threads to an unread stdout pipe blocked the receiver and made
file transfers time out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:25:05 +03:00
co-authored by Claude Opus 5.5
parent 9b1d029b14
commit 894fdb7eb5
6 changed files with 970 additions and 10 deletions
+144 -6
View File
@@ -7,6 +7,7 @@ from tkinter import filedialog, font as tkfont, messagebox, ttk
import threading
import app_backup
import app_state
import connect_dialog
import device_session
@@ -579,38 +580,171 @@ def do_download_to_sax():
def _invoke_app_action(slot_name):
"""Invokes an action slot on the app component (comp_id 0) of the connected device."""
"""Invokes an action slot on the app component (comp_id 0) of the connected device.
Returns whether the device took it; why not is on the status bar.
"""
ds = app_state.device_session
if ds is None or not ds.is_connected:
app_state.set_status("Not connected to a device", warn=True)
return
return False
comp = ds.sox_client.cache_get(0)
if comp is None:
app_state.set_status("App component not loaded", warn=True)
return
return False
slots = ds.sox_client.slot_registry.get((comp.kit_id, comp.type_id))
if slots is None:
app_state.set_status("No slot registry for the app component", warn=True)
return
return False
slot = next((s for s in slots if s["name"] == slot_name and s.get("is_action")), None)
if slot is None:
app_state.set_status(f"Action slot '{slot_name}' not found on app", warn=True)
return
return False
try:
ds.sox_client.invoke(0, slot["id"])
app_state.set_status(f"Invoked '{slot_name}' on device")
except Exception as e:
app_state.set_status(f"Invoke '{slot_name}' failed: {e}", warn=True)
return
return False
if slot_name == "save":
app_state.mark_clean()
elif slot_name in ("hibernate", "quit", "restart", "reboot"):
do_device_disconnect()
return True
_APP_ACTIONS = ["save", "hibernate", "quit", "restart", "reboot"]
def do_get_with_file():
"""App > Get with File: backs the device's app up to a zip in <Sedona home>/store/apps.
The zip carries the device's app.sab, so changes not yet saved on the device are
offered a save first. The transfer runs off the main thread behind a small progress
window.
"""
ds = app_state.device_session
if not app_state.live_mode or ds is None or not ds.is_connected:
messagebox.showinfo("Get with File", "Connect to a device first.")
return
if not app_state.SEDONA_HOME:
messagebox.showerror("Get with File", "The Sedona home folder is not set (Preferences).")
return
if app_state.is_dirty:
answer = messagebox.askyesnocancel(
"Unsaved Changes",
"The app has changes that are not saved on the device yet." + NL + NL +
"Save the app on the device before getting it?" + NL +
"(No gets the app as it was last saved.)",
)
if answer is None:
return
if answer and not _invoke_app_action("save"):
messagebox.showerror(
"Get with File",
"The device did not save the app, so nothing was fetched. "
"The status bar says why.")
return
path = _ask_backup_path(ds)
if not path:
return
window = app_state.window
dlg = tk.Toplevel(window)
dlg.title("Get with File")
dlg.resizable(False, False)
dlg.transient(window)
dlg.protocol("WM_DELETE_WINDOW", lambda: None) # a transfer cannot be stopped midway
frame = ttk.Frame(dlg, padding=15)
frame.pack(fill=tk.BOTH, expand=True)
label = ttk.Label(frame, text="Starting ...", width=50)
label.pack(anchor="w")
bar = ttk.Progressbar(frame, length=360, maximum=1.0)
bar.pack(fill=tk.X, pady=(8, 0))
dlg.update_idletasks()
dlg.geometry(f"+{window.winfo_x() + (window.winfo_width() - dlg.winfo_width()) // 2}"
f"+{window.winfo_y() + (window.winfo_height() - dlg.winfo_height()) // 2}")
dlg.grab_set()
def status(text, fraction):
def show():
if dlg.winfo_exists():
label.config(text=text)
if fraction is not None:
bar.config(value=fraction)
window.after(0, show)
def finish(result, error):
dlg.grab_release()
dlg.destroy()
if error is not None:
app_state.set_status(f"Get with File failed: {error}", warn=True)
messagebox.showerror("Get with File", f"The backup failed:{NL}{NL}{error}")
return
_report_backup(result)
def work():
try:
result = app_backup.get_with_file(ds.sox_client, path, status)
except Exception as e:
window.after(0, lambda e=e: finish(None, e))
else:
window.after(0, lambda: finish(result, None))
threading.Thread(target=work, daemon=True).start()
def _ask_backup_path(ds):
"""The save dialog for the zip: <Sedona home>/store/apps, the name filled in.
The name takes the app's deviceName and appName, read off the device now -- the
app component's config is not loaded until something asks for it.
"""
device_name, app_name = app_state.device_host, "app"
try:
comp = ds.sox_client.cache_get(0)
if comp is not None:
ds.sox_client.update([comp], device_session.CONFIG)
device_name = comp.values.get("deviceName") or device_name
app_name = comp.values.get("appName") or app_name
except Exception as e:
app_state.set_status(f"Could not read the app's names: {e}", warn=True)
folder = app_backup.backup_folder()
try:
os.makedirs(folder, exist_ok=True)
except OSError:
folder = app_state.SEDONA_HOME
return filedialog.asksaveasfilename(
title="Get with File",
initialdir=folder,
initialfile=app_backup.backup_file_name(device_name, app_name),
defaultextension=".zip",
filetypes=[("Zip archive", "*.zip"), ("All files", "*.*")],
)
def _report_backup(result):
"""Says where the zip went, and warns of anything the app names but could not get."""
lines = [f"Saved {os.path.basename(result.path)}", f"in {os.path.dirname(result.path)}",
"", f"{len(result.entries)} files in the zip."]
warn = False
if result.sax_error:
warn = True
lines += ["", f"app.sax could not be written: {result.sax_error}"]
if result.missing_on_device:
warn = True
lines += ["", "The app names these files, but the device does not have them:"]
lines += [f" {uri}" for uri in result.missing_on_device]
if result.missing_in_bank:
lines += ["", "No source for these in Sedona home's banks; only the device copy is in the zip:"]
lines += [f" {name}" for name in result.missing_in_bank]
app_state.set_status(f"Got {os.path.basename(result.path)}", warn=warn)
(messagebox.showwarning if warn else messagebox.showinfo)("Get with File", NL.join(lines))
NL = chr(10) # newline, for the multi-line message boxes
@@ -2372,6 +2506,7 @@ def _on_connected(ds, root_comp):
app_state.window.title(app_state.base_window_title_live())
menu_device.entryconfig("Disconnect", state="normal")
menu_device.entryconfig("Download to SAX...", state="normal")
menu_app_info.entryconfig("Get with File...", state="normal")
app_state.set_status(f"Connected to {app_state.device_host}:{app_state.device_port}")
sax_root = ds.build_dom_tree(root_comp)
@@ -2438,6 +2573,7 @@ def do_device_disconnect():
menu_device.entryconfig("Discover...", state="normal")
menu_device.entryconfig("Disconnect", state="disabled")
menu_device.entryconfig("Download to SAX...", state="disabled")
menu_app_info.entryconfig("Get with File...", state="disabled")
app_state.set_status("Disconnected")
sax_file.reset_to_startup_state()
@@ -2693,6 +2829,8 @@ menu_bar.add_cascade(label="Kits", menu=menu_kits)
menu_app_info = tk.Menu(menu_bar, tearoff=0)
menu_app_info.add_command(label="App Info", command=show_app_info)
menu_app_info.add_separator()
menu_app_info.add_command(label="Get with File...", command=do_get_with_file, state="disabled")
menu_bar.add_cascade(label="App", menu=menu_app_info)
menu_prefs = tk.Menu(menu_bar, tearoff=0)