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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user