Rewrite plugin installer as a declarative manifest/engine framework for reliable mix-and-match plugin installs, with backup/restore support and assorted plugin fixes (Hardcover file API, Bookerly/Hardcover SettingAction registration, GitHubSync include path, Lockscreen render timing)

This commit is contained in:
Sterling Archer
2026-07-16 15:39:10 -07:00
parent 3ad7a7ab39
commit fec251f4d1
23 changed files with 2079 additions and 3243 deletions
+183 -69
View File
@@ -5,11 +5,13 @@ import sys
import shutil
import subprocess
import glob
import importlib.util
import argparse
import inspect
import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from framework.discovery import discover_all, build_selected
from framework.engine import apply as apply_plugin_framework, Context, PatchError
REPO_URL = "https://github.com/crosspoint-reader/crosspoint-reader.git"
REPO_DIR = "crosspoint-reader"
PLUGINS_DIR = "plugins"
@@ -64,10 +66,96 @@ def get_local_head_sha():
return None
def backup_device():
print(" Backup your current firmware before flashing? [y/N]: ", end="", flush=True)
answer = input().strip().lower()
def find_backup_files():
return sorted(glob.glob("backup.*.bin"), reverse=True)
def restore_from_backup():
"""If any backup.*.bin files are sitting in the current directory, offer
to write one back to the device instead of proceeding with a fresh
install. This always runs before backup_device() and, if the user goes
through with a restore, exits the script afterward rather than
continuing on to clone/patch/build/flash."""
backups = find_backup_files()
if not backups:
return
print(f" Found {len(backups)} existing backup{'s' if len(backups) != 1 else ''}:")
for i, b in enumerate(backups, 1):
tag = " ← most recent" if i == 1 else ""
try:
size_mb = os.path.getsize(b) / (1024 * 1024)
size_label = f" ({size_mb:.1f} MB)"
except OSError:
size_label = ""
print(f" {i}. {b}{size_label}{tag}")
answer = input("\n Restore firmware from backup? [N/y]: ").strip().lower()
if answer not in ("y", "yes"):
return
if len(backups) == 1:
chosen = backups[0]
else:
choice = input(f"\n Which backup do you want to restore? [1-{len(backups)}] (default: 1): ").strip()
if not choice:
chosen = backups[0]
elif choice.isdigit() and 1 <= int(choice) <= len(backups):
chosen = backups[int(choice) - 1]
else:
sys.exit("ERROR: Invalid choice — aborting restore.")
print()
print(" Ensure your xteink device is connected via USB and awake.")
input(" Press Enter when ready... ")
ports = detect_serial_ports()
if not ports:
sys.exit("ERROR: No USB serial ports detected — cannot restore.")
cu_ports = [(d, desc) for d, desc in ports if d.startswith("/dev/cu.")]
recommended = cu_ports[0][0] if cu_ports else ports[0][0]
print(f"\n Found {len(ports)} serial port(s):")
for i, (device, desc) in enumerate(ports, 1):
tag = " ← recommended" if device == recommended else ""
print(f" {i}. {device} — {desc}{tag}")
port_choice = input(f"\n Press ENTER to use {recommended}, or enter 1-{len(ports)}: ").strip()
if not port_choice:
port = recommended
elif port_choice.isdigit() and 0 <= int(port_choice) - 1 < len(ports):
port = ports[int(port_choice) - 1][0]
else:
sys.exit("ERROR: Invalid port choice — aborting restore.")
while True:
print(f"\n Writing {chosen} → {port} ...")
result = subprocess.run(
f"esptool --port {port} --baud 921600 write-flash 0x0 {chosen}",
shell=True,
)
if result.returncode == 0:
print(f"\n ✓ Restored {chosen} to the device.")
sys.exit(0)
print(" ✗ Restore failed (USB dropout, wrong port, or device asleep are common causes).")
retry_choice = input(" [r]etry or [A]bort? [r/A]: ").strip().lower()
if retry_choice == "r":
print("\n Ensure your xteink device is still connected via USB and awake")
print(" (try a different cable/port if it keeps dropping out).")
input(" Press Enter to retry... ")
continue
else:
sys.exit("Aborted.")
def backup_device():
print(" Backup your current firmware before flashing? [Y/n]: ", end="", flush=True)
answer = input().strip().lower()
if answer not in ("", "y", "yes"):
print(" Skipping backup.")
return
@@ -75,11 +163,20 @@ def backup_device():
print(" Ensure your xteink device is connected via USB and awake.")
input(" Press Enter when ready... ")
ports = detect_serial_ports()
if not ports:
print(" No USB serial ports detected — skipping backup.")
return
while True:
ports = detect_serial_ports()
if ports:
break
print(" No USB serial ports detected.")
retry_choice = input(" Check the USB connection, then [r]etry or [C]ontinue without backup? [r/C]: ").strip().lower()
if retry_choice == "r":
print("\n Ensure your xteink device is still connected via USB and awake")
print(" (try a different cable/port if it isn't being detected).")
input(" Press Enter to retry... ")
continue
else:
print(" Continuing without a backup.")
return
cu_ports = [(d, desc) for d, desc in ports if d.startswith("/dev/cu.")]
recommended = cu_ports[0][0] if cu_ports else ports[0][0]
@@ -99,23 +196,43 @@ def backup_device():
print(" Invalid choice — skipping backup.")
return
timestamp = datetime.datetime.now().strftime("%Y%m%d.%H%M%S")
filename = f"backup.{timestamp}.bin"
print(f"\n Reading flash from {port} → {filename} ...")
while True:
timestamp = datetime.datetime.now().strftime("%Y%m%d.%H%M%S")
filename = f"backup.{timestamp}.bin"
print(f"\n Reading flash from {port} → {filename} ...")
result = subprocess.run(
f"esptool --port {port} --baud 921600 read-flash 0x0 ALL {filename}",
shell=True,
)
result = subprocess.run(
f"esptool --port {port} --baud 921600 read-flash 0x0 ALL {filename}",
shell=True,
)
if result.returncode == 0:
print(f" ✓ Backup saved to {filename}")
print()
print(f" To restore your device to this backup, run:")
print(f" esptool --port {port} --baud 921600 write-flash 0x0 {filename}")
else:
print(" ✗ Backup failed. You can continue or abort and retry manually.")
if input(" Continue without backup? [y/N]: ").strip().lower() not in ("y", "yes"):
if result.returncode == 0:
print(f" ✓ Backup saved to {filename}")
print()
print(f" To restore your device to this backup, run:")
print(f" esptool --port {port} --baud 921600 write-flash 0x0 {filename}")
break
# Remove the partial/corrupt file left behind by the failed attempt
if os.path.exists(filename):
try:
os.remove(filename)
except OSError:
pass
print(" ✗ Backup failed (USB dropout, wrong port, or device asleep are common causes).")
choice = input(" [r]etry, [A]bort, or [c]ontinue without backup? [r/A/c]: ").strip().lower()
if choice == "r":
print("\n Ensure your xteink device is still connected via USB and awake")
print(" (try a different cable/port if it keeps dropping out).")
input(" Press Enter to retry... ")
continue
elif choice == "c":
print(" Continuing without a backup.")
break
else:
# Empty input or anything else defaults to the safe choice: abort.
print(" Aborted.")
sys.exit(0)
print()
@@ -145,28 +262,6 @@ def clone_repo(force: bool = True):
run(f"git clone --recursive {REPO_URL} {REPO_DIR}")
def run_plugin_patch(plugin_dir: str, yes_all: bool = False) -> bool:
patch_path = os.path.join(plugin_dir, "patch.py")
if not os.path.exists(patch_path):
print(f" WARNING: No patch.py found in {plugin_dir}, skipping.")
return False
spec = importlib.util.spec_from_file_location("patch", patch_path)
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
if hasattr(module, "patch"):
sig = inspect.signature(module.patch)
if "yes_all" in sig.parameters:
module.patch(os.path.abspath(REPO_DIR), yes_all=yes_all)
else:
module.patch(os.path.abspath(REPO_DIR))
return True
except Exception as exc:
print(f" ERROR while running patch.py for {plugin_dir}: {exc}")
return False
def clear_plugin_caches():
for plugin_name in os.listdir(PLUGINS_DIR):
cache_dir = os.path.join(PLUGINS_DIR, plugin_name, "__pycache__")
@@ -174,25 +269,20 @@ def clear_plugin_caches():
rmtree(cache_dir)
def apply_plugins(yes_all: bool = False):
print("[2/3] Applying plugins...")
def select_plugins(yes_all: bool = False):
"""Ask the user which plugins to install. Selection only - no file
touched yet, so nothing here depends on which other plugins are chosen."""
if not os.path.exists(PLUGINS_DIR):
print(f" No '{PLUGINS_DIR}' directory found — skipping plugin step.")
return
return []
plugin_dirs = sorted(
d for d in os.listdir(PLUGINS_DIR)
if os.path.isdir(os.path.join(PLUGINS_DIR, d))
)
if not plugin_dirs:
print(" No plugin directories found.")
return
for plugin_name in plugin_dirs:
plugin_path = os.path.join(PLUGINS_DIR, plugin_name)
available = discover_all(PLUGINS_DIR)
if not available:
print(" No plugins with a plugin.py manifest found.")
return []
chosen = []
for plugin_name in sorted(available):
print(f"\n Plugin: {plugin_name}")
if yes_all:
answer = "y"
@@ -201,15 +291,37 @@ def apply_plugins(yes_all: bool = False):
answer = input(f" Install '{plugin_name}'? [Y/n]: ").strip().lower()
if answer in ("", "y", "yes"):
print(f" Installing {plugin_name}...")
success = run_plugin_patch(plugin_path, yes_all=yes_all)
if success:
print(f" ✓ {plugin_name} installed.")
else:
print(f" ✗ {plugin_name} failed or was skipped.")
chosen.append(plugin_name)
else:
print(f" Skipping {plugin_name}.")
return chosen
def apply_plugins(plugin_names, yes_all: bool = False):
print("[2/3] Applying plugins...")
if not plugin_names:
print(" No plugins selected.")
return
print(f" Selected: {', '.join(plugin_names)}")
ctx = Context(repo_dir=os.path.abspath(REPO_DIR), yes_all=yes_all, prompt=not yes_all)
try:
selected = build_selected(plugin_names, PLUGINS_DIR, ctx)
apply_plugin_framework(ctx, selected)
except (PatchError, KeyError) as exc:
sys.exit(
f"\nERROR: {exc}\n\n"
"This usually means the upstream crosspoint-reader source has "
"changed in a way this plugin set doesn't recognize yet. No "
"half-applied changes were left in place beyond this point - "
"please report this so the anchor can be updated."
)
print(f"\n ✓ {len(plugin_names)} plugin(s) installed: {', '.join(plugin_names)}")
def list_likely_serial_ports():
patterns = [
@@ -371,10 +483,12 @@ def main():
print(" Aborted.")
sys.exit(0)
print()
restore_from_backup()
backup_device()
clone_repo(force=not args.no_reclone)
clear_plugin_caches()
apply_plugins(yes_all=args.yes)
chosen_plugins = select_plugins(yes_all=args.yes)
apply_plugins(chosen_plugins, yes_all=args.yes)
build_and_flash(args.environment)
print("\nDone.")