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
Whitespace-only changes.
@@ -0,0 +1,38 @@
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
from .engine import SelectedPlugin
|
||||
|
||||
|
||||
def discover_all(plugins_dir):
|
||||
"""Return {name: (plugin_dir, get_manifest_callable)} for every plugin
|
||||
directory that has a plugin.py exposing get_manifest(ctx)."""
|
||||
found = {}
|
||||
for name in sorted(os.listdir(plugins_dir)):
|
||||
plugin_dir = os.path.join(plugins_dir, name)
|
||||
manifest_path = os.path.join(plugin_dir, "plugin.py")
|
||||
if not os.path.isdir(plugin_dir) or not os.path.exists(manifest_path):
|
||||
continue
|
||||
spec = importlib.util.spec_from_file_location(f"xteink_plugin_{name}", manifest_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
if not hasattr(module, "get_manifest"):
|
||||
continue
|
||||
found[name] = (plugin_dir, module.get_manifest)
|
||||
return found
|
||||
|
||||
|
||||
def build_selected(names, plugins_dir, ctx):
|
||||
"""Build a deterministic, sorted-by-name list of SelectedPlugin for the
|
||||
given plugin names. Order doesn't affect correctness (the engine derives
|
||||
shared files from the whole set at once) but keeping it stable makes
|
||||
output/diffs reproducible."""
|
||||
found = discover_all(plugins_dir)
|
||||
selected = []
|
||||
for name in sorted(names):
|
||||
if name not in found:
|
||||
raise KeyError(f"Unknown plugin: {name} (available: {', '.join(sorted(found))})")
|
||||
plugin_dir, get_manifest = found[name]
|
||||
manifest = get_manifest(ctx)
|
||||
selected.append(SelectedPlugin(name=name, plugin_dir=plugin_dir, manifest=manifest))
|
||||
return selected
|
||||
@@ -0,0 +1,858 @@
|
||||
"""
|
||||
The composition engine.
|
||||
|
||||
Design principle: every shared file is touched EXACTLY ONCE, in one pass,
|
||||
built from the full set of *currently selected* plugins. Nothing is derived
|
||||
from "is some other plugin's marker text already in this file" - so the
|
||||
result for {darkmode} alone, {smallerfonts} alone, or {darkmode,
|
||||
smallerfonts, hardcover, lockscreen, pong, githubsync, bookerly} together is
|
||||
always internally consistent, because it's always generated fresh from the
|
||||
same selection instead of being poked at incrementally by N independent
|
||||
scripts that each assume the others already ran (or didn't).
|
||||
|
||||
Anchors are matched against the *current* upstream crosspoint-reader source.
|
||||
If an anchor isn't found, we raise PatchError immediately with the file and
|
||||
a snippet of what we were looking for, rather than silently no-op'ing like
|
||||
the old patch.py scripts did (which is how upstream drift + missing plugins
|
||||
produced silently-broken installs before).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from .manifest import PluginManifest
|
||||
|
||||
|
||||
class PatchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectedPlugin:
|
||||
name: str
|
||||
plugin_dir: str
|
||||
manifest: PluginManifest
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
repo_dir: str
|
||||
yes_all: bool = False
|
||||
prompt: bool = True
|
||||
|
||||
|
||||
def read_file(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def write_file(path, content):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def find_first(filename, repo_dir):
|
||||
results = glob.glob(os.path.join(repo_dir, "**", filename), recursive=True)
|
||||
if not results:
|
||||
raise PatchError(f"Could not locate {filename} anywhere under {repo_dir}")
|
||||
return results[0]
|
||||
|
||||
|
||||
def replace_once(content, anchor, replacement, file_label):
|
||||
count = content.count(anchor)
|
||||
if count == 0:
|
||||
raise PatchError(
|
||||
f"{file_label}: expected anchor not found (upstream source may have "
|
||||
f"changed). Anchor was:\n{anchor!r}"
|
||||
)
|
||||
if count > 1:
|
||||
raise PatchError(
|
||||
f"{file_label}: anchor matched {count} times, expected exactly 1 "
|
||||
f"(ambiguous patch target). Anchor was:\n{anchor!r}"
|
||||
)
|
||||
return content.replace(anchor, replacement, 1)
|
||||
|
||||
|
||||
def contains_once(content, anchor, file_label):
|
||||
count = content.count(anchor)
|
||||
if count == 0:
|
||||
raise PatchError(f"{file_label}: expected anchor not found:\n{anchor!r}")
|
||||
if count > 1:
|
||||
raise PatchError(f"{file_label}: anchor matched {count} times, expected exactly 1:\n{anchor!r}")
|
||||
|
||||
|
||||
def uses_plugins_tab(selected: List[SelectedPlugin]) -> bool:
|
||||
return any(sp.manifest.plugins_tab_entries for sp in selected)
|
||||
|
||||
|
||||
def validate_manifests(selected: List[SelectedPlugin]):
|
||||
"""Catch self-inconsistent manifests before touching any file. This is
|
||||
exactly the class of bug the old patch.py scripts were prone to
|
||||
(referencing something that was never declared) - the difference now is
|
||||
it's a single, mechanical check instead of relying on every plugin
|
||||
author to notice by eye. Currently checks: every SettingAction an
|
||||
"action"-kind PluginsTabEntry refers to must have a matching
|
||||
SettingActionEnumValue declared somewhere in the selection (usually the
|
||||
same plugin)."""
|
||||
declared_actions = {a.name for sp in selected for a in sp.manifest.setting_actions}
|
||||
errors = []
|
||||
for sp in selected:
|
||||
for e in sp.manifest.plugins_tab_entries:
|
||||
if e.kind == "action" and e.action_name and e.action_name not in declared_actions:
|
||||
errors.append(
|
||||
f"plugin '{sp.name}' has a PluginsTabEntry with action_name="
|
||||
f"'{e.action_name}' but no matching SettingActionEnumValue('{e.action_name}') "
|
||||
f"is declared in its (or any selected plugin's) setting_actions - the generated "
|
||||
f"code would reference an undeclared SettingAction enum member and fail to compile."
|
||||
)
|
||||
if errors:
|
||||
raise PatchError("Manifest validation failed:\n - " + "\n - ".join(errors))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source file copying
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def copy_source_files(repo_dir, selected: List[SelectedPlugin]):
|
||||
for sp in selected:
|
||||
for sf in sp.manifest.source_files:
|
||||
src = os.path.join(sp.plugin_dir, sf.src_name)
|
||||
if not os.path.exists(src):
|
||||
raise PatchError(f"{sp.name}: declared source file not found: {src}")
|
||||
dest_dir = os.path.join(repo_dir, sf.dest_subdir)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
dst = os.path.join(dest_dir, sf.src_name)
|
||||
shutil.copy2(src, dst)
|
||||
print(f" ✓ [{sp.name}] {sf.src_name}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CrossPointSettings.h (persisted struct fields + includes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_cross_point_settings_h(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("CrossPointSettings.h", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "CrossPointSettings.h"
|
||||
|
||||
includes = [inc.header for sp in selected for inc in sp.manifest.includes
|
||||
if inc.target == "cross_point_settings_h"]
|
||||
fields = [f.decl for sp in selected for f in sp.manifest.settings_fields]
|
||||
|
||||
if not includes and not fields:
|
||||
return
|
||||
|
||||
if includes:
|
||||
include_anchor = "#include <HalStorage.h>"
|
||||
contains_once(content, include_anchor, label)
|
||||
include_block = include_anchor + "".join(f'\n#include "{h}"' for h in includes)
|
||||
content = content.replace(include_anchor, include_block, 1)
|
||||
|
||||
if fields:
|
||||
field_anchor = " uint8_t imageRendering = IMAGES_DISPLAY;"
|
||||
contains_once(content, field_anchor, label)
|
||||
field_block = field_anchor + "".join(f"\n {decl}" for decl in fields)
|
||||
content = content.replace(field_anchor, field_block, 1)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(includes)} include(s), {len(fields)} field(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SettingsList.h (master settings list used for save/load + web API dump)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _settings_list_entry_for_tab_entry(e):
|
||||
"""Build the categoryless SettingsList.h entry for a PluginsTabEntry."""
|
||||
if e.kind == "enum":
|
||||
n = len(e.option_labels) if e.option_labels else 2
|
||||
dummy_opts = ", ".join(["StrId::STR_NONE_OPT"] * n)
|
||||
obf = ".withObfuscated()" if e.obfuscated else ""
|
||||
return (f' SettingInfo::Enum(StrId::STR_NONE_OPT, &CrossPointSettings::{e.key},\n'
|
||||
f' {{{dummy_opts}}}, "{e.key}"){obf},')
|
||||
if e.kind == "toggle":
|
||||
obf = ".withObfuscated()" if e.obfuscated else ""
|
||||
return f' SettingInfo::Toggle(StrId::STR_NONE_OPT, &CrossPointSettings::{e.key}, "{e.key}"){obf},'
|
||||
if e.kind == "string":
|
||||
obf = ".withObfuscated()" if e.obfuscated else ""
|
||||
return (f' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.{e.key},\n'
|
||||
f' sizeof(SETTINGS.{e.key}), "{e.key}"){obf},')
|
||||
return None # "action" kind has no persisted entry
|
||||
|
||||
|
||||
def patch_settings_list_h(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("SettingsList.h", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "SettingsList.h"
|
||||
|
||||
lines = []
|
||||
for sp in selected:
|
||||
for e in sp.manifest.settings_list_entries:
|
||||
lines.append(f" {e.cpp}")
|
||||
for e in sp.manifest.plugins_tab_entries:
|
||||
entry = _settings_list_entry_for_tab_entry(e)
|
||||
if entry:
|
||||
lines.append(entry)
|
||||
|
||||
if not lines:
|
||||
return
|
||||
|
||||
anchor = " };\n // Only show tilt page turn setting when the QMI8658 IMU is present (X3)"
|
||||
contains_once(content, anchor, label)
|
||||
block = "\n".join(lines) + "\n };\n // Only show tilt page turn setting when the QMI8658 IMU is present (X3)"
|
||||
content = content.replace(anchor, block, 1)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(lines)} entrie(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SettingsActivity.h (SettingAction enum, categoryCount, pluginsSettings vec)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_settings_activity_h(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("SettingsActivity.h", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "SettingsActivity.h"
|
||||
|
||||
includes = [inc.header for sp in selected for inc in sp.manifest.includes
|
||||
if inc.target == "settings_activity_h"]
|
||||
if includes:
|
||||
include_anchor = "#include <I18n.h>"
|
||||
contains_once(content, include_anchor, label)
|
||||
content = content.replace(include_anchor,
|
||||
include_anchor + "".join(f'\n#include "{h}"' for h in includes), 1)
|
||||
|
||||
actions = [a.name for sp in selected for a in sp.manifest.setting_actions]
|
||||
if actions:
|
||||
m = re.search(r"enum class SettingAction \{.*?\n(\};)", content, re.DOTALL)
|
||||
if not m:
|
||||
raise PatchError(f"{label}: could not locate `enum class SettingAction {{ ... }};` block")
|
||||
insertion = "".join(f" {name},\n" for name in actions)
|
||||
content = content[:m.start(1)] + insertion + content[m.start(1):]
|
||||
|
||||
if uses_plugins_tab(selected):
|
||||
cat_anchor = "static constexpr int categoryCount = 4;"
|
||||
contains_once(content, cat_anchor, label)
|
||||
content = content.replace(cat_anchor, "static constexpr int categoryCount = 5;", 1)
|
||||
|
||||
vec_anchor = " std::vector<SettingInfo> systemSettings;"
|
||||
contains_once(content, vec_anchor, label)
|
||||
content = content.replace(vec_anchor,
|
||||
vec_anchor + "\n std::vector<SettingInfo> pluginsSettings;", 1)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(includes)} include(s), {len(actions)} action(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SettingsActivity.cpp (the big one: tab, list population, labels, values,
|
||||
# switch-case action dispatch)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_settings_activity_cpp(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("SettingsActivity.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "SettingsActivity.cpp"
|
||||
|
||||
includes = [inc.header for sp in selected for inc in sp.manifest.includes
|
||||
if inc.target == "settings_activity_cpp"]
|
||||
if includes:
|
||||
anchor = '#include "SettingsList.h"'
|
||||
contains_once(content, anchor, label)
|
||||
content = content.replace(anchor, anchor + "".join(f'\n#include "{h}"' for h in includes), 1)
|
||||
|
||||
tab_entries = [(sp, e) for sp in selected for e in sp.manifest.plugins_tab_entries]
|
||||
|
||||
if uses_plugins_tab(selected):
|
||||
# 1) categoryNames array gains a virtual "Plugins" slot (StrId::STR_NONE_OPT).
|
||||
cat_anchor = (
|
||||
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
|
||||
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};'
|
||||
)
|
||||
cat_replacement = (
|
||||
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
|
||||
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n'
|
||||
' StrId::STR_NONE_OPT};'
|
||||
)
|
||||
content = replace_once(content, cat_anchor, cat_replacement, label)
|
||||
|
||||
# 2) rebuildSettingsLists(): clear the plugins vector too.
|
||||
clear_anchor = " systemSettings.clear();"
|
||||
content = replace_once(content, clear_anchor, clear_anchor + "\n pluginsSettings.clear();", label)
|
||||
|
||||
# 3) Populate pluginsSettings once, from every selected entry that
|
||||
# shows on-device (all "action" entries; enum/toggle entries
|
||||
# unless explicitly hidden).
|
||||
push_lines = []
|
||||
for sp, e in tab_entries:
|
||||
if e.kind == "action":
|
||||
push_lines.append(
|
||||
" pluginsSettings.push_back(SettingInfo::Action(\n"
|
||||
f" StrId::STR_NONE_OPT, SettingAction::{e.action_name}\n"
|
||||
" ));"
|
||||
)
|
||||
elif e.kind in ("enum", "toggle") and e.show_on_device:
|
||||
n = len(e.option_labels) if e.option_labels else 2
|
||||
dummy_opts = ", ".join(["StrId::STR_NONE_OPT"] * n)
|
||||
if e.kind == "enum":
|
||||
push_lines.append(
|
||||
" pluginsSettings.push_back(SettingInfo::Enum(\n"
|
||||
f" StrId::STR_NONE_OPT, &CrossPointSettings::{e.key},\n"
|
||||
f" {{{dummy_opts}}}, \"{e.key}\", StrId::STR_NONE_OPT\n"
|
||||
" ));"
|
||||
)
|
||||
else:
|
||||
push_lines.append(
|
||||
" pluginsSettings.push_back(SettingInfo::Toggle(\n"
|
||||
f" StrId::STR_NONE_OPT, &CrossPointSettings::{e.key}, \"{e.key}\"\n"
|
||||
" ));"
|
||||
)
|
||||
populate_anchor = (
|
||||
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));'
|
||||
)
|
||||
content = replace_once(content, populate_anchor,
|
||||
populate_anchor + ("\n" + "\n".join(push_lines) if push_lines else ""), label)
|
||||
|
||||
# 4) The two `switch (selectedCategoryIndex) { case 0: ... case 3: ... }`
|
||||
# blocks (rebuildSettingsLists + loop()'s hasChangedCategory branch)
|
||||
# both need a case 4. Match structurally so indentation differences
|
||||
# between the two occurrences don't matter.
|
||||
case_pattern = re.compile(
|
||||
r"([ \t]*)case 3:\n([ \t]*)currentSettings = &systemSettings;\n[ \t]*break;\n"
|
||||
)
|
||||
|
||||
def _add_case4(m):
|
||||
case_indent, body_indent = m.group(1), m.group(2)
|
||||
return m.group(0) + f"{case_indent}case 4:\n{body_indent}currentSettings = &pluginsSettings;\n{body_indent}break;\n"
|
||||
|
||||
new_content, n_subs = case_pattern.subn(_add_case4, content)
|
||||
if n_subs != 2:
|
||||
raise PatchError(f"{label}: expected 2 `case 3:` switch blocks, found {n_subs}")
|
||||
content = new_content
|
||||
|
||||
# 5) Tab bar label: index 4 renders literally as "Plugins".
|
||||
tabs_anchor = (
|
||||
' std::vector<TabInfo> tabs;\n'
|
||||
' tabs.reserve(categoryCount);\n'
|
||||
' for (int i = 0; i < categoryCount; i++) {\n'
|
||||
' tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});\n'
|
||||
' }'
|
||||
)
|
||||
tabs_replacement = (
|
||||
' std::vector<TabInfo> tabs;\n'
|
||||
' tabs.reserve(categoryCount);\n'
|
||||
' for (int i = 0; i < categoryCount; i++) {\n'
|
||||
' const char* tabLabel = (i == 4) ? "Plugins" : I18N.get(categoryNames[i]);\n'
|
||||
' tabs.push_back({tabLabel, selectedCategoryIndex == i});\n'
|
||||
' }'
|
||||
)
|
||||
content = replace_once(content, tabs_anchor, tabs_replacement, label)
|
||||
|
||||
# 6) confirmLabel / "next category" hint also needs to say "Plugins".
|
||||
confirm_anchor = (
|
||||
' const auto confirmLabel =\n'
|
||||
' (selectedSettingIndex == 0)\n'
|
||||
' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n'
|
||||
' : (selectedSettingIndex > 0 && (*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP\n'
|
||||
' ? tr(STR_SELECT)\n'
|
||||
' : tr(STR_TOGGLE));'
|
||||
)
|
||||
confirm_replacement = (
|
||||
' const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n'
|
||||
' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n'
|
||||
' const auto confirmLabel =\n'
|
||||
' (selectedSettingIndex == 0)\n'
|
||||
' ? nextCatLabel\n'
|
||||
' : (selectedSettingIndex > 0 && (*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP\n'
|
||||
' ? tr(STR_SELECT)\n'
|
||||
' : tr(STR_TOGGLE));'
|
||||
)
|
||||
content = replace_once(content, confirm_anchor, confirm_replacement, label)
|
||||
|
||||
# 7) Row label lambda: plugin rows have nameId == STR_NONE_OPT, so they
|
||||
# need their real label supplied out-of-band.
|
||||
label_lines = []
|
||||
for sp, e in tab_entries:
|
||||
if e.kind == "action":
|
||||
label_lines.append(
|
||||
f' if (s.type == SettingType::ACTION && s.action == SettingAction::{e.action_name}) '
|
||||
f'return "{e.label}";'
|
||||
)
|
||||
elif e.key:
|
||||
label_lines.append(
|
||||
f' if (s.key && std::string(s.key) == "{e.key}") return "{e.label}";'
|
||||
)
|
||||
label_anchor = (
|
||||
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,'
|
||||
)
|
||||
label_replacement = (
|
||||
' [&settings, this](int index) -> std::string {\n'
|
||||
' if (selectedCategoryIndex == 4) {\n'
|
||||
' const auto& s = settings[index];\n'
|
||||
+ "\n".join(label_lines) + "\n"
|
||||
' }\n'
|
||||
' return std::string(I18N.get(settings[index].nameId));\n'
|
||||
' }, nullptr, nullptr,'
|
||||
)
|
||||
content = replace_once(content, label_anchor, label_replacement, label)
|
||||
|
||||
# 8) Value text: plugin-owned enum keys and action rows get custom
|
||||
# rendering; anything else falls through to the original chain
|
||||
# completely unchanged.
|
||||
value_branches = []
|
||||
for sp, e in tab_entries:
|
||||
if e.kind == "enum" and e.value_text_expr:
|
||||
value_branches.append(
|
||||
f' if (setting.key && std::string(setting.key) == "{e.key}") {{\n'
|
||||
f' const uint8_t value = SETTINGS.*(setting.valuePtr);\n'
|
||||
f' valueText = {e.value_text_expr};\n'
|
||||
f' }} else '
|
||||
)
|
||||
elif e.kind == "action":
|
||||
value_branches.append(
|
||||
f' if (setting.type == SettingType::ACTION && setting.action == SettingAction::{e.action_name}) {{\n'
|
||||
f' valueText = "{e.action_value_text}";\n'
|
||||
f' }} else '
|
||||
)
|
||||
if value_branches:
|
||||
value_anchor = ' if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {'
|
||||
value_replacement = "".join(value_branches) + value_anchor
|
||||
content = replace_once(content, value_anchor, value_replacement, label)
|
||||
|
||||
# 9) switch (setting.action) { ... } dispatch: launch the activity.
|
||||
case_lines = []
|
||||
for sp, e in tab_entries:
|
||||
if e.kind == "action" and e.activity_launch_expr:
|
||||
case_lines.append(
|
||||
f" case SettingAction::{e.action_name}:\n"
|
||||
f" startActivityForResult({e.activity_launch_expr}, resultHandler);\n"
|
||||
f" break;"
|
||||
)
|
||||
if case_lines:
|
||||
none_anchor = " case SettingAction::None:\n // Do nothing\n break;"
|
||||
content = replace_once(content, none_anchor, "\n".join(case_lines) + "\n" + none_anchor, label)
|
||||
|
||||
overrides = [o for sp in selected for o in sp.manifest.enum_value_overrides]
|
||||
if overrides:
|
||||
enum_anchor = (
|
||||
' } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {\n'
|
||||
' const uint8_t value = SETTINGS.*(setting.valuePtr);\n'
|
||||
' valueText = I18N.get(setting.enumValues[value]);\n'
|
||||
' } else if (setting.type == SettingType::ENUM && setting.valueGetter) {'
|
||||
)
|
||||
by_key = {}
|
||||
for o in overrides:
|
||||
by_key.setdefault(o.key, []).append(o)
|
||||
branches = ""
|
||||
for key, ovs in by_key.items():
|
||||
for i, o in enumerate(ovs):
|
||||
kw = "if" if i == 0 else "} else if"
|
||||
branches += (f' {kw} (setting.key && std::string(setting.key) == "{key}" && {o.condition_expr}) {{\n'
|
||||
f' valueText = "{o.text}";\n')
|
||||
branches += " } else {\n valueText = I18N.get(setting.enumValues[value]);\n }\n"
|
||||
enum_replacement = (
|
||||
' } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {\n'
|
||||
' const uint8_t value = SETTINGS.*(setting.valuePtr);\n'
|
||||
+ branches +
|
||||
' } else if (setting.type == SettingType::ENUM && setting.valueGetter) {'
|
||||
)
|
||||
content = replace_once(content, enum_anchor, enum_replacement, label)
|
||||
|
||||
toggle_hooks = [h for sp in selected for h in sp.manifest.toggle_hooks]
|
||||
if toggle_hooks:
|
||||
tail_anchor = (
|
||||
" syncQuickResumeTimeoutForSleepScreen(sleepScreenChanged, quickResumeTimeoutChanged);\n"
|
||||
" SETTINGS.saveToFile();\n"
|
||||
" rebuildSettingsLists();\n"
|
||||
" selectedSettingIndex = std::min(selectedSettingIndex, settingsCount);\n"
|
||||
"}"
|
||||
)
|
||||
hook_block = ""
|
||||
for h in toggle_hooks:
|
||||
hook_block += f' if (setting.key && std::string(setting.key) == "{h.key}") {{\n{h.code}\n }}\n'
|
||||
content = replace_once(content, tail_anchor, hook_block + tail_anchor, label)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(tab_entries)} Plugins-tab row(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CrossPointWebServer.cpp (web UI name/category + enum option overrides)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_web_server(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("CrossPointWebServer.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "CrossPointWebServer.cpp"
|
||||
|
||||
keyed_entries = []
|
||||
for sp in selected:
|
||||
for e in sp.manifest.plugins_tab_entries:
|
||||
if e.key:
|
||||
keyed_entries.append(e)
|
||||
# settings_list_entries with no PluginsTabEntry counterpart (e.g. a
|
||||
# plugin might declare a raw entry directly) don't get name overrides
|
||||
# automatically since we don't know their key/label here - plugins
|
||||
# should use PluginsTabEntry (with show_on_device=False) for that.
|
||||
|
||||
appends = [a for sp in selected for a in sp.manifest.web_option_appends]
|
||||
|
||||
if not keyed_entries and not appends:
|
||||
return
|
||||
|
||||
hidden = [e.key for e in keyed_entries if e.hidden_from_web]
|
||||
named = [e for e in keyed_entries if not e.hidden_from_web]
|
||||
|
||||
if hidden:
|
||||
hide_cond = " || ".join(f'strcmp(s.key, "{k}") == 0' for k in hidden)
|
||||
skip_line = f' if (s.key && ({hide_cond})) continue;\n'
|
||||
else:
|
||||
skip_line = ""
|
||||
|
||||
if named:
|
||||
name_block = " if (s.key) {\n"
|
||||
for i, e in enumerate(named):
|
||||
kw = "if" if i == 0 else "} else if"
|
||||
name_block += f' {kw} (strcmp(s.key, "{e.key}") == 0) {{\n'
|
||||
name_block += f' doc["name"] = "{e.label}";\n'
|
||||
name_block += ' doc["category"] = "Plugins";\n'
|
||||
name_block += " }\n }\n"
|
||||
else:
|
||||
name_block = ""
|
||||
|
||||
if skip_line or name_block:
|
||||
anchor = ' doc["category"] = I18N.get(s.category);\n\n switch (s.type) {'
|
||||
contains_once(content, anchor, label)
|
||||
replacement = ' doc["category"] = I18N.get(s.category);\n\n' + skip_line + name_block + '\n switch (s.type) {'
|
||||
content = content.replace(anchor, replacement, 1)
|
||||
|
||||
enum_entries = [e for e in named if e.kind == "enum" and e.option_labels]
|
||||
if enum_entries:
|
||||
opt_block = ""
|
||||
for i, e in enumerate(enum_entries):
|
||||
kw = "if" if i == 0 else "} else if"
|
||||
opt_block += f' {kw} (strcmp(s.key, "{e.key}") == 0) {{\n'
|
||||
opt_block += ' JsonArray opts = doc["options"].to<JsonArray>();\n'
|
||||
for lbl in e.option_labels:
|
||||
opt_block += f' opts.add("{lbl}");\n'
|
||||
opt_block += " }\n"
|
||||
opt_wrapped = " if (s.key) {\n" + opt_block + " }\n"
|
||||
|
||||
enum_anchor = (
|
||||
' for (const auto& opt : s.enumValues) {\n'
|
||||
' options.add(I18N.get(opt));\n'
|
||||
' }\n'
|
||||
' }\n'
|
||||
' break;\n'
|
||||
)
|
||||
contains_once(content, enum_anchor, label)
|
||||
content = content.replace(enum_anchor, enum_anchor[:-len(' break;\n')] + opt_wrapped + ' break;\n', 1)
|
||||
|
||||
if appends:
|
||||
by_key = {}
|
||||
for a in appends:
|
||||
by_key.setdefault(a.key, []).append(a.label)
|
||||
append_block = " if (s.key) {\n"
|
||||
for key, labels in by_key.items():
|
||||
append_block += f' if (strcmp(s.key, "{key}") == 0) {{\n'
|
||||
for lbl in labels:
|
||||
append_block += f' options.add("{lbl}");\n'
|
||||
append_block += " }\n"
|
||||
append_block += " }\n"
|
||||
|
||||
loop_anchor = (
|
||||
' for (const auto& opt : s.enumValues) {\n'
|
||||
' options.add(I18N.get(opt));\n'
|
||||
' }\n'
|
||||
' }\n'
|
||||
)
|
||||
contains_once(content, loop_anchor, label)
|
||||
content = content.replace(loop_anchor, loop_anchor + append_block, 1)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(keyed_entries)} key(s), {len(hidden)} hidden, {len(appends)} option append(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main.cpp (early_boot / post_boot hooks, includes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_main_cpp(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("main.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "main.cpp"
|
||||
|
||||
includes = [inc.header for sp in selected for inc in sp.manifest.includes if inc.target == "main_cpp"]
|
||||
if includes:
|
||||
anchor = '#include "CrossPointSettings.h"'
|
||||
contains_once(content, anchor, label)
|
||||
content = content.replace(anchor, anchor + "".join(f'\n#include "{h}"' for h in includes), 1)
|
||||
|
||||
early_hooks = [h.code for sp in selected for h in sp.manifest.main_hooks if h.point == "early_boot"]
|
||||
if early_hooks:
|
||||
anchor = " RECENT_BOOKS.loadFromFile();\n I18N.setLanguage(static_cast<Language>(SETTINGS.language));"
|
||||
contains_once(content, anchor, label)
|
||||
block = " RECENT_BOOKS.loadFromFile();\n" + "\n".join(early_hooks) + \
|
||||
"\n I18N.setLanguage(static_cast<Language>(SETTINGS.language));"
|
||||
content = content.replace(anchor, block, 1)
|
||||
|
||||
display_hooks = [h.code for sp in selected for h in sp.manifest.main_hooks if h.point == "post_display_setup"]
|
||||
if display_hooks:
|
||||
anchor = " setupDisplayAndFonts(resume != BootResume::Splash);\n\n switch (resume) {"
|
||||
contains_once(content, anchor, label)
|
||||
block = " setupDisplayAndFonts(resume != BootResume::Splash);\n\n" + \
|
||||
"\n".join(display_hooks) + "\n\n switch (resume) {"
|
||||
content = content.replace(anchor, block, 1)
|
||||
|
||||
post_hooks = [h.code for sp in selected for h in sp.manifest.main_hooks if h.point == "post_boot"]
|
||||
if post_hooks:
|
||||
anchor = " // Ensure we're not still holding the power button before leaving setup\n waitForPowerRelease();"
|
||||
contains_once(content, anchor, label)
|
||||
block = "\n".join(post_hooks) + "\n\n" + \
|
||||
" // Ensure we're not still holding the power button before leaving setup\n waitForPowerRelease();"
|
||||
content = content.replace(anchor, block, 1)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(includes)} include(s), {len(early_hooks)} early hook(s), "
|
||||
f"{len(display_hooks)} post-display hook(s), {len(post_hooks)} post hook(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reader dark-mode-style invert hook (Epub/Txt/Xtc)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_reader_invert_hooks(repo_dir, selected: List[SelectedPlugin]):
|
||||
hooks = [sp.manifest.reader_invert_hook for sp in selected if sp.manifest.reader_invert_hook]
|
||||
if not hooks:
|
||||
return
|
||||
if len(hooks) > 1:
|
||||
raise PatchError("More than one plugin declared a reader_invert_hook - only one is supported at a time.")
|
||||
hook = hooks[0]
|
||||
predicate = hook.predicate_expr
|
||||
|
||||
# --- EpubReaderActivity.cpp ---
|
||||
path = find_first("EpubReaderActivity.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "EpubReaderActivity.cpp"
|
||||
|
||||
inc_anchor = '#include "EpubReaderActivity.h"'
|
||||
contains_once(content, inc_anchor, label)
|
||||
content = content.replace(inc_anchor, inc_anchor + f'\n#include "{hook.include_header}"', 1)
|
||||
|
||||
main_anchor = (
|
||||
' page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);\n'
|
||||
' renderStatusBar();\n'
|
||||
' const auto tBwRender = millis();'
|
||||
)
|
||||
main_replacement = (
|
||||
' page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);\n'
|
||||
' renderStatusBar();\n'
|
||||
f' if ({predicate}) renderer.invertScreen();\n'
|
||||
' const auto tBwRender = millis();'
|
||||
)
|
||||
content = replace_once(content, main_anchor, main_replacement, label)
|
||||
|
||||
image_anchor = (
|
||||
' int16_t imgX, imgY, imgW, imgH;\n'
|
||||
' if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {\n'
|
||||
' renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false);\n'
|
||||
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
|
||||
'\n'
|
||||
' // Re-render page content to restore images into the blanked area\n'
|
||||
' // Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)\n'
|
||||
' page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);\n'
|
||||
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
|
||||
' } else {\n'
|
||||
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
|
||||
' }'
|
||||
)
|
||||
image_replacement = (
|
||||
' int16_t imgX, imgY, imgW, imgH;\n'
|
||||
' if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {\n'
|
||||
f' const bool pluginDarkMode = ({predicate});\n'
|
||||
' renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, pluginDarkMode);\n'
|
||||
' if (pluginDarkMode) renderer.invertScreen();\n'
|
||||
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
|
||||
'\n'
|
||||
' // Re-render page content to restore images into the blanked area\n'
|
||||
' // Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)\n'
|
||||
' page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);\n'
|
||||
' if (pluginDarkMode) renderer.invertScreen();\n'
|
||||
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
|
||||
' } else {\n'
|
||||
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
|
||||
' }'
|
||||
)
|
||||
content = replace_once(content, image_anchor, image_replacement, label)
|
||||
write_file(path, content)
|
||||
print(f" {label} patched (invert hook).")
|
||||
|
||||
# --- TxtReaderActivity.cpp ---
|
||||
path = find_first("TxtReaderActivity.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "TxtReaderActivity.cpp"
|
||||
|
||||
inc_anchor = '#include "TxtReaderActivity.h"'
|
||||
contains_once(content, inc_anchor, label)
|
||||
content = content.replace(inc_anchor, inc_anchor + f'\n#include "{hook.include_header}"', 1)
|
||||
|
||||
txt_anchor = (
|
||||
' // BW rendering\n'
|
||||
' renderLines();\n'
|
||||
' renderStatusBar();\n'
|
||||
'\n'
|
||||
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);'
|
||||
)
|
||||
txt_replacement = (
|
||||
' // BW rendering\n'
|
||||
' renderLines();\n'
|
||||
' renderStatusBar();\n'
|
||||
f' if ({predicate}) renderer.invertScreen();\n'
|
||||
'\n'
|
||||
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);'
|
||||
)
|
||||
content = replace_once(content, txt_anchor, txt_replacement, label)
|
||||
write_file(path, content)
|
||||
print(f" {label} patched (invert hook).")
|
||||
|
||||
# --- XtcReaderActivity.cpp (1-bit path only; see README note on the
|
||||
# 2-bit grayscale pipeline) ---
|
||||
path = find_first("XtcReaderActivity.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "XtcReaderActivity.cpp"
|
||||
|
||||
inc_anchor = '#include "XtcReaderActivity.h"'
|
||||
contains_once(content, inc_anchor, label)
|
||||
content = content.replace(inc_anchor, inc_anchor + f'\n#include "{hook.include_header}"', 1)
|
||||
|
||||
xtc_anchor = (
|
||||
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);\n'
|
||||
'\n'
|
||||
' LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth);'
|
||||
)
|
||||
xtc_replacement = (
|
||||
f' if ({predicate}) renderer.invertScreen();\n'
|
||||
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);\n'
|
||||
'\n'
|
||||
' LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth);'
|
||||
)
|
||||
content = replace_once(content, xtc_anchor, xtc_replacement, label)
|
||||
write_file(path, content)
|
||||
print(f" {label} patched (invert hook, 1-bit path only).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# platformio.ini / translation files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_platformio_ini(repo_dir, selected: List[SelectedPlugin]):
|
||||
flags = []
|
||||
for sp in selected:
|
||||
for f in sp.manifest.platformio_flags:
|
||||
if f.line not in flags:
|
||||
flags.append(f.line)
|
||||
if not flags:
|
||||
return
|
||||
|
||||
path = os.path.join(repo_dir, "platformio.ini")
|
||||
if not os.path.exists(path):
|
||||
raise PatchError("platformio.ini not found")
|
||||
content = read_file(path)
|
||||
if all(f in content for f in flags):
|
||||
return
|
||||
|
||||
anchor = "[env:default]\nextends = base\nbuild_flags =\n"
|
||||
if anchor not in content:
|
||||
raise PatchError("platformio.ini: could not find [env:default] build_flags anchor")
|
||||
addition = "".join(f" {f}\n" for f in flags if f not in content)
|
||||
content = content.replace(anchor, anchor + addition, 1)
|
||||
write_file(path, content)
|
||||
print(f" platformio.ini patched ({len(flags)} flag(s)).")
|
||||
|
||||
|
||||
def patch_translation_files(repo_dir, selected: List[SelectedPlugin]):
|
||||
entries = [e for sp in selected for e in sp.manifest.translation_entries]
|
||||
if not entries:
|
||||
return
|
||||
|
||||
yaml_dir = os.path.join(repo_dir, "lib", "I18n", "translations")
|
||||
if not os.path.isdir(yaml_dir):
|
||||
raise PatchError(f"translation directory not found: {yaml_dir}")
|
||||
|
||||
yaml_files = glob.glob(os.path.join(yaml_dir, "*.yaml"))
|
||||
if not yaml_files:
|
||||
raise PatchError("no translation YAML files found")
|
||||
|
||||
patched = 0
|
||||
for yf in yaml_files:
|
||||
content = read_file(yf)
|
||||
lines = content.splitlines(keepends=True)
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
new_lines.append(line)
|
||||
for e in entries:
|
||||
if line.startswith(e.after_key + ":") and f'{e.key}:' not in content:
|
||||
new_lines.append(f'{e.key}: "{e.value}"\n')
|
||||
write_file(yf, "".join(new_lines))
|
||||
patched += 1
|
||||
print(f" Translation files patched ({patched} file(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply(ctx: Context, selected: List[SelectedPlugin]):
|
||||
"""Apply every selected plugin's contributions to repo_dir in one
|
||||
consistent pass. `selected` should already be in a stable, deterministic
|
||||
order (install.py sorts by plugin name)."""
|
||||
repo_dir = ctx.repo_dir
|
||||
|
||||
validate_manifests(selected)
|
||||
|
||||
ordered_pre = sorted(selected, key=lambda sp: (sp.manifest.phase, sp.name))
|
||||
for sp in ordered_pre:
|
||||
if sp.manifest.pre_patch:
|
||||
print(f" [{sp.name}] running pre-patch step...")
|
||||
sp.manifest.pre_patch(ctx)
|
||||
|
||||
print(" Copying plugin source files...")
|
||||
copy_source_files(repo_dir, selected)
|
||||
|
||||
print(" Patching CrossPointSettings.h...")
|
||||
patch_cross_point_settings_h(repo_dir, selected)
|
||||
|
||||
print(" Patching SettingsList.h...")
|
||||
patch_settings_list_h(repo_dir, selected)
|
||||
|
||||
print(" Patching SettingsActivity.h...")
|
||||
patch_settings_activity_h(repo_dir, selected)
|
||||
|
||||
print(" Patching SettingsActivity.cpp...")
|
||||
patch_settings_activity_cpp(repo_dir, selected)
|
||||
|
||||
print(" Patching CrossPointWebServer.cpp...")
|
||||
patch_web_server(repo_dir, selected)
|
||||
|
||||
print(" Patching main.cpp...")
|
||||
patch_main_cpp(repo_dir, selected)
|
||||
|
||||
print(" Patching reader activities (invert hook)...")
|
||||
patch_reader_invert_hooks(repo_dir, selected)
|
||||
|
||||
print(" Patching platformio.ini...")
|
||||
patch_platformio_ini(repo_dir, selected)
|
||||
|
||||
print(" Patching translation files...")
|
||||
patch_translation_files(repo_dir, selected)
|
||||
|
||||
ordered_post = sorted(selected, key=lambda sp: (sp.manifest.phase, sp.name))
|
||||
for sp in ordered_post:
|
||||
if sp.manifest.post_patch:
|
||||
print(f" [{sp.name}] running post-patch step...")
|
||||
sp.manifest.post_patch(ctx)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Declarative contribution model for xteink plugins.
|
||||
|
||||
The old design had every plugin's patch.py directly rewrite shared CrossPoint
|
||||
source files with hand-written string/regex surgery, and each plugin's patch
|
||||
script *also* hardcoded literal snippets belonging to other plugins (e.g.
|
||||
darkmode's patch.py contained the exact text of the smallerfonts plugin's
|
||||
settings entry, and referenced SmallerFontsPlugin/CrossPointSettings::
|
||||
smallerFontsMode directly) so that things would still line up if both were
|
||||
installed together. That makes every plugin's correctness depend on which
|
||||
*other* plugins happen to be installed and in what order their patches ran -
|
||||
which is exactly why installing a subset (or adding a new plugin) breaks
|
||||
things.
|
||||
|
||||
The fix: plugins no longer touch shared files at all. They describe *what*
|
||||
they need in a shared UI/struct/behaviour via small declarative objects
|
||||
below. A single engine (framework/engine.py) collects the contributions of
|
||||
whichever plugins were actually selected, and applies each shared file
|
||||
exactly once, deriving the whole "Plugins" tab / settings struct / web UI
|
||||
from the current selection instead of guessing at a partially-patched file's
|
||||
history.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceFile:
|
||||
"""A plugin-owned .h/.cpp file to copy into the firmware tree."""
|
||||
src_name: str
|
||||
dest_subdir: str # relative to repo_dir, e.g. "src/activities/settings"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Include:
|
||||
"""An extra #include line needed in one of the shared files."""
|
||||
header: str
|
||||
target: str # "cross_point_settings_h" | "settings_activity_h" | "settings_activity_cpp" | "main_cpp"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsField:
|
||||
"""A member to add to the CrossPointSettings struct (persisted state)."""
|
||||
decl: str # e.g. "uint8_t darkModeState = 0;"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsListEntry:
|
||||
"""A literal SettingInfo::... initializer added to the master settings list
|
||||
(src/SettingsList.h). Used by save/load and by the web UI's full settings
|
||||
dump. Do NOT give these a StrId category other than STR_NONE_OPT - visible
|
||||
Plugins-tab rows are declared via PluginsTabEntry below instead, so they
|
||||
aren't double-counted."""
|
||||
cpp: str # trailing comma, no trailing newline needed
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingActionEnumValue:
|
||||
"""Requests a new enum class SettingAction member."""
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginsTabEntry:
|
||||
"""One row rendered in the on-device 'Plugins' settings tab (and mirrored
|
||||
into the web UI). kind is one of:
|
||||
- "enum": cycles setting.key through option_labels; value_text_expr
|
||||
(a C++ expression using `value`) renders the current value.
|
||||
- "toggle": on/off backed by a uint8_t field.
|
||||
- "string": free text field (e.g. an API token), usually obfuscated.
|
||||
- "action": launches an activity; doesn't read/write a settings field.
|
||||
"""
|
||||
label: str
|
||||
kind: str
|
||||
key: Optional[str] = None
|
||||
option_labels: Optional[List[str]] = None # web UI + on-device generic fallback text
|
||||
value_text_expr: Optional[str] = None # e.g. 'DarkModePlugin::stateName(static_cast<DarkModeState>(value))'
|
||||
obfuscated: bool = False
|
||||
hidden_from_web: bool = False
|
||||
show_on_device: bool = True # False = web/API-only (e.g. an API token field)
|
||||
action_name: Optional[str] = None # must match a SettingActionEnumValue.name
|
||||
action_value_text: str = "Launch"
|
||||
activity_launch_expr: Optional[str] = None # e.g. 'std::make_unique<PongActivity>(renderer, mappedInput)'
|
||||
activity_needs_rebuild: bool = False # result handler should call rebuildSettingsLists()
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnumValueOverride:
|
||||
"""Overrides the on-device valueText for one specific value of an
|
||||
EXISTING built-in enum setting (as opposed to PluginsTabEntry, which
|
||||
describes a brand new plugin-owned setting). Used by e.g. bookerly to
|
||||
make the built-in `fontFamily` setting show "Bookerly" for its new
|
||||
enum value, while every other value keeps rendering normally."""
|
||||
key: str
|
||||
condition_expr: str # e.g. "value == CrossPointSettings::BOOKERLY"
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebOptionAppend:
|
||||
"""Appends one extra dropdown option string to an EXISTING built-in
|
||||
enum setting's web-UI options array (paired with EnumValueOverride
|
||||
above for the on-device side)."""
|
||||
key: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToggleHook:
|
||||
"""Custom logic that runs inside SettingsActivity::toggleCurrentSetting()
|
||||
right after a matching setting's value has been changed, before the
|
||||
generic save+rebuild tail. Used for things like lockscreen's "just
|
||||
enabled, no PIN set yet -> launch PIN-creation activity instead of a
|
||||
plain save" behaviour. `code` may end with `return;` to skip the
|
||||
generic tail entirely for that keypress."""
|
||||
key: str
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MainHook:
|
||||
"""A code block inserted into main.cpp's setup(). Multiple plugins'
|
||||
hooks at the same point are concatenated in manifest order - none of
|
||||
them need to know about each other."""
|
||||
point: str # "early_boot" (right after RECENT_BOOKS load, before routing -
|
||||
# display/renderer/fonts are NOT ready yet at this point, so
|
||||
# nothing that renders to screen can run here)
|
||||
# or "post_display_setup" (right after setupDisplayAndFonts(),
|
||||
# before the boot-resume routing/splash paints anything -
|
||||
# display+fonts ARE ready; use this for anything that needs
|
||||
# to draw a screen, e.g. a lock gate, before Home/Reader
|
||||
# becomes visible)
|
||||
# or "post_boot" (once, after the boot-resume routing decision, every boot)
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformioFlag:
|
||||
line: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranslationEntry:
|
||||
after_key: str # insert immediately after this existing YAML key, in every translation file
|
||||
key: str
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReaderInvertHook:
|
||||
"""Generalized version of darkmode's screen-invert behaviour: a runtime
|
||||
predicate that, when true, XORs the framebuffer right after each reader
|
||||
page paints (Epub/Txt/Xtc)."""
|
||||
predicate_expr: str
|
||||
include_header: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginManifest:
|
||||
name: str
|
||||
pretty_name: str
|
||||
source_files: List[SourceFile] = field(default_factory=list)
|
||||
includes: List[Include] = field(default_factory=list)
|
||||
settings_fields: List[SettingsField] = field(default_factory=list)
|
||||
settings_list_entries: List[SettingsListEntry] = field(default_factory=list)
|
||||
setting_actions: List[SettingActionEnumValue] = field(default_factory=list)
|
||||
plugins_tab_entries: List[PluginsTabEntry] = field(default_factory=list)
|
||||
enum_value_overrides: List[EnumValueOverride] = field(default_factory=list)
|
||||
web_option_appends: List[WebOptionAppend] = field(default_factory=list)
|
||||
toggle_hooks: List[ToggleHook] = field(default_factory=list)
|
||||
main_hooks: List[MainHook] = field(default_factory=list)
|
||||
platformio_flags: List[PlatformioFlag] = field(default_factory=list)
|
||||
translation_entries: List[TranslationEntry] = field(default_factory=list)
|
||||
reader_invert_hook: Optional[ReaderInvertHook] = None
|
||||
|
||||
# Escape hatches for genuinely plugin-specific work that doesn't touch
|
||||
# shared files (font generation, credential prompts, copying non-source
|
||||
# assets). ctx is a framework.engine.Context.
|
||||
pre_patch: Optional[Callable] = None
|
||||
post_patch: Optional[Callable] = None
|
||||
|
||||
# Ordering hint only (lower runs earlier among pre_patch/post_patch
|
||||
# callbacks - e.g. bookerly's font generation must run before
|
||||
# smallerfonts' generic font-resolution wrapping). Shared-file structural
|
||||
# patches themselves are NOT order-dependent between plugins.
|
||||
phase: int = 0
|
||||
Reference in new issue
Block a user