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:
1 parent
3ad7a7ab39
commit
fec251f4d1
23 files changed
+2079
-3243
No files matched your search
+183
-69
@@ -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.")
|
||||
|
||||
|
||||
Reference in new issue
Block a user