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
+42 -26
View File
@@ -10,6 +10,11 @@
A plugin system for customizing and extending https://github.com/crosspoint-reader/crosspoint-reader firmware on your xteink device. Plugins are applied as source-level patches before the firmware is compiled and flashed. A plugin system for customizing and extending https://github.com/crosspoint-reader/crosspoint-reader firmware on your xteink device. Plugins are applied as source-level patches before the firmware is compiled and flashed.
> **Note on this revision:** the plugin installer was rewritten from independent per-plugin `patch.py` scripts (which broke when installed as anything other than "all of them, in this exact order") to a shared declarative framework - see [Why plugin.py instead of patch.py](#why-pluginpy-instead-of-patchpy). Every plugin now installs correctly alone or in any combination. A few honest notes on functional gaps found and decisions made along the way:
> - **Smaller Fonts** actually only implements two states (Disabled/Enabled - one size step down) even though the UI text below describes three; the underlying code never distinguished a "Smallest" tier or handled OpenDyslexic. This rewrite matches what the code actually does rather than inventing the missing tier.
> - **Bookerly** generates 12/14/16/18pt only; the 8pt/10pt claim below wasn't actually wired up in the original code either. Bookerly also won't appear in the font picker on devices that also have SD-card custom fonts installed (a narrow pre-existing edge case).
> - **GitHub Sync**'s original boot hook only ran inside one rare boot path (quick-resume with no cached frame), so it silently never synced on most ordinary boots - it's now a proper once-per-boot hook. Its install-time NVS credential pre-seeding was dropped for simplicity; configure it via Settings → Plugins → GitHub Sync after flashing instead.
## Plugins ## Plugins
### Dark Mode ### Dark Mode
@@ -30,10 +35,9 @@ Adds a Smaller Fonts option to the Plugins settings tab and the web interface Se
Mode | Effect Mode | Effect
-----|-------- -----|--------
Disabled | No change (default) Disabled | No change (default)
Smaller | Drops the current font size down by one step (e.g. Bookerly 16 → 14) Enabled | Drops the current font size down by one step (e.g. Bookerly 16 → 14)
Smallest | Drops the current font size down by two steps (e.g. Bookerly 16 → 12)
Supports Bookerly, Noto Sans, and OpenDyslexic. The plugin also generates and embeds Bookerly at 8pt and 10pt — sizes not included in the stock firmware. Supports Noto Serif, Noto Sans, and Bookerly.
--- ---
@@ -57,10 +61,8 @@ Features:
Adds the Bookerly font to your xteink device, available as a reader font option alongside the built-in Noto Serif, Noto Sans, and OpenDyslexic fonts. Adds the Bookerly font to your xteink device, available as a reader font option alongside the built-in Noto Serif, Noto Sans, and OpenDyslexic fonts.
- Generates Bookerly at 12pt, 14pt, 16pt, and 18pt during install - Generates Bookerly at 12pt, 14pt, 16pt, and 18pt during install
- Also generates 8pt and 10pt variants for use with the Smaller Fonts plugin
- Selectable via Settings → Reader → Font - Selectable via Settings → Reader → Font
- Works with the Smaller Fonts plugin to drop one size step down (e.g. 16 → 14)
Requires the Smaller Fonts plugin to be installed if you want to use Bookerly at sizes below 12pt.
--- ---
@@ -89,8 +91,7 @@ Syncs `.epub` files from a private GitHub repository to your device on boot.
- Downloads new or updated books from your configured repo automatically on startup - Downloads new or updated books from your configured repo automatically on startup
- Skips files already on the device that haven't changed - Skips files already on the device that haven't changed
- Configurable via Settings → Plugins → GitHub Sync (username, personal access token, repo, branch) - Configurable via Settings → Plugins → GitHub Sync (username, personal access token, repo, branch), after flashing
- Credentials can also be pre-configured during install
Requirements: Requirements:
- Active internet connection via WiFi - Active internet connection via WiFi
@@ -148,34 +149,47 @@ Note: This script modifies and flashes custom firmware to your device. The autho
``` ```
xteink-plugins/ xteink-plugins/
├── install.py # Interactive installer: clone → patch → build → flash ├── install.py # Interactive installer: clone → select → apply → build → flash
├── framework/ # Shared plugin engine (see "Adding a Plugin" below)
│ ├── manifest.py # Declarative contribution types plugins are built from
│ ├── engine.py # Applies every selected plugin's contributions in one pass
│ └── discovery.py # Finds plugin.py files under plugins/
├── test_harness.py # Applies every plugin, alone and in combination, against a
│ # fresh clone and reports pass/fail - run this after adding
│ # or editing a plugin
└── plugins/ └── plugins/
├── darkmode/ ├── darkmode/
│ ├── patch.py │ ├── plugin.py
│ ├── DarkModePlugin.h/.cpp │ └── DarkModePlugin.h/.cpp
│ └── DarkModeSettingsPage.h/.cpp
├── smallerfonts/ ├── smallerfonts/
│ ├── patch.py │ ├── plugin.py
│ ├── SmallerFontsPlugin.h/.cpp │ └── SmallerFontsPlugin.h/.cpp
│ └── SmallerFontsSettingsPage.h/.cpp
├── lockscreen/ ├── lockscreen/
│ ├── patch.py │ ├── plugin.py
│ ├── LockscreenPlugin.h/.cpp │ ├── LockscreenPlugin.h/.cpp
│ ├── LockscreenSettingsPage.h/.cpp
│ └── LockscreenActivity.h/.cpp │ └── LockscreenActivity.h/.cpp
├── bookerly/ ├── bookerly/
│ ├── patch.py │ ├── plugin.py
│ └── BookerlyPlugin.h/.cpp │ └── BookerlyPlugin.h/.cpp
├── hardcover/ ├── hardcover/
│ ├── patch.py │ ├── plugin.py
│ ├── HardcoverPlugin.h/.cpp │ ├── HardcoverPlugin.h/.cpp
│ └── HardcoverSyncActivity.h/.cpp │ └── HardcoverSyncActivity.h/.cpp
└── githubsync/ ├── githubsync/
├── patch.py │ ├── plugin.py
├── GitHubSync.h/.cpp │ ├── GitHubSync.h/.cpp
└── GitHubSyncSettingsActivity.h/.cpp │ └── GitHubSyncSettingsActivity.h/.cpp
└── pong/
├── plugin.py
└── PongActivity.h/.cpp
``` ```
### Why plugin.py instead of patch.py
Previously every plugin's `patch.py` directly rewrote CrossPoint's shared source files with hand-written string edits, and each one had to hardcode knowledge of every *other* plugin's edits to land in the right place (e.g. darkmode's patch literally contained smallerfonts' settings entry). That's fine as long as you always install every plugin together in the same order, but install any subset, or add a new plugin later, and those hardcoded assumptions stop holding - settings referenced fields that were never declared, and the build failed.
`plugin.py` instead returns a declarative `PluginManifest` (see `framework/manifest.py`) describing *what* the plugin needs - a settings field, a row in the on-device "Plugins" tab, a new `SettingAction`, a boot-time hook, and so on. A single engine (`framework/engine.py`) collects the manifests of only the plugins you actually selected and writes each shared file exactly once, built fresh from that selection. Installing 1 plugin or all 7, in any combination, produces internally consistent output every time.
## Troubleshooting ## Troubleshooting
### Linux: Permission denied when flashing ### Linux: Permission denied when flashing
@@ -210,7 +224,9 @@ Restart your terminal and re-run install.py.
## Adding a Plugin ## Adding a Plugin
1. Create a new directory under plugins/ with your plugin's name. 1. Create a new directory under `plugins/` with your plugin's name.
2. Add a patch.py file with a patch(repo_dir: str) function. 2. Add a `plugin.py` with a `get_manifest(ctx) -> PluginManifest` function. Use an existing plugin (e.g. `plugins/pong/plugin.py` for a simple action-row example, or `plugins/darkmode/plugin.py` for a settings-backed one) as a template. `framework/manifest.py` documents every contribution type: `SettingsField`, `PluginsTabEntry`, `SettingActionEnumValue`, `MainHook`, `ToggleHook`, `TranslationEntry`, and so on.
3. List any plugin-owned `.h`/`.cpp` files under `source_files` so the installer copies them into the firmware tree - you don't touch any shared CrossPoint file directly.
4. Run `python3 test_harness.py <path-to-a-crosspoint-reader-clone> /tmp/scratch <your-plugin-name>` (or `all` to check every plugin and combination) to confirm it applies cleanly alone and alongside everything else, before shipping it.
The installer will automatically discover and offer to install any directory under plugins/ that contains a patch.py. The installer automatically discovers and offers to install any directory under `plugins/` that contains a `plugin.py` exposing `get_manifest`.
View File
+38
View File
@@ -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
+858
View File
@@ -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)
+187
View File
@@ -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
+183 -69
View File
@@ -5,11 +5,13 @@ import sys
import shutil import shutil
import subprocess import subprocess
import glob import glob
import importlib.util
import argparse import argparse
import inspect
import datetime 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_URL = "https://github.com/crosspoint-reader/crosspoint-reader.git"
REPO_DIR = "crosspoint-reader" REPO_DIR = "crosspoint-reader"
PLUGINS_DIR = "plugins" PLUGINS_DIR = "plugins"
@@ -64,10 +66,96 @@ def get_local_head_sha():
return None return None
def backup_device(): def find_backup_files():
print(" Backup your current firmware before flashing? [y/N]: ", end="", flush=True) return sorted(glob.glob("backup.*.bin"), reverse=True)
answer = input().strip().lower()
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"): 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.") print(" Skipping backup.")
return return
@@ -75,11 +163,20 @@ def backup_device():
print(" Ensure your xteink device is connected via USB and awake.") print(" Ensure your xteink device is connected via USB and awake.")
input(" Press Enter when ready... ") input(" Press Enter when ready... ")
ports = detect_serial_ports() while True:
ports = detect_serial_ports()
if not ports: if ports:
print(" No USB serial ports detected — skipping backup.") break
return 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.")] 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] recommended = cu_ports[0][0] if cu_ports else ports[0][0]
@@ -99,23 +196,43 @@ def backup_device():
print(" Invalid choice — skipping backup.") print(" Invalid choice — skipping backup.")
return return
timestamp = datetime.datetime.now().strftime("%Y%m%d.%H%M%S") while True:
filename = f"backup.{timestamp}.bin" timestamp = datetime.datetime.now().strftime("%Y%m%d.%H%M%S")
print(f"\n Reading flash from {port} → {filename} ...") filename = f"backup.{timestamp}.bin"
print(f"\n Reading flash from {port} → {filename} ...")
result = subprocess.run( result = subprocess.run(
f"esptool --port {port} --baud 921600 read-flash 0x0 ALL {filename}", f"esptool --port {port} --baud 921600 read-flash 0x0 ALL {filename}",
shell=True, shell=True,
) )
if result.returncode == 0: if result.returncode == 0:
print(f" ✓ Backup saved to {filename}") print(f" ✓ Backup saved to {filename}")
print() print()
print(f" To restore your device to this backup, run:") print(f" To restore your device to this backup, run:")
print(f" esptool --port {port} --baud 921600 write-flash 0x0 {filename}") print(f" esptool --port {port} --baud 921600 write-flash 0x0 {filename}")
else: break
print(" ✗ Backup failed. You can continue or abort and retry manually.")
if input(" Continue without backup? [y/N]: ").strip().lower() not in ("y", "yes"): # 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.") print(" Aborted.")
sys.exit(0) sys.exit(0)
print() print()
@@ -145,28 +262,6 @@ def clone_repo(force: bool = True):
run(f"git clone --recursive {REPO_URL} {REPO_DIR}") 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(): def clear_plugin_caches():
for plugin_name in os.listdir(PLUGINS_DIR): for plugin_name in os.listdir(PLUGINS_DIR):
cache_dir = os.path.join(PLUGINS_DIR, plugin_name, "__pycache__") cache_dir = os.path.join(PLUGINS_DIR, plugin_name, "__pycache__")
@@ -174,25 +269,20 @@ def clear_plugin_caches():
rmtree(cache_dir) rmtree(cache_dir)
def apply_plugins(yes_all: bool = False): def select_plugins(yes_all: bool = False):
print("[2/3] Applying plugins...") """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): if not os.path.exists(PLUGINS_DIR):
print(f" No '{PLUGINS_DIR}' directory found — skipping plugin step.") print(f" No '{PLUGINS_DIR}' directory found — skipping plugin step.")
return return []
plugin_dirs = sorted( available = discover_all(PLUGINS_DIR)
d for d in os.listdir(PLUGINS_DIR) if not available:
if os.path.isdir(os.path.join(PLUGINS_DIR, d)) print(" No plugins with a plugin.py manifest found.")
) return []
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)
chosen = []
for plugin_name in sorted(available):
print(f"\n Plugin: {plugin_name}") print(f"\n Plugin: {plugin_name}")
if yes_all: if yes_all:
answer = "y" answer = "y"
@@ -201,15 +291,37 @@ def apply_plugins(yes_all: bool = False):
answer = input(f" Install '{plugin_name}'? [Y/n]: ").strip().lower() answer = input(f" Install '{plugin_name}'? [Y/n]: ").strip().lower()
if answer in ("", "y", "yes"): if answer in ("", "y", "yes"):
print(f" Installing {plugin_name}...") chosen.append(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.")
else: else:
print(f" Skipping {plugin_name}.") 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(): def list_likely_serial_ports():
patterns = [ patterns = [
@@ -371,10 +483,12 @@ def main():
print(" Aborted.") print(" Aborted.")
sys.exit(0) sys.exit(0)
print() print()
restore_from_backup()
backup_device() backup_device()
clone_repo(force=not args.no_reclone) clone_repo(force=not args.no_reclone)
clear_plugin_caches() 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) build_and_flash(args.environment)
print("\nDone.") print("\nDone.")
-425
View File
@@ -1,425 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
import hashlib
import subprocess
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:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def font_id_from_files(paths):
total = 0
for p in paths:
h = hashlib.sha256(open(p, "rb").read()).hexdigest()
total += int(h, 16)
return total % (2 ** 32) - (2 ** 31)
def generate_fonts(plugin_dir, repo_dir):
scripts_dir = os.path.join(repo_dir, "lib", "EpdFont", "scripts")
fonts_dir = os.path.join(repo_dir, "lib", "EpdFont", "builtinFonts")
source_dir = os.path.join(plugin_dir, "fonts")
fontconvert = os.path.join(scripts_dir, "fontconvert.py")
subprocess.run(
[sys.executable, "-m", "pip", "install", "freetype-py==2.5.1", "fonttools", "--break-system-packages", "-q"],
check=True
)
sizes = [12, 14, 16, 18]
styles = ["Regular"]
for size in sizes:
for style in styles:
fname = f"bookerly_{size}_{style.lower()}"
out_path = os.path.join(fonts_dir, f"{fname}.h")
if os.path.exists(out_path):
print(f" already exists: {fname}.h")
continue
src_path = os.path.join(source_dir, f"Bookerly-{style}.ttf")
if not os.path.exists(src_path):
sys.exit(f"ERROR: Missing font file: {src_path}")
with open(out_path, "w") as f:
result = subprocess.run(
[sys.executable, fontconvert, fname, str(size), src_path, "--2bit", "--compress"],
capture_output=True, text=True
)
if result.returncode != 0 or not result.stdout.strip():
print(f" ERROR generating {fname}: {result.stderr[:200]}")
if os.path.exists(out_path):
os.remove(out_path)
continue
f.write(result.stdout)
print(f" ✓ generated {fname}.h")
return fonts_dir
def compute_font_ids(fonts_dir):
ids = {}
sizes = [12, 14, 16, 18]
for size in sizes:
path = os.path.join(fonts_dir, f"bookerly_{size}_regular.h")
if os.path.exists(path):
h = hashlib.sha256(open(path, "rb").read()).hexdigest()
ids[f"BOOKERLY_{size}_FONT_ID"] = int(h, 16) % (2 ** 32) - (2 ** 31)
return ids
def patch_font_ids_h(repo_dir, new_ids):
path = find_first("fontIds.h", repo_dir)
content = read_file(path)
lines = []
for name, val in new_ids.items():
if name not in content:
lines.append(f"#define {name} ({val})")
if not lines:
print(" fontIds.h already patched, skipping.")
return
write_file(path, content.rstrip() + "\n" + "\n".join(lines) + "\n")
print(f" fontIds.h: added {len(lines)} new font IDs.")
def patch_all_h(repo_dir, new_ids):
path = find_first("all.h", repo_dir)
content = read_file(path)
added = 0
sizes = [12, 14, 16, 18]
for size in sizes:
key = f"BOOKERLY_{size}_FONT_ID"
if key not in new_ids:
continue
fname = f"bookerly_{size}_regular"
inc = f'#include <builtinFonts/{fname}.h>'
if inc not in content:
content += inc + "\n"
added += 1
if added:
write_file(path, content)
print(f" all.h: added {added} includes.")
else:
print(" all.h already patched, skipping.")
def patch_main_cpp(repo_dir, new_ids):
path = find_first("main.cpp", repo_dir)
content = read_file(path)
sizes = [8, 10, 12, 14, 16, 18]
decl_lines = []
insert_lines = []
for size in sizes:
key = f"BOOKERLY_{size}_FONT_ID"
varbase = f"bookerly{size}"
if key not in new_ids:
continue
if f"EpdFontFamily {varbase}FontFamily" in content:
continue
decl_lines += [
f'EpdFont {varbase}RegularFont(&bookerly_{size}_regular);',
f'EpdFontFamily {varbase}FontFamily(&{varbase}RegularFont, &{varbase}RegularFont, &{varbase}RegularFont, &{varbase}RegularFont);',
]
insert_lines.append(f' renderer.insertFont({key}, {varbase}FontFamily);')
if not decl_lines:
print(" main.cpp already patched, skipping.")
return
anchor_decl = '#ifndef OMIT_FONTS'
content = content.replace(anchor_decl, "\n".join(decl_lines) + "\n" + anchor_decl, 1)
anchor_insert = ' renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);'
content = content.replace(anchor_insert, "\n".join(insert_lines) + "\n" + anchor_insert, 1)
write_file(path, content)
print(f" main.cpp: added {len(decl_lines) // 2} font families.")
def patch_cross_point_settings_h(repo_dir):
path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path)
if "BOOKERLY = 3" in content:
print(" CrossPointSettings.h already patched, skipping.")
return
if '"activities/settings/BookerlyPlugin.h"' not in content:
content = content.replace(
'#include "activities/settings/SmallerFontsPlugin.h"',
'#include "activities/settings/SmallerFontsPlugin.h"\n#include "activities/settings/BookerlyPlugin.h"'
)
if 'BOOKERLY = 3' not in content:
content = content.replace(
'enum FONT_FAMILY { NOTOSERIF = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, FONT_FAMILY_COUNT };',
'enum FONT_FAMILY { NOTOSERIF = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, BOOKERLY = 3, FONT_FAMILY_COUNT };'
)
write_file(path, content)
print(" CrossPointSettings.h patched.")
def patch_cross_point_settings_cpp(repo_dir, new_ids):
path = find_first("CrossPointSettings.cpp", repo_dir)
content = read_file(path)
if "BOOKERLY" in content:
print(" CrossPointSettings.cpp already patched, skipping.")
return
content = content.replace(
' case OPENDYSLEXIC:\n switch (lineSpacing) {\n case TIGHT:\n return 0.90f;\n case NORMAL:\n default:\n return 0.95f;\n case WIDE:\n return 1.0f;\n }\n }\n}\n\nunsigned long',
' case OPENDYSLEXIC:\n switch (lineSpacing) {\n case TIGHT:\n return 0.90f;\n case NORMAL:\n default:\n return 0.95f;\n case WIDE:\n return 1.0f;\n }\n case BOOKERLY:\n switch (lineSpacing) {\n case TIGHT:\n return 0.95f;\n case NORMAL:\n default:\n return 1.0f;\n case WIDE:\n return 1.1f;\n }\n }\n}\n\nunsigned long'
)
use_resolve = 'resolve' in content
bookerly_return = 'resolve(BOOKERLY_12_FONT_ID)' if use_resolve else 'BOOKERLY_12_FONT_ID'
bookerly_case = (
' case BOOKERLY:\n'
f' return {bookerly_return};\n'
)
anchor = None
for candidate in [
' case EXTRA_LARGE:\n return resolve(OPENDYSLEXIC_14_FONT_ID);\n }\n }\n}\n',
' case EXTRA_LARGE:\n return OPENDYSLEXIC_14_FONT_ID;\n }\n }\n}\n',
]:
if candidate in content:
anchor = candidate
break
if anchor:
content = content.replace(
anchor,
' case EXTRA_LARGE:\n return ' + ('resolve(OPENDYSLEXIC_14_FONT_ID)' if 'resolve' in anchor else 'OPENDYSLEXIC_14_FONT_ID') + ';\n }\n'
+ bookerly_case +
' }\n}\n'
)
write_file(path, content)
print(" CrossPointSettings.cpp patched.")
def patch_settings_list_h(repo_dir):
path = find_first("SettingsList.h", repo_dir)
content = read_file(path)
already_patched = 'STR_NONE_OPT}, "fontFamily"' in content and 'bookerlyInstalled' in content
if already_patched:
print(" SettingsList.h already patched, skipping.")
return
if 'STR_NONE_OPT}, "fontFamily"' not in content:
content = content.replace(
'{StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, "fontFamily"',
'{StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC, StrId::STR_NONE_OPT}, "fontFamily"'
)
write_file(path, content)
print(" SettingsList.h patched.")
def patch_settings_activity_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
if 'BookerlyInstalled' in content:
print(" SettingsActivity.h already patched, skipping.")
return
if 'categoryCount = 4' in content:
content = content.replace(
'static constexpr int categoryCount = 4;',
'static constexpr int categoryCount = 5;'
)
if 'pluginsSettings' not in content:
content = content.replace(
' std::vector<SettingInfo> systemSettings;',
' std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;'
)
content = content.replace(
' PongGame,\n};',
' PongGame,\n BookerlyInstalled,\n};'
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_activity_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if '"Bookerly Font"' in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "fontIds.h"',
'#include "fontIds.h"\n#include "BookerlyPlugin.h"'
)
if 'SettingAction::BookerlyInstalled' not in content:
content = content.replace(
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n',
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n'
' pluginsSettings.push_back(SettingInfo::Action(\n'
' StrId::STR_NONE_OPT, SettingAction::BookerlyInstalled\n'
' ));\n'
)
if '"Bookerly Font"' not in content:
content = content.replace(
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,',
' [&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
' if (s.type == SettingType::ACTION && s.action == SettingAction::BookerlyInstalled) return "Bookerly Font";\n'
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' }, nullptr, nullptr,'
)
if '"Bookerly"' not in content:
content = content.replace(
' const uint8_t value = SETTINGS.*(setting.valuePtr);\n'
' valueText = I18N.get(setting.enumValues[value]);\n',
' const uint8_t value = SETTINGS.*(setting.valuePtr);\n'
' if (setting.key && std::string(setting.key) == "fontFamily" && value == CrossPointSettings::BOOKERLY) {\n'
' valueText = "Bookerly";\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }\n'
)
if '"Installed"' not in content:
content = content.replace(
' } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {\n'
' valueText = std::to_string(SETTINGS.*(setting.valuePtr));\n'
' }',
' } else if (setting.type == SettingType::ACTION && setting.action == SettingAction::BookerlyInstalled) {\n'
' valueText = "Installed";\n'
' } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {\n'
' valueText = std::to_string(SETTINGS.*(setting.valuePtr));\n'
' }'
)
write_file(path, content)
print(" SettingsActivity.cpp patched.")
def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path)
if '"Bookerly"' in content:
print(" CrossPointWebServer.cpp already patched, skipping.")
return
content = content.replace(
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' doc["name"] = "Smaller Fonts";\n'
' doc["category"] = "Plugins";\n'
' }\n'
' }\n',
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' doc["name"] = "Smaller Fonts";\n'
' doc["category"] = "Plugins";\n'
' } else if (strcmp(s.key, "fontFamily") == 0) {\n'
' JsonArray opts = doc["options"].to<JsonArray>();\n'
' opts.add("Noto Serif");\n'
' opts.add("Noto Sans");\n'
' opts.add("OpenDyslexic");\n'
' opts.add("Bookerly");\n'
' }\n'
' }\n'
)
write_file(path, content)
print(" CrossPointWebServer.cpp patched.")
def copy_plugin_sources(plugin_dir, repo_dir):
dest = os.path.join(repo_dir, "src", "activities", "settings")
os.makedirs(dest, exist_ok=True)
for fname in os.listdir(plugin_dir):
if fname.endswith((".h", ".cpp", ".hpp")):
src = os.path.join(plugin_dir, fname)
dst = os.path.join(dest, fname)
shutil.copy2(src, dst)
print(f" ✓ {fname}")
def patch(repo_dir: str):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Generating Bookerly font files...")
fonts_dir = generate_fonts(plugin_dir, repo_dir)
print(" Computing font IDs...")
new_ids = compute_font_ids(fonts_dir)
for k, v in new_ids.items():
print(f" {k} = {v}")
print(" Patching fontIds.h...")
patch_font_ids_h(repo_dir, new_ids)
print(" Patching all.h...")
patch_all_h(repo_dir, new_ids)
print(" Patching main.cpp...")
patch_main_cpp(repo_dir, new_ids)
print(" Patching CrossPointSettings.h...")
patch_cross_point_settings_h(repo_dir)
print(" Patching CrossPointSettings.cpp...")
patch_cross_point_settings_cpp(repo_dir, new_ids)
print(" Patching SettingsList.h...")
patch_settings_list_h(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_activity_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_activity_cpp(repo_dir)
print(" Patching CrossPointWebServer.cpp (web UI)...")
patch_web_server(repo_dir)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+288
View File
@@ -0,0 +1,288 @@
import sys
import os
import re
import hashlib
import subprocess
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, PluginsTabEntry, EnumValueOverride, WebOptionAppend, SettingActionEnumValue,
)
from framework.engine import find_first, read_file, write_file, PatchError, replace_once, contains_once
PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
# NOTE: this only extends the *static* built-in font-family list
# (SettingsList.h's plain SettingInfo::Enum entry). CrossPoint also has a
# second, SD-card-font-aware variant (buildFontFamilySetting()) that swaps in
# when the user has SD-card fonts installed - Bookerly won't appear there.
# That's a narrow pre-existing edge case (SD-card fonts + Bookerly at once)
# left as a known follow-up rather than something this pass fully covers.
def _generate_fonts(ctx):
fonts_dir = os.path.join(ctx.repo_dir, "lib", "EpdFont", "builtinFonts")
scripts_dir = os.path.join(ctx.repo_dir, "lib", "EpdFont", "scripts")
source_dir = os.path.join(PLUGIN_DIR, "fonts")
fontconvert = os.path.join(scripts_dir, "fontconvert.py")
subprocess.run(
[sys.executable, "-m", "pip", "install", "freetype-py==2.5.1", "fonttools", "--break-system-packages", "-q"],
check=True,
)
sizes = [12, 14, 16, 18]
for size in sizes:
fname = f"bookerly_{size}_regular"
out_path = os.path.join(fonts_dir, f"{fname}.h")
if os.path.exists(out_path):
print(f" already exists: {fname}.h")
continue
src_path = os.path.join(source_dir, "Bookerly-Regular.ttf")
if not os.path.exists(src_path):
raise PatchError(f"Missing font file: {src_path}")
result = subprocess.run(
[sys.executable, fontconvert, fname, str(size), src_path, "--2bit", "--compress"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
raise PatchError(f"Failed generating {fname}: {result.stderr[:300]}")
with open(out_path, "w") as f:
f.write(result.stdout)
print(f" ✓ generated {fname}.h")
ids = {}
for size in sizes:
path = os.path.join(fonts_dir, f"bookerly_{size}_regular.h")
h = hashlib.sha256(open(path, "rb").read()).hexdigest()
ids[f"BOOKERLY_{size}_FONT_ID"] = int(h, 16) % (2 ** 32) - (2 ** 31)
ctx.bookerly_font_ids = ids
def _patch_font_registration(ctx):
ids = ctx.bookerly_font_ids
# fontIds.h
path = find_first("fontIds.h", ctx.repo_dir)
content = read_file(path)
lines = [f"#define {name} ({val})" for name, val in ids.items() if name not in content]
if lines:
write_file(path, content.rstrip() + "\n" + "\n".join(lines) + "\n")
print(f" fontIds.h: added {len(lines)} font ID(s).")
# all.h
path = find_first("all.h", ctx.repo_dir)
content = read_file(path)
added = 0
for size in [12, 14, 16, 18]:
inc = f"#include <builtinFonts/bookerly_{size}_regular.h>"
if inc not in content:
content += inc + "\n"
added += 1
if added:
write_file(path, content)
print(f" all.h: added {added} include(s).")
# main.cpp: font family declarations + registration
path = find_first("main.cpp", ctx.repo_dir)
content = read_file(path)
decl_lines, insert_lines = [], []
for size in [12, 14, 16, 18]:
var = f"bookerly{size}"
if f"EpdFontFamily {var}FontFamily" in content:
continue
decl_lines += [
f"EpdFont {var}RegularFont(&bookerly_{size}_regular);",
f"EpdFontFamily {var}FontFamily(&{var}RegularFont, &{var}RegularFont, &{var}RegularFont, &{var}RegularFont);",
]
insert_lines.append(f" renderer.insertFont(BOOKERLY_{size}_FONT_ID, {var}FontFamily);")
if decl_lines:
# "#ifndef OMIT_FONTS" legitimately appears twice (global font
# declarations, then again inside setup() around insertFont calls) -
# we specifically want the *first* (global declaration) occurrence,
# which is what replace(..., count=1) targets.
anchor_decl = "#ifndef OMIT_FONTS"
if anchor_decl not in content:
raise PatchError("main.cpp: could not find `#ifndef OMIT_FONTS` (font declarations section)")
content = content.replace(anchor_decl, "\n".join(decl_lines) + "\n" + anchor_decl, 1)
anchor_insert = " renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);"
contains_once(content, anchor_insert, "main.cpp")
content = content.replace(anchor_insert, "\n".join(insert_lines) + "\n" + anchor_insert, 1)
write_file(path, content)
print(f" main.cpp: registered {len(decl_lines) // 2} Bookerly size(s).")
def _patch_font_family_enum(ctx):
path = find_first("CrossPointSettings.h", ctx.repo_dir)
content = read_file(path)
if "BOOKERLY" in content:
return
m = re.search(r"enum FONT_FAMILY \{(.*?)FONT_FAMILY_COUNT\s*\};", content)
if not m:
raise PatchError("CrossPointSettings.h: could not find `enum FONT_FAMILY { ... FONT_FAMILY_COUNT };`")
content = content[:m.start()] + m.group(0).replace("FONT_FAMILY_COUNT", "BOOKERLY, FONT_FAMILY_COUNT", 1) + content[m.end():]
write_file(path, content)
print(" CrossPointSettings.h: added FONT_FAMILY::BOOKERLY.")
def _patch_line_spacing_and_reader_font_id(ctx):
path = find_first("CrossPointSettings.cpp", ctx.repo_dir)
content = read_file(path)
label = "CrossPointSettings.cpp"
if "case BOOKERLY:" not in content:
spacing_anchor = (
" case NOTOSANS:\n"
" switch (lineSpacing) {\n"
" case TIGHT:\n"
" return 0.90f;\n"
" case NORMAL:\n"
" default:\n"
" return 0.95f;\n"
" case WIDE:\n"
" return 1.0f;\n"
" }\n"
" }\n"
"}\n"
)
spacing_replacement = (
" case NOTOSANS:\n"
" switch (lineSpacing) {\n"
" case TIGHT:\n"
" return 0.90f;\n"
" case NORMAL:\n"
" default:\n"
" return 0.95f;\n"
" case WIDE:\n"
" return 1.0f;\n"
" }\n"
" case BOOKERLY:\n"
" switch (lineSpacing) {\n"
" case TIGHT:\n"
" return 0.95f;\n"
" case NORMAL:\n"
" default:\n"
" return 1.0f;\n"
" case WIDE:\n"
" return 1.1f;\n"
" }\n"
" }\n"
"}\n"
)
content = replace_once(content, spacing_anchor, spacing_replacement, label)
if "BOOKERLY_12_FONT_ID" not in content:
font_anchor = (
" case NOTOSANS:\n"
" switch (fontSize) {\n"
" case SMALL:\n"
" return NOTOSANS_12_FONT_ID;\n"
" case MEDIUM:\n"
" default:\n"
" return NOTOSANS_14_FONT_ID;\n"
" case LARGE:\n"
" return NOTOSANS_16_FONT_ID;\n"
" case EXTRA_LARGE:\n"
" return NOTOSANS_18_FONT_ID;\n"
" }\n"
" }\n"
"}\n"
)
font_replacement = (
" case NOTOSANS:\n"
" switch (fontSize) {\n"
" case SMALL:\n"
" return NOTOSANS_12_FONT_ID;\n"
" case MEDIUM:\n"
" default:\n"
" return NOTOSANS_14_FONT_ID;\n"
" case LARGE:\n"
" return NOTOSANS_16_FONT_ID;\n"
" case EXTRA_LARGE:\n"
" return NOTOSANS_18_FONT_ID;\n"
" }\n"
" case BOOKERLY:\n"
" switch (fontSize) {\n"
" case SMALL:\n"
" return BOOKERLY_12_FONT_ID;\n"
" case MEDIUM:\n"
" default:\n"
" return BOOKERLY_14_FONT_ID;\n"
" case LARGE:\n"
" return BOOKERLY_16_FONT_ID;\n"
" case EXTRA_LARGE:\n"
" return BOOKERLY_18_FONT_ID;\n"
" }\n"
" }\n"
"}\n"
)
content = replace_once(content, font_anchor, font_replacement, label)
write_file(path, content)
print(" CrossPointSettings.cpp: Bookerly line-spacing + font-id cases added.")
def _patch_settings_list_option(ctx):
path = find_first("SettingsList.h", ctx.repo_dir)
content = read_file(path)
label = "SettingsList.h"
anchor = (
'SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,\n'
' {StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS}, "fontFamily", StrId::STR_CAT_READER),'
)
if "StrId::STR_NONE_OPT}, \"fontFamily\"" in content:
return
replacement = (
'SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,\n'
' {StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_NONE_OPT}, "fontFamily", StrId::STR_CAT_READER),'
)
content = replace_once(content, anchor, replacement, label)
write_file(path, content)
print(" SettingsList.h: fontFamily gained a 3rd (Bookerly) option.")
def _pre_patch(ctx):
print(" Generating Bookerly font files...")
_generate_fonts(ctx)
def _post_patch(ctx):
print(" Registering Bookerly font family...")
_patch_font_registration(ctx)
_patch_font_family_enum(ctx)
_patch_line_spacing_and_reader_font_id(ctx)
_patch_settings_list_option(ctx)
def get_manifest(ctx):
return PluginManifest(
name="bookerly",
pretty_name="Bookerly Font",
phase=0, # must run before smallerfonts (phase 1) wraps getReaderFontId's returns
source_files=[
SourceFile("BookerlyPlugin.h", "src/activities/settings"),
SourceFile("BookerlyPlugin.cpp", "src/activities/settings"),
],
setting_actions=[
SettingActionEnumValue("BookerlyInstalled"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Bookerly Font",
kind="action",
action_name="BookerlyInstalled",
action_value_text="Installed",
activity_launch_expr=None, # informational row only, matches original behaviour
),
],
enum_value_overrides=[
EnumValueOverride(key="fontFamily", condition_expr="value == CrossPointSettings::BOOKERLY", text="Bookerly"),
],
web_option_appends=[
WebOptionAppend(key="fontFamily", label="Bookerly"),
],
pre_patch=_pre_patch,
post_patch=_post_patch,
)
-536
View File
@@ -1,536 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
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:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def copy_plugin_sources(plugin_dir, repo_dir):
dest = os.path.join(repo_dir, "src", "activities", "settings")
os.makedirs(dest, exist_ok=True)
skip = {"DarkModeSettingsPage.h", "DarkModeSettingsPage.cpp"}
for fname in os.listdir(plugin_dir):
if fname.endswith((".h", ".cpp", ".hpp")) and fname not in skip:
src = os.path.join(plugin_dir, fname)
dst = os.path.join(dest, fname)
shutil.copy2(src, dst)
print(f" ✓ {fname}")
def patch_cross_point_settings_h(repo_dir):
path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path)
if "darkModeState" in content:
print(" CrossPointSettings.h already patched, skipping.")
return
content = content.replace(
'#pragma once\n#include <HalStorage.h>',
'#pragma once\n#include <HalStorage.h>\n#include "activities/settings/DarkModePlugin.h"'
)
content = content.replace(
' uint8_t imageRendering = IMAGES_DISPLAY;',
' uint8_t imageRendering = IMAGES_DISPLAY;\n uint8_t darkModeState = 0;'
)
write_file(path, content)
print(" CrossPointSettings.h patched.")
def patch_settings_list_h(repo_dir):
path = find_first("SettingsList.h", repo_dir)
content = read_file(path)
if "darkModeState" in content:
print(" SettingsList.h already patched, skipping.")
return
entries = (
' SettingInfo::Enum(StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"),\n'
' SettingInfo::Enum(StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"),\n'
)
content = content.replace(
' };\n return list;\n}\n',
entries + ' };\n return list;\n}\n'
)
write_file(path, content)
print(" SettingsList.h patched.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
already_has_plugins_tab = "categoryCount = 5" in content
already_has_plugins_vec = "pluginsSettings" in content
if already_has_plugins_tab and already_has_plugins_vec:
print(" SettingsActivity.h already patched, skipping.")
return
if '"DarkModePlugin.h"' not in content:
content = content.replace(
'#include <I18n.h>',
'#include <I18n.h>\n#include "DarkModePlugin.h"'
)
if not already_has_plugins_tab:
content = content.replace(
'static constexpr int categoryCount = 4;',
'static constexpr int categoryCount = 5;'
)
if not already_has_plugins_vec:
content = content.replace(
' std::vector<SettingInfo> systemSettings;',
' std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;'
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if '"DarkModePlugin.h"' not in content:
content = content.replace(
'#include "SettingsList.h"',
'#include "SettingsList.h"\n#include "DarkModePlugin.h"'
)
if "STR_NONE_OPT" not in content:
content = content.replace(
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};',
'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};'
)
if "pluginsSettings.clear();" not in content:
content = content.replace(
' systemSettings.clear();',
' systemSettings.clear();\n pluginsSettings.clear();'
)
plugins_block = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n'
' ));\n'
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n'
' ));\n'
)
broken_block = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT,\n'
' &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n'
' "darkModeState"\n'
' ));\n'
)
if broken_block in content:
content = content.replace(broken_block, plugins_block)
elif '"darkModeState"' in content and '"smallerFontsMode"' not in content:
content = content.replace(
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n'
' ));\n',
plugins_block
)
elif '"darkModeState"' not in content:
content = content.replace(
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n',
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n'
+ plugins_block
)
if 'case 4:\n currentSettings = &pluginsSettings;' not in content:
content = content.replace(
' case 3:\n currentSettings = &systemSettings;\n break;',
' case 3:\n currentSettings = &systemSettings;\n break;\n'
' case 4:\n currentSettings = &pluginsSettings;\n break;'
)
if '"Plugins"' not in content:
content = content.replace(
' 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'
' }',
' 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'
' }'
)
if 'nextCatLabel' not in content:
content = content.replace(
' const auto confirmLabel = (selectedSettingIndex == 0)\n'
' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n'
' : tr(STR_TOGGLE);',
' const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n'
' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n'
' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);'
)
if 'selectedCategoryIndex == 4' not in content:
content = content.replace(
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); },',
' [&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
' if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n'
' if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n'
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' },'
)
else:
for s_decl in [
' const auto& s = settings[index];\n',
' const auto& s = settings[index];\n',
]:
if s_decl in content and '"darkModeState") return "Dark Mode"' not in content:
content = content.replace(
s_decl,
s_decl +
s_decl[:len(s_decl) - len(s_decl.lstrip())] +
'if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n' +
s_decl[:len(s_decl) - len(s_decl.lstrip())] +
'if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n',
1
)
break
both_present = 'DarkModePlugin::stateName' in content and 'SmallerFontsPlugin::modeName' in content
only_dark = 'DarkModePlugin::stateName' in content and 'SmallerFontsPlugin::modeName' not in content
neither = 'DarkModePlugin::stateName' not in content
if only_dark:
content = content.replace(
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }',
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }'
)
elif neither:
if 'SmallerFontsPlugin::modeName' in content:
content = content.replace(
' if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }',
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }'
)
else:
content = content.replace(
' valueText = I18N.get(setting.enumValues[value]);',
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }'
)
write_file(path, content)
print(" SettingsActivity.cpp patched.")
def patch_epub_reader(repo_dir):
path = find_first("EpubReaderActivity.cpp", repo_dir)
content = read_file(path)
image_block_patched = (
'renderer.fillRect' in content and
'isDarkMode' in content.split('renderer.fillRect')[1].split('FAST_REFRESH')[0]
)
if "isDarkMode" in content and image_block_patched:
print(" EpubReaderActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "EpubReaderActivity.h"',
'#include "EpubReaderActivity.h"\n#include "activities/settings/DarkModePlugin.h"'
)
content = content.replace(
' page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);\n'
' renderStatusBar();\n'
' fcm->logStats("bw_render");\n',
' page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);\n'
' renderStatusBar();\n'
' if (DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState))) renderer.invertScreen();\n'
' fcm->logStats("bw_render");\n'
)
old_image_block = (
' 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, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);\n'
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
' } else {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' }\n'
)
new_image_block = (
' if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {\n'
' const bool darkMode = DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState));\n'
' renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, darkMode);\n'
' if (darkMode) renderer.invertScreen();\n'
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
'\n'
' page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);\n'
' if (darkMode) renderer.invertScreen();\n'
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
' } else {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' }\n'
)
if old_image_block in content:
content = content.replace(old_image_block, new_image_block)
write_file(path, content)
print(" EpubReaderActivity.cpp patched.")
def patch_txt_reader(repo_dir):
path = find_first("TxtReaderActivity.cpp", repo_dir)
content = read_file(path)
if "isDarkMode" in content:
print(" TxtReaderActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "TxtReaderActivity.h"',
'#include "TxtReaderActivity.h"\n#include "activities/settings/DarkModePlugin.h"'
)
content = content.replace(
' renderLines();\n'
' renderStatusBar();\n'
'\n'
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);',
' renderLines();\n'
' renderStatusBar();\n'
' if (DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState))) renderer.invertScreen();\n'
'\n'
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);'
)
write_file(path, content)
print(" TxtReaderActivity.cpp patched.")
def patch_xtc_reader(repo_dir):
path = find_first("XtcReaderActivity.cpp", repo_dir)
content = read_file(path)
if "isDarkMode" in content:
print(" XtcReaderActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "XtcReaderActivity.h"',
'#include "XtcReaderActivity.h"\n#include "activities/settings/DarkModePlugin.h"'
)
content = content.replace(
' if (pagesUntilFullRefresh <= 1) {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n'
' } else {\n'
' renderer.displayBuffer();\n'
' pagesUntilFullRefresh--;\n'
' }\n'
'\n'
' // Pass 2: LSB buffer',
' if (DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState))) renderer.invertScreen();\n'
' if (pagesUntilFullRefresh <= 1) {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n'
' } else {\n'
' renderer.displayBuffer();\n'
' pagesUntilFullRefresh--;\n'
' }\n'
'\n'
' // Pass 2: LSB buffer'
)
content = content.replace(
' if (pagesUntilFullRefresh <= 1) {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n'
' } else {\n'
' renderer.displayBuffer();\n'
' pagesUntilFullRefresh--;\n'
' }\n'
'\n'
' LOG_DBG("XTR"',
' if (DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState))) renderer.invertScreen();\n'
' if (pagesUntilFullRefresh <= 1) {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n'
' } else {\n'
' renderer.displayBuffer();\n'
' pagesUntilFullRefresh--;\n'
' }\n'
'\n'
' LOG_DBG("XTR"'
)
write_file(path, content)
print(" XtcReaderActivity.cpp patched.")
def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path)
if '"Dark Mode"' in content or '"Smaller Fonts"' in content:
print(" CrossPointWebServer.cpp already patched, skipping.")
return
name_overrides = (
'\n'
' if (s.key) {\n'
' if (strcmp(s.key, "darkModeState") == 0) {\n'
' doc["name"] = "Dark Mode";\n'
' doc["category"] = "Plugins";\n'
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' doc["name"] = "Smaller Fonts";\n'
' doc["category"] = "Plugins";\n'
' }\n'
' }\n'
)
content = content.replace(
' doc["category"] = I18N.get(s.category);\n'
'\n'
' switch (s.type) {',
' doc["category"] = I18N.get(s.category);\n'
+ name_overrides +
'\n'
' switch (s.type) {'
)
enum_overrides = (
' if (s.key) {\n'
' if (strcmp(s.key, "darkModeState") == 0) {\n'
' JsonArray opts = doc["options"].to<JsonArray>();\n'
' opts.add("Disabled");\n'
' opts.add("Enabled");\n'
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' JsonArray opts = doc["options"].to<JsonArray>();\n'
' opts.add("Disabled");\n'
' opts.add("Smaller");\n'
' opts.add("Smallest");\n'
' }\n'
' }\n'
)
content = content.replace(
' for (const auto& opt : s.enumValues) {\n'
' options.add(I18N.get(opt));\n'
' }\n'
' break;\n',
' for (const auto& opt : s.enumValues) {\n'
' options.add(I18N.get(opt));\n'
' }\n'
+ enum_overrides +
' break;\n'
)
write_file(path, content)
print(" CrossPointWebServer.cpp patched.")
def patch(repo_dir: str):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching CrossPointSettings.h...")
patch_cross_point_settings_h(repo_dir)
print(" Patching SettingsList.h...")
patch_settings_list_h(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
print(" Patching EpubReaderActivity.cpp...")
patch_epub_reader(repo_dir)
print(" Patching TxtReaderActivity.cpp...")
patch_txt_reader(repo_dir)
print(" Patching XtcReaderActivity.cpp...")
patch_xtc_reader(repo_dir)
print(" Patching CrossPointWebServer.cpp (web UI)...")
patch_web_server(repo_dir)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+37
View File
@@ -0,0 +1,37 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingsField, PluginsTabEntry, ReaderInvertHook,
)
def get_manifest(ctx):
return PluginManifest(
name="darkmode",
pretty_name="Dark Mode",
source_files=[
SourceFile("DarkModePlugin.h", "src/activities/settings"),
SourceFile("DarkModePlugin.cpp", "src/activities/settings"),
],
includes=[
Include("activities/settings/DarkModePlugin.h", "cross_point_settings_h"),
],
settings_fields=[
SettingsField("uint8_t darkModeState = 0;"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Dark Mode",
kind="enum",
key="darkModeState",
option_labels=["Disabled", "Enabled"],
value_text_expr="DarkModePlugin::stateName(static_cast<DarkModeState>(value))",
),
],
reader_invert_hook=ReaderInvertHook(
predicate_expr="DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState))",
include_header="activities/settings/DarkModePlugin.h",
),
)
-564
View File
@@ -1,564 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
import subprocess
import getpass
import csv
import tempfile
import ssl
import urllib.request
import urllib.error
import json
from pathlib import Path
NVS_NAMESPACE = "github_sync"
XTEINK_CONFIG_FILE = Path.home() / ".xteink"
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:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def load_xteink_config() -> dict:
if not XTEINK_CONFIG_FILE.exists():
return {}
try:
with open(XTEINK_CONFIG_FILE, "r") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def save_xteink_config(cfg: dict) -> None:
try:
saved = load_xteink_config()
if "username" in cfg:
saved["githubuser"] = cfg["username"]
if "repo" in cfg:
saved["githubrepo"] = cfg["repo"]
if "branch" in cfg:
saved["githubbranch"] = cfg["branch"]
with open(XTEINK_CONFIG_FILE, "w") as f:
json.dump(saved, f, indent=2)
print(f" ✓ Config saved to {XTEINK_CONFIG_FILE}")
except Exception as e:
print(f" ! Could not save config: {e}")
_github_https_deps_ready = False
def ensure_python_module(module_name: str, pip_package: str | None = None, *, description: str = "") -> None:
pip_package = pip_package or module_name
try:
__import__(module_name)
return
except ImportError:
pass
desc = f" — {description}" if description else ""
print(f" ! Python module '{module_name}' is not installed{desc}.")
input(f" Press Enter to install {pip_package}... ")
result = subprocess.run(
[sys.executable, "-m", "pip", "install", pip_package],
capture_output=True, text=True,
)
if result.returncode != 0:
sys.exit(f"ERROR: Failed to install {pip_package}:\n{result.stderr.strip()}")
print(f" ✓ {pip_package} installed")
try:
__import__(module_name)
except ImportError:
sys.exit(f"ERROR: '{module_name}' still not importable after install")
def ensure_github_https_dependencies() -> None:
global _github_https_deps_ready
if _github_https_deps_ready:
return
if os.environ.get("GITHUB_SYNC_SSL_NO_VERIFY", "").strip().lower() in ("1", "true", "yes", "on"):
_github_https_deps_ready = True
return
cf = os.environ.get("SSL_CERT_FILE", "").strip()
if cf and os.path.isfile(cf):
_github_https_deps_ready = True
return
try:
import certifi # noqa: F401
_github_https_deps_ready = True
return
except ImportError:
pass
ensure_python_module(
"certifi",
description="recommended for GitHub HTTPS (fixes macOS SSL certificate errors)",
)
_github_https_deps_ready = True
def get_github_ssl_context():
flag = os.environ.get("GITHUB_SYNC_SSL_NO_VERIFY", "").strip().lower()
if flag in ("1", "true", "yes", "on"):
print(" ! SSL verification is OFF (GITHUB_SYNC_SSL_NO_VERIFY). Only use if you trust this network.")
return ssl._create_unverified_context()
cert_file = os.environ.get("SSL_CERT_FILE", "").strip()
if cert_file and os.path.isfile(cert_file):
return ssl.create_default_context(cafile=cert_file)
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
pass
return ssl.create_default_context()
def ssl_troubleshoot_hint(err_txt: str | None) -> str:
if not err_txt:
return ""
if "CERTIFICATE_VERIFY_FAILED" in err_txt or "SSL" in err_txt:
return (
" Try: pip install certifi (then re-run), or run macOS "
"'Install Certificates.command' for your Python, or set SSL_CERT_FILE to a CA bundle. "
"Last resort: GITHUB_SYNC_SSL_NO_VERIFY=1 (insecure)."
)
return ""
def github_api_get(path: str, token: str | None = None, timeout: int = 10):
url = f"https://api.github.com{path}"
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "xteink-github-sync-patcher",
}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
ctx = get_github_ssl_context()
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
body = resp.read().decode("utf-8", errors="replace")
data = json.loads(body) if body else {}
return resp.status, data, None
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
try:
data = json.loads(body) if body else {}
except Exception:
data = {"message": body.strip()} if body else {}
return e.code, data, None
except Exception as e:
return None, None, str(e)
def validate_github_username(username: str) -> tuple[bool, str]:
status, data, err_txt = github_api_get(f"/users/{username}")
if err_txt:
return False, f"Could not reach GitHub API: {err_txt}{ssl_troubleshoot_hint(err_txt)}"
if status == 200:
return True, "GitHub username is reachable."
if status == 404:
return False, "Username not found on GitHub."
msg = data.get("message", "Unknown API error") if isinstance(data, dict) else "Unknown API error"
return False, f"GitHub API error ({status}): {msg}"
def validate_pat(token: str) -> tuple[bool, str, str | None]:
status, data, err_txt = github_api_get("/user", token=token)
if err_txt:
return False, f"Could not validate PAT: {err_txt}{ssl_troubleshoot_hint(err_txt)}", None
if status == 200 and isinstance(data, dict):
login = data.get("login")
return True, f"PAT is valid (authenticated as {login}).", login
if status in (401, 403):
msg = data.get("message", "Unauthorized") if isinstance(data, dict) else "Unauthorized"
return False, f"PAT rejected: {msg}", None
msg = data.get("message", "Unknown API error") if isinstance(data, dict) else "Unknown API error"
return False, f"PAT validation failed ({status}): {msg}", None
def validate_repo_access(owner: str, repo: str, token: str) -> tuple[bool, str]:
status, data, err_txt = github_api_get(f"/repos/{owner}/{repo}", token=token)
if err_txt:
return False, f"Could not validate repo access: {err_txt}{ssl_troubleshoot_hint(err_txt)}"
if status == 200:
return True, "Repo is reachable with this PAT."
if status == 404:
return False, "Repo not found, or PAT cannot access it."
if status in (401, 403):
msg = data.get("message", "Unauthorized") if isinstance(data, dict) else "Unauthorized"
return False, f"Access denied: {msg}"
msg = data.get("message", "Unknown API error") if isinstance(data, dict) else "Unknown API error"
return False, f"Repo validation failed ({status}): {msg}"
def prompt_github_config() -> dict:
print("\n GitHub Sync Configuration by Justin Oros")
print(" Press Enter on any field to use the saved value, or type a new one.")
print(" Leave GitHub username blank to skip and configure on-device later.\n")
saved = load_xteink_config()
saved_user = saved.get("githubuser", "")
saved_repo = saved.get("githubrepo", "xteink")
saved_branch = saved.get("githubbranch", "main")
while True:
if saved_user:
raw = input(f" GitHub username ({saved_user}): ").strip()
username = raw or saved_user
else:
username = input(" GitHub username: ").strip()
if not username:
return {}
ensure_github_https_dependencies()
ok_user, msg = validate_github_username(username)
if ok_user:
print(f" ✓ {msg}")
break
print(f" ! {msg}")
retry = input(" Try another username? [Y/n]: ").strip().lower()
if retry == "n":
return {}
print("\n To generate a Personal Access Token (PAT):")
print(" 1. Go to github.com -> Settings -> Developer settings")
print(" 2. Personal access tokens -> Fine-grained tokens")
print(" 3. Click 'Generate new token'")
print(" 4. Token name: xteink")
print(" 5. Expiration: No expiration")
print(" 6. Repository access: Only selected repositories -> select 'xteink'")
print(" 7. Permissions -> Add permissions -> Contents: Read-only")
print(" 8. Click 'Generate token' then copy it - GitHub only shows it once\n")
authenticated_login = None
while True:
pat = getpass.getpass(" Personal Access Token (PAT): ").strip()
if not pat:
return {}
ok_pat, msg, authenticated_login = validate_pat(pat)
if ok_pat:
print(f" ✓ {msg}")
break
print(f" ! {msg}")
retry = input(" Try entering PAT again? [Y/n]: ").strip().lower()
if retry == "n":
return {}
while True:
raw = input(f" Repo name ({saved_repo}): ").strip()
repo = raw or saved_repo
owner_for_repo = authenticated_login or username
ok_repo, msg = validate_repo_access(owner_for_repo, repo, pat)
if ok_repo:
print(f" ✓ {msg}")
if authenticated_login and authenticated_login != username:
print(f" ! Username '{username}' differs from PAT owner '{authenticated_login}'. Repo was validated under '{owner_for_repo}'.")
break
print(f" ! {msg}")
retry = input(" Try another repo name? [Y/n]: ").strip().lower()
if retry == "n":
return {}
raw = input(f" Branch ({saved_branch}): ").strip()
branch = raw or saved_branch
cfg = {"username": username, "pat": pat, "repo": repo, "branch": branch}
save_xteink_config(cfg)
return cfg
def check_nvs_gen() -> bool:
result = subprocess.run(
["python3", "-m", "esp_idf_nvs_partition_gen", "--help"],
capture_output=True, text=True
)
if result.returncode == 0:
return True
result2 = subprocess.run(
[sys.executable, "-m", "pip", "install", "esp-idf-nvs-partition-gen"],
capture_output=True, text=True
)
result3 = subprocess.run(
["python3", "-m", "esp_idf_nvs_partition_gen", "--help"],
capture_output=True, text=True
)
return result3.returncode == 0
def write_nvs_partition(cfg: dict) -> Path | None:
if not check_nvs_gen():
print(" ! esp-idf-nvs-partition-gen unavailable — enter credentials on-device via Settings -> GitHub Sync.")
return None
nvs_csv = Path(tempfile.mkdtemp()) / "github_sync_nvs.csv"
rows = [
["key", "type", "encoding", "value"],
[NVS_NAMESPACE, "namespace", "", ""],
["username", "data", "string", cfg["username"]],
["pat", "data", "string", cfg["pat"]],
["repo", "data", "string", cfg["repo"]],
["branch", "data", "string", cfg["branch"]],
]
with open(nvs_csv, "w", newline="") as f:
writer = csv.writer(f)
writer.writerows(rows)
nvs_bin = nvs_csv.with_suffix(".bin")
result = subprocess.run(
["python3", "-m", "esp_idf_nvs_partition_gen", "generate",
str(nvs_csv), str(nvs_bin), "0x3000"],
capture_output=True, text=True
)
if result.returncode != 0:
print(f" ! nvs_partition_gen failed — enter credentials on-device via Settings -> GitHub Sync.\n{result.stderr.strip()}")
return None
print(f" ✓ NVS partition written to {nvs_bin}")
return nvs_bin
def copy_plugin_sources(plugin_dir, repo_dir):
copies = [
(os.path.join(plugin_dir, "GitHubSync.h"),
os.path.join(repo_dir, "include", "GitHubSync.h")),
(os.path.join(plugin_dir, "GitHubSyncSettingsActivity.h"),
os.path.join(repo_dir, "include", "GitHubSyncSettingsActivity.h")),
(os.path.join(plugin_dir, "GitHubSync.cpp"),
os.path.join(repo_dir, "src", "github_sync", "GitHubSync.cpp")),
(os.path.join(plugin_dir, "GitHubSyncSettingsActivity.cpp"),
os.path.join(repo_dir, "src", "activities", "settings", "GitHubSyncSettingsActivity.cpp")),
]
for src, dst in copies:
if not os.path.exists(src):
sys.exit(f"ERROR: Patch file missing: {src}")
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
print(f" ✓ {os.path.basename(dst)}")
def patch_platformio_ini(repo_dir):
ini = os.path.join(repo_dir, "platformio.ini")
if not os.path.exists(ini):
print(" platformio.ini not found, skipping.")
return
content = read_file(ini)
if "ArduinoJson" in content:
print(" platformio.ini already patched, skipping.")
return
content = content.replace(
"lib_deps",
"lib_deps\n\tbblanchon/ArduinoJson @ ^7",
1
)
write_file(ini, content)
print(" platformio.ini patched.")
def patch_main_cpp(repo_dir):
candidates = [
os.path.join(repo_dir, "src", "main.cpp"),
os.path.join(repo_dir, "src", "Main.cpp"),
]
main_path = next((p for p in candidates if os.path.exists(p)), None)
if not main_path:
print(" ! Could not find main.cpp — add GitHub sync call manually.")
return
content = read_file(main_path)
if '#include "GitHubSync.h"' in content:
print(" main.cpp already patched, skipping.")
return
content = content.replace(
'#include "CrossPointSettings.h"',
'#include "GitHubSync.h"\n#include "CrossPointSettings.h"',
1
)
sync_call = (
'\n if (GitHubSync::isConfigured()) {\n'
' GitHubSyncResult result = GitHubSync::sync();\n'
' if (result != GitHubSyncResult::OK) {\n'
' LOG_ERR("SYNC", "%s", GitHubSync::resultMessage(result));\n'
' }\n'
' }\n'
)
if "GitHubSync::isConfigured" not in content:
content = content.replace(
"activityManager.goToBoot();",
"activityManager.goToBoot();" + sync_call,
1
)
write_file(main_path, content)
print(" main.cpp patched.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
if "GitHubSync," in content:
print(" SettingsActivity.h already patched, skipping.")
return
content = content.replace(
" CheckForUpdates,",
" CheckForUpdates,\n GitHubSync,",
1
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if '#include "GitHubSyncSettingsActivity.h"' in content and "SettingAction::GitHubSync" in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
if '#include "GitHubSyncSettingsActivity.h"' not in content:
content = content.replace(
'#include "SettingsActivity.h"',
'#include "SettingsActivity.h"\n#include "GitHubSyncSettingsActivity.h"',
1
)
if "SettingAction::GitHubSync" not in content:
content = content.replace(
"SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));",
"SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));\n"
" pluginsSettings.push_back(SettingInfo::Action(StrId::STR_NONE_OPT, SettingAction::GitHubSync));",
1
)
if "case SettingAction::GitHubSync:" not in content:
content = content.replace(
"case SettingAction::CheckForUpdates:",
"case SettingAction::CheckForUpdates:\n"
" case SettingAction::GitHubSync:\n"
" startActivityForResult(std::make_unique<GitHubSyncSettingsActivity>(renderer, mappedInput), resultHandler);\n"
" break;",
1
)
if '"GitHub Sync"' not in content:
content = content.replace(
'[&settings, this](int index) -> std::string {\n',
'[&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
' if (s.type == SettingType::ACTION && s.action == SettingAction::GitHubSync) return "GitHub Sync";\n'
' }\n',
1
)
if '"Installed"' not in content:
content = content.replace(
'} else if (setting.type == SettingType::ACTION && setting.action == SettingAction::BookerlyInstalled) {\n'
' valueText = "Installed";\n',
'} else if (setting.type == SettingType::ACTION && setting.action == SettingAction::BookerlyInstalled) {\n'
' valueText = "Installed";\n'
' } else if (setting.type == SettingType::ACTION && setting.action == SettingAction::GitHubSync) {\n'
' valueText = "Sync";\n',
1
)
elif "GitHubSync" not in content.split('valueText = "Installed"')[0].split('BookerlyInstalled')[-1]:
content = content.replace(
'} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {\n'
' valueText = std::to_string(SETTINGS.*(setting.valuePtr));\n'
' }',
'} else if (setting.type == SettingType::ACTION && setting.action == SettingAction::GitHubSync) {\n'
' valueText = "Sync";\n'
' } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {\n'
' valueText = std::to_string(SETTINGS.*(setting.valuePtr));\n'
' }',
1
)
write_file(path, content)
print(" SettingsActivity.cpp patched.")
def patch_translation_files(repo_dir):
yaml_dir = os.path.join(repo_dir, "lib", "I18n", "translations")
if not os.path.isdir(yaml_dir):
print(" ! Could not find translation YAML files — add STR_GITHUB_SYNC manually.")
return
yaml_files = glob.glob(os.path.join(yaml_dir, "*.yaml"))
if not yaml_files:
print(" ! No translation YAML files found.")
return
patched = 0
for yf in yaml_files:
content = read_file(yf)
if "STR_GITHUB_SYNC" in content:
patched += 1
continue
lines = content.splitlines(keepends=True)
new_lines = []
inserted = False
for line in lines:
new_lines.append(line)
if line.startswith("STR_CHECK_UPDATES:"):
new_lines.append('STR_GITHUB_SYNC: "GitHub Sync"\n')
inserted = True
if inserted:
write_file(yf, "".join(new_lines))
patched += 1
else:
print(f" ! STR_CHECK_UPDATES not found in {os.path.basename(yf)} — add STR_GITHUB_SYNC manually.")
print(f" Translation files patched ({patched} files).")
def patch(repo_dir: str, yes_all: bool = False):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching platformio.ini...")
patch_platformio_ini(repo_dir)
print(" Patching main.cpp...")
patch_main_cpp(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
print(" Patching translation files...")
patch_translation_files(repo_dir)
if not yes_all:
print("\n GitHub Sync credentials (optional — can also be set on-device).")
answer = input(" Configure GitHub credentials now? [Y/n]: ").strip().lower()
if answer in ("", "y", "yes"):
cfg = prompt_github_config()
if cfg:
nvs_bin = write_nvs_partition(cfg)
if nvs_bin:
print(f"\n NVS partition ready: {nvs_bin}")
print(" Flash it after firmware upload with:")
print(f" esptool.py --chip esp32c3 write_flash 0x9000 {nvs_bin}")
else:
print(" Skipping credential prompt (--yes mode). Configure on-device via Settings -> GitHub Sync.")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+66
View File
@@ -0,0 +1,66 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingActionEnumValue, PluginsTabEntry, MainHook,
)
# NOTE on scope: the original patch.py also pre-provisioned GitHub credentials
# directly into a reserved NVS flash partition at install time (via a
# generated nvs.csv + `esptool ... write-flash 0x3000`), so users wouldn't
# have to type a PAT on-device. That's flash-time, not source-patch-time
# logic, and GitHubSyncSettingsActivity already lets you configure
# username/PAT/repo/branch on-device (or you can add web-UI editing later),
# so this rewrite drops the NVS pre-seeding step for simplicity/safety and
# has you configure GitHub Sync via Settings -> Plugins -> GitHub Sync after
# first boot instead. Ask if you want the NVS pre-seeding restored.
#
# Also fixed in this rewrite: the original main.cpp hook only ran
# GitHubSync::sync() inside one narrow boot branch (quick-resume-with-no-
# saved-frame), so on most ordinary boots it silently never synced. It's now
# a "post_boot" hook that runs exactly once, every boot, after the routing
# decision - regardless of which plugins are installed alongside it.
def get_manifest(ctx):
return PluginManifest(
name="githubsync",
pretty_name="GitHub Sync",
source_files=[
SourceFile("GitHubSync.h", "src/activities/settings"),
SourceFile("GitHubSync.cpp", "src/activities/settings"),
SourceFile("GitHubSyncSettingsActivity.h", "src/activities/settings"),
SourceFile("GitHubSyncSettingsActivity.cpp", "src/activities/settings"),
],
includes=[
Include("GitHubSync.h", "settings_activity_cpp"),
Include("GitHubSyncSettingsActivity.h", "settings_activity_cpp"),
Include("activities/settings/GitHubSync.h", "main_cpp"),
],
setting_actions=[
SettingActionEnumValue("GitHubSync"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="GitHub Sync",
kind="action",
action_name="GitHubSync",
action_value_text="Sync",
activity_launch_expr="std::make_unique<GitHubSyncSettingsActivity>(renderer, mappedInput)",
),
],
main_hooks=[
MainHook(
point="post_boot",
code=(
" if (GitHubSync::isConfigured()) {\n"
" GitHubSyncResult ghSyncResult = GitHubSync::sync();\n"
" if (ghSyncResult != GitHubSyncResult::OK) {\n"
' LOG_ERR("SYNC", "%s", GitHubSync::resultMessage(ghSyncResult));\n'
" }\n"
" }"
),
),
],
)
+4 -4
View File
@@ -119,7 +119,7 @@ std::string getIsbnFromEpub(Epub& epub, const std::string& path) {
// ---- Progress reading ------------------------------------------------------- // ---- Progress reading -------------------------------------------------------
int epubProgressPercent(const std::string& cachePath, int& outPage, Epub& epub) { int epubProgressPercent(const std::string& cachePath, int& outPage, Epub& epub) {
FsFile f; HalFile f;
if (!Storage.openFileForRead("HCV", (cachePath + "/progress.bin").c_str(), f)) if (!Storage.openFileForRead("HCV", (cachePath + "/progress.bin").c_str(), f))
return -1; return -1;
uint8_t data[6] = {}; uint8_t data[6] = {};
@@ -178,7 +178,7 @@ void collectBooks(std::vector<InProgressBook>& inProgress, std::vector<InProgres
std::string cachePath = buildCachePath(path); std::string cachePath = buildCachePath(path);
// Check progress.bin exists before loading the EPUB // Check progress.bin exists before loading the EPUB
FsFile f; HalFile f;
if (!Storage.openFileForRead("HCV", (cachePath + "/progress.bin").c_str(), f)) continue; if (!Storage.openFileForRead("HCV", (cachePath + "/progress.bin").c_str(), f)) continue;
f.close(); f.close();
@@ -351,7 +351,7 @@ std::string readSessionCachePath(int userBookId) {
} }
int loadCachedReadId(int userBookId) { int loadCachedReadId(int userBookId) {
FsFile f; HalFile f;
std::string path = readSessionCachePath(userBookId); std::string path = readSessionCachePath(userBookId);
if (!Storage.openFileForRead("HCV", path.c_str(), f)) return 0; if (!Storage.openFileForRead("HCV", path.c_str(), f)) return 0;
uint8_t data[4] = {}; uint8_t data[4] = {};
@@ -362,7 +362,7 @@ int loadCachedReadId(int userBookId) {
} }
void saveCachedReadId(int userBookId, int readId) { void saveCachedReadId(int userBookId, int readId) {
FsFile f; HalFile f;
std::string path = readSessionCachePath(userBookId); std::string path = readSessionCachePath(userBookId);
if (!Storage.openFileForWrite("HCV", path.c_str(), f)) { if (!Storage.openFileForWrite("HCV", path.c_str(), f)) {
LOG_ERR("HCV", "Could not save read session cache for user_book_id=%d", userBookId); LOG_ERR("HCV", "Could not save read session cache for user_book_id=%d", userBookId);
-415
View File
@@ -1,415 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
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:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def copy_plugin_sources(plugin_dir, repo_dir):
dest = os.path.join(repo_dir, "src", "activities", "settings")
os.makedirs(dest, exist_ok=True)
for fname in os.listdir(plugin_dir):
if fname.endswith((".h", ".cpp", ".hpp")):
src = os.path.join(plugin_dir, fname)
dst = os.path.join(dest, fname)
shutil.copy2(src, dst)
print(f" \u2713 {fname}")
def patch_cross_point_settings_h(repo_dir, token=""):
path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path)
if "hardcoverApiToken" in content:
print(" CrossPointSettings.h already patched, skipping.")
return
safe_token = token.replace('\\', '\\\\').replace('"', '\\"')
content = content.replace(
' uint8_t imageRendering = IMAGES_DISPLAY;',
f' uint8_t imageRendering = IMAGES_DISPLAY;\n char hardcoverApiToken[640] = "{safe_token}";'
)
write_file(path, content)
print(f" CrossPointSettings.h patched{' (token embedded)' if token else ' (no token)'}.")
def patch_settings_list_h(repo_dir):
path = find_first("SettingsList.h", repo_dir)
content = read_file(path)
if "hardcoverApiToken" in content:
print(" SettingsList.h already patched, skipping.")
return
content = content.replace(
' };\n return list;\n}\n',
' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.hardcoverApiToken,\n'
' sizeof(SETTINGS.hardcoverApiToken), "hardcoverApiToken")\n'
' .withObfuscated(),\n'
' };\n return list;\n}\n'
)
write_file(path, content)
print(" SettingsList.h patched.")
def patch_setting_action_enum(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
if "HardcoverSync" in content:
print(" SettingAction enum already patched, skipping.")
return
for anchor in [
' Language,\n};',
' BookerlyInstalled,\n};',
' GitHubSync,\n Language,\n};',
' Language,\n BookerlyInstalled,\n};',
' GitHubSync,\n Language,\n BookerlyInstalled,\n};',
]:
if anchor in content:
content = content.replace(anchor, anchor[:-2] + ' HardcoverSync,\n};')
break
else:
import re
content = re.sub(r'(\n};)', r'\n HardcoverSync,\1', content, count=1)
write_file(path, content)
print(" SettingAction::HardcoverSync added.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
already_has_tab = "categoryCount = 5" in content
already_has_vec = "pluginsSettings" in content
if already_has_tab and already_has_vec:
print(" SettingsActivity.h already patched, skipping.")
return
if not already_has_tab:
content = content.replace(
'static constexpr int categoryCount = 4;',
'static constexpr int categoryCount = 5;'
)
if not already_has_vec:
content = content.replace(
' std::vector<SettingInfo> systemSettings;',
' std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;'
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if "HardcoverPlugin::syncProgress" in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "SettingsList.h"',
'#include "SettingsList.h"\n#include "HardcoverPlugin.h"\n#include "HardcoverSyncActivity.h"'
)
if 'StrId::STR_NONE_OPT};' not in content and 'STR_NONE_OPT' not in content.split('categoryNames')[1].split(';')[0]:
content = content.replace(
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};',
'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};'
)
if "pluginsSettings.clear();" not in content:
content = content.replace(
' systemSettings.clear();',
' systemSettings.clear();\n pluginsSettings.clear();'
)
hardcover_push = (
' pluginsSettings.push_back(SettingInfo::Action(\n'
' StrId::STR_NONE_OPT, SettingAction::HardcoverSync\n'
' ));\n'
)
if 'SettingAction::HardcoverSync' not in content:
anchors = [
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n'
' ));\n',
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n'
' ));\n',
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n',
]
for anchor in anchors:
if anchor in content:
content = content.replace(anchor, anchor + hardcover_push)
break
if 'case 4:\n currentSettings = &pluginsSettings;' not in content:
content = content.replace(
' case 3:\n currentSettings = &systemSettings;\n break;',
' case 3:\n currentSettings = &systemSettings;\n break;\n'
' case 4:\n currentSettings = &pluginsSettings;\n break;'
)
if '"Plugins"' not in content:
content = content.replace(
' 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'
' }',
' 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'
' }'
)
if 'nextCatLabel' not in content:
content = content.replace(
' const auto confirmLabel = (selectedSettingIndex == 0)\n'
' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n'
' : tr(STR_TOGGLE);',
' const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n'
' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n'
' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);'
)
hardcover_label_line = (
' if (s.type == SettingType::ACTION &&\n'
' s.action == SettingAction::HardcoverSync) return "Hardcover Progress";\n'
)
if 'Hardcover Progress' not in content:
if 'selectedCategoryIndex == 4' in content:
closing = (
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' }, nullptr, nullptr,'
)
closing_bookerly = (
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' }, nullptr, nullptr,'
)
for last_key_line in [
' if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n',
' if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n',
' s.action == SettingAction::GitHubSync) return "GitHub Sync";\n',
' s.action == SettingAction::BookerlyInstalled) return "Bookerly Font";\n',
]:
for cl in [closing, closing_bookerly]:
anchor = last_key_line + cl
if anchor in content:
content = content.replace(anchor, last_key_line + hardcover_label_line + cl)
break
else:
continue
break
else:
for cl in [closing, closing_bookerly]:
if cl in content:
content = content.replace(cl, hardcover_label_line + cl, 1)
break
else:
old_lambda = (
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,'
)
new_lambda = (
' [&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
+ hardcover_label_line +
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' }, nullptr, nullptr,'
)
content = content.replace(old_lambda, new_lambda)
hardcover_value_block = (
' if (setting.type == SettingType::ACTION &&\n'
' setting.action == SettingAction::HardcoverSync) {\n'
' valueText = "Sync";\n'
' } else '
)
if 'SettingAction::HardcoverSync) {\n valueText = "Sync"' not in content:
for value_anchor in [
' if (setting.type == SettingType::ACTION &&\n'
' setting.action == SettingAction::GoodreadsSync) {\n'
' valueText = "Sync";\n'
' } else if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {',
' if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {',
]:
if value_anchor in content:
content = content.replace(value_anchor, hardcover_value_block + value_anchor)
break
hardcover_case = (
' case SettingAction::HardcoverSync:\n'
' startActivityForResult(std::make_unique<HardcoverSyncActivity>(renderer, mappedInput), resultHandler);\n'
' break;\n'
)
if 'case SettingAction::HardcoverSync:' not in content:
for anchor in [
' case SettingAction::GoodreadsSync:\n'
' startActivityForResult(std::make_unique<GoodreadsSyncActivity>(renderer, mappedInput), resultHandler);\n'
' break;\n'
' case SettingAction::None:',
' case SettingAction::Language:\n'
' startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);\n'
' break;\n'
' case SettingAction::None:',
]:
if anchor in content:
content = content.replace(anchor, hardcover_case + anchor)
break
write_file(path, content)
print(" SettingsActivity.cpp patched.")
def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path)
if '"hardcoverApiToken"' in content or '"Hardcover API Token"' in content:
print(" CrossPointWebServer.cpp already patched, skipping.")
return
hardcover_block = (
' if (strcmp(s.key, "hardcoverApiToken") == 0) {\n'
' doc["name"] = "Hardcover API Token";\n'
' doc["category"] = "Plugins";\n'
' }\n'
)
if 'if (s.key) {' in content and 'darkModeState' in content:
content = content.replace(
' if (strcmp(s.key, "darkModeState") == 0) {\n',
hardcover_block +
' if (strcmp(s.key, "darkModeState") == 0) {\n'
)
else:
content = content.replace(
' doc["category"] = I18N.get(s.category);\n'
'\n'
' switch (s.type) {',
' doc["category"] = I18N.get(s.category);\n'
'\n'
' if (s.key) {\n'
+ hardcover_block +
' }\n'
'\n'
' switch (s.type) {'
)
write_file(path, content)
print(" CrossPointWebServer.cpp patched.")
def patch(repo_dir: str, yes_all: bool = False):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
hardcover_file = os.path.expanduser("~/.hardcover")
token = ""
if os.path.exists(hardcover_file):
raw = open(hardcover_file).read().strip()
if raw.startswith("Bearer "):
raw = raw[len("Bearer "):]
token = raw
if token:
print(f" Using Hardcover API token from ~/.hardcover.")
else:
print(" Hardcover API token required.")
print(" Get yours from https://hardcover.app/account/api")
print(" Paste your Hardcover API token below.")
while not token:
raw = input(" Hardcover API token: ").strip()
if raw.startswith("Bearer "):
raw = raw[len("Bearer "):]
token = raw
if not token:
print(" Token cannot be empty. Please enter your Hardcover API token.")
with open(hardcover_file, "w") as f:
f.write(f"Bearer {token}\n")
print(f" Token saved to ~/.hardcover for future installs.")
if len(token) >= 640:
sys.exit(f"ERROR: Hardcover API token is too long ({len(token)} chars, max 639).")
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching CrossPointSettings.h...")
patch_cross_point_settings_h(repo_dir, token)
print(" Patching SettingsList.h...")
patch_settings_list_h(repo_dir)
print(" Patching SettingAction enum...")
patch_setting_action_enum(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
print(" Patching CrossPointWebServer.cpp (web UI)...")
patch_web_server(repo_dir)
import glob as _glob
cpp_results = _glob.glob(os.path.join(repo_dir, "**", "SettingsActivity.cpp"), recursive=True)
if cpp_results:
cpp_content = open(cpp_results[0]).read()
checks = [
("label lambda: 'Hardcover Progress'", "Hardcover Progress" in cpp_content),
("switch case: HardcoverSync", "case SettingAction::HardcoverSync:" in cpp_content),
("HardcoverSyncActivity launched", "HardcoverSyncActivity" in cpp_content),
("pluginsSettings.clear()", "pluginsSettings.clear();" in cpp_content),
("case 4 -> pluginsSettings", "case 4:" in cpp_content),
]
all_ok = True
for label, ok in checks:
print(f" [{'PASS' if ok else 'FAIL'}] {label}")
if not ok:
all_ok = False
if not all_ok:
sys.exit("ERROR: One or more Hardcover patch checks failed.")
print(" Hardcover plugin patch verified OK.")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+83
View File
@@ -0,0 +1,83 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingsField, PluginsTabEntry, SettingActionEnumValue,
)
HARDCOVER_TOKEN_FILE = os.path.expanduser("~/.hardcover")
def _get_token(ctx):
token = ""
if os.path.exists(HARDCOVER_TOKEN_FILE):
raw = open(HARDCOVER_TOKEN_FILE).read().strip()
if raw.startswith("Bearer "):
raw = raw[len("Bearer "):]
token = raw
if token:
print(f" [hardcover] Using API token from {HARDCOVER_TOKEN_FILE}.")
return token
if not ctx.prompt:
return ""
print(" [hardcover] Hardcover API token required.")
print(" [hardcover] Get yours from https://hardcover.app/account/api")
while not token:
raw = input(" Hardcover API token: ").strip()
if raw.startswith("Bearer "):
raw = raw[len("Bearer "):]
token = raw
if not token:
print(" Token cannot be empty.")
with open(HARDCOVER_TOKEN_FILE, "w") as f:
f.write(f"Bearer {token}\n")
print(f" [hardcover] Token saved to {HARDCOVER_TOKEN_FILE} for future installs.")
return token
def get_manifest(ctx):
token = _get_token(ctx)
if len(token) >= 640:
raise SystemExit(f"ERROR: Hardcover API token is too long ({len(token)} chars, max 639).")
safe_token = token.replace("\\", "\\\\").replace('"', '\\"')
return PluginManifest(
name="hardcover",
pretty_name="Hardcover Sync",
source_files=[
SourceFile("HardcoverPlugin.h", "src/activities/settings"),
SourceFile("HardcoverPlugin.cpp", "src/activities/settings"),
SourceFile("HardcoverSyncActivity.h", "src/activities/settings"),
SourceFile("HardcoverSyncActivity.cpp", "src/activities/settings"),
],
includes=[
Include("HardcoverPlugin.h", "settings_activity_cpp"),
Include("HardcoverSyncActivity.h", "settings_activity_cpp"),
],
settings_fields=[
SettingsField(f'char hardcoverApiToken[640] = "{safe_token}";'),
],
setting_actions=[
SettingActionEnumValue("HardcoverSync"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Hardcover API Token",
kind="string",
key="hardcoverApiToken",
obfuscated=True,
show_on_device=False,
),
PluginsTabEntry(
label="Hardcover Progress",
kind="action",
action_name="HardcoverSync",
action_value_text="Sync",
activity_launch_expr="std::make_unique<HardcoverSyncActivity>(renderer, mappedInput)",
),
],
)
-547
View File
@@ -1,547 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
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:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def copy_plugin_sources(plugin_dir, repo_dir):
settings_dest = os.path.join(repo_dir, "src", "activities", "settings")
activities_dest = os.path.join(repo_dir, "src", "activities")
os.makedirs(settings_dest, exist_ok=True)
settings_files = {
"LockscreenSettingsPage.h", "LockscreenSettingsPage.cpp",
"LockscreenPlugin.h", "LockscreenPlugin.cpp",
}
for fname in os.listdir(plugin_dir):
if not fname.endswith((".h", ".cpp", ".hpp")):
continue
src = os.path.join(plugin_dir, fname)
dst = os.path.join(settings_dest if fname in settings_files else activities_dest, fname)
shutil.copy2(src, dst)
print(f" ✓ {fname}")
def patch_cross_point_settings_h(repo_dir):
path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path)
if "lockscreenMode" in content:
print(" CrossPointSettings.h already patched, skipping.")
return
content = content.replace(
'#pragma once\n#include <HalStorage.h>',
'#pragma once\n#include <HalStorage.h>\n#include "activities/settings/LockscreenPlugin.h"'
)
content = content.replace(
' uint8_t imageRendering = IMAGES_DISPLAY;',
' uint8_t imageRendering = IMAGES_DISPLAY;\n'
' uint8_t lockscreenMode = 0;\n'
' char lockscreenPinHash[9] = {};'
)
write_file(path, content)
print(" CrossPointSettings.h patched.")
def patch_settings_list_h(repo_dir):
path = find_first("SettingsList.h", repo_dir)
content = read_file(path)
if "lockscreenMode" in content and "lockscreenPinHash" in content:
if '{StrId::STR_NONE_OPT, StrId::STR_NONE_OPT,\n StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}' in content:
content = content.replace(
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT,\n'
' StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n',
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n'
)
write_file(path, content)
print(" SettingsList.h patched (reduced lockscreenMode to 2 options).")
else:
print(" SettingsList.h already patched, skipping.")
return
if "lockscreenMode" in content and "lockscreenPinHash" not in content:
content = content.replace(
' "lockscreenMode", StrId::STR_NONE_OPT),\n',
' "lockscreenMode", StrId::STR_NONE_OPT),\n'
' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.lockscreenPinHash,\n'
' sizeof(SETTINGS.lockscreenPinHash),\n'
' "lockscreenPinHash", StrId::STR_NONE_OPT),\n'
)
write_file(path, content)
print(" SettingsList.h patched (added lockscreenPinHash).")
return
entry = (
'\n'
' SettingInfo::Enum(StrId::STR_NONE_OPT, &CrossPointSettings::lockscreenMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n'
' "lockscreenMode", StrId::STR_NONE_OPT),\n'
' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.lockscreenPinHash,\n'
' sizeof(SETTINGS.lockscreenPinHash),\n'
' "lockscreenPinHash", StrId::STR_NONE_OPT),\n'
)
content = content.replace(
' };\n return list;\n}\n',
entry + ' };\n return list;\n}\n'
)
write_file(path, content)
print(" SettingsList.h patched.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
already_has_plugins_tab = "categoryCount = 5" in content
already_has_plugins_vec = "pluginsSettings" in content
if already_has_plugins_tab and already_has_plugins_vec:
print(" SettingsActivity.h already patched, skipping.")
return
if not already_has_plugins_tab:
content = content.replace(
'static constexpr int categoryCount = 4;',
'static constexpr int categoryCount = 5;'
)
if not already_has_plugins_vec:
content = content.replace(
' std::vector<SettingInfo> systemSettings;',
' std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;'
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if '"LockscreenPlugin.h"' in content and 'lockscreenPinHash' in content and '"lockscreenMode") return "Lockscreen"' in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
changed = False
if '"LockscreenPlugin.h"' not in content:
content = content.replace(
'#include "SettingsList.h"',
'#include "SettingsList.h"\n'
'#include "activities/settings/LockscreenPlugin.h"\n'
'#include "activities/LockscreenActivity.h"'
)
changed = True
if 'STR_NONE_OPT};' not in content:
content = content.replace(
'StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};',
'StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n'
' StrId::STR_NONE_OPT};'
)
changed = True
if 'pluginsSettings.clear()' not in content:
result = content.replace(
' systemSettings.clear();\n',
' systemSettings.clear();\n pluginsSettings.clear();\n'
)
if 'pluginsSettings.clear()' in result:
content = result
changed = True
lockscreen_push = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::lockscreenMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n'
' "lockscreenMode", StrId::STR_NONE_OPT\n'
' ));\n'
)
if '"lockscreenMode", StrId::STR_NONE_OPT' not in content:
for anchor in [
' selectedCategoryIndex = 0;\n selectedSettingIndex = 0;',
' selectedCategoryIndex = 0;',
' currentSettings = &displaySettings;',
]:
if anchor in content:
content = content.replace(anchor, lockscreen_push + anchor, 1)
changed = True
break
if 'case 4:' not in content:
content = content.replace(
' case 3:\n'
' currentSettings = &systemSettings;\n'
' break;\n',
' case 3:\n'
' currentSettings = &systemSettings;\n'
' break;\n'
' case 4:\n'
' currentSettings = &pluginsSettings;\n'
' break;\n'
)
changed = True
if '"Plugins"' not in content:
content = content.replace(
' tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});\n'
' }',
' const char* tabLabel = (i == 4) ? "Plugins" : I18N.get(categoryNames[i]);\n'
' tabs.push_back({tabLabel, selectedCategoryIndex == i});\n'
' }'
)
changed = True
if 'nextCatIdx' not in content:
content = content.replace(
' const auto confirmLabel = (selectedSettingIndex == 0)\n'
' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n'
' : tr(STR_TOGGLE);',
' const int nextCatIdx = (selectedCategoryIndex + 1) % categoryCount;\n'
' const char* nextCatLabel = (nextCatIdx == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIdx]);\n'
' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);'
)
changed = True
if 'selectedCategoryIndex == 4' not in content:
content = content.replace(
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); },',
' [&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
' if (s.key && std::string(s.key) == "lockscreenMode") return "Lockscreen";\n'
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' },'
)
changed = True
elif '"lockscreenMode") return "Lockscreen"' not in content:
import re
result = re.sub(
r'( if \(selectedCategoryIndex == 4\) \{.*?)( \})',
lambda m: m.group(1) + ' if (s.key && std::string(s.key) == "lockscreenMode") return "Lockscreen";\n' + m.group(2),
content,
count=1,
flags=re.DOTALL,
)
if result != content:
content = result
changed = True
if 'LockscreenPlugin::modeName' not in content:
import re
lockscreen_value = (
' if (setting.type == SettingType::ENUM &&\n'
' setting.key && std::string(setting.key) == "lockscreenMode") {\n'
' valueText = LockscreenPlugin::modeName(static_cast<LockscreenMode>(SETTINGS.lockscreenMode));\n'
' } else '
)
result = re.sub(
r'( if \(setting\.type == SettingType::(?:ACTION|TOGGLE|ENUM))',
lockscreen_value + r'\1',
content,
count=1,
)
if result != content:
content = result
changed = True
if 'lockscreenPinHash' not in content:
content = content.replace(
' SETTINGS.saveToFile();\n'
'}\n'
'\n'
'void SettingsActivity::render',
' if (setting.key && std::string(setting.key) == "lockscreenMode") {\n'
' if (SETTINGS.lockscreenMode == 0) {\n'
' memset(SETTINGS.lockscreenPinHash, 0, sizeof(SETTINGS.lockscreenPinHash));\n'
" } else if (SETTINGS.lockscreenPinHash[0] == '\\0') {\n"
' startActivityForResult(\n'
' std::make_unique<LockscreenActivity>(renderer, mappedInput, LockscreenActivity::Purpose::CREATE),\n'
' [](const ActivityResult&) { SETTINGS.saveToFile(); });\n'
' return;\n'
' }\n'
' }\n'
'\n'
' SETTINGS.saveToFile();\n'
'}\n'
'\n'
'void SettingsActivity::render'
)
changed = True
if changed:
write_file(path, content)
print(" SettingsActivity.cpp patched.")
else:
print(" SettingsActivity.cpp already patched, skipping.")
def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path)
if 'lockscreenMode") == 0 || strcmp(s.key, "lockscreenPinHash") == 0)) continue' in content:
print(" CrossPointWebServer.cpp already patched, skipping.")
return
target = (
' if (s.key && strcmp(s.key, "lockscreenMode") == 0) {\n'
' doc["name"] = "Lockscreen";\n'
' doc["category"] = "Plugins";\n'
' }\n'
)
if target in content:
content = content.replace(
target,
' if (s.key && (strcmp(s.key, "lockscreenMode") == 0 || strcmp(s.key, "lockscreenPinHash") == 0)) continue;\n'
)
write_file(path, content)
print(" CrossPointWebServer.cpp patched (lockscreen hidden from WUI).")
return
skip = ' if (s.key && (strcmp(s.key, "lockscreenMode") == 0 || strcmp(s.key, "lockscreenPinHash") == 0)) continue;\n'
# Insert continue right after category line regardless of what follows
anchor = ' doc["category"] = I18N.get(s.category);\n'
if anchor in content:
content = content.replace(anchor, anchor + '\n' + skip, 1)
write_file(path, content)
print(" CrossPointWebServer.cpp patched (lockscreen hidden from WUI).")
def patch_main_cpp(repo_dir):
path = find_first("main.cpp", repo_dir)
content = read_file(path)
already_has_includes = "LockscreenPlugin" in content
already_has_block = "LockscreenActivity lockAct" in content
if already_has_includes and already_has_block:
print(" main.cpp already patched, skipping.")
return
content = content.replace(
'#include "CrossPointSettings.h"',
'#include "CrossPointSettings.h"\n'
'#include "activities/settings/LockscreenPlugin.h"\n'
'#include "activities/LockscreenActivity.h"'
)
lockscreen_block = (
'\n'
' {\n'
' const auto lsMode = static_cast<LockscreenMode>(SETTINGS.lockscreenMode);\n'
' const bool hasPinSet = SETTINGS.lockscreenPinHash[0] != \'\\0\';\n'
' const bool shouldLock = hasPinSet && LockscreenPlugin::shouldLock(lsMode);\n'
'\n'
' if (shouldLock) {\n'
' LockscreenActivity lockAct(renderer, mappedInputManager, LockscreenActivity::Purpose::UNLOCK);\n'
' lockAct.onEnter();\n'
' lockAct.renderDirect();\n'
' while (!lockAct.isDone()) {\n'
' gpio.update();\n'
' lockAct.loop();\n'
' if (lockAct.needsRender()) {\n'
' lockAct.renderDirect();\n'
' }\n'
' delay(10);\n'
' }\n'
' lockAct.onExit();\n'
' renderer.clearScreen();\n'
' renderer.displayBuffer();\n'
' if (!lockAct.wasSuccessful()) {\n'
' enterDeepSleep();\n'
' return;\n'
' }\n'
' }\n'
' }\n'
)
anchor = ' RECENT_BOOKS.loadFromFile();\n\n if (HalSystem::isRebootFromPanic())'
if anchor in content:
content = content.replace(
anchor,
' RECENT_BOOKS.loadFromFile();\n' + lockscreen_block + '\n if (HalSystem::isRebootFromPanic())'
)
else:
idx = content.find('RECENT_BOOKS')
print(' DIAG main.cpp around RECENT_BOOKS:', repr(content[max(0,idx-50):idx+300]))
import re
m = re.search(r'RECENT_BOOKS\.loadFromFile\(\);.*?\n(\s*\n)*(\s*if)', content, re.DOTALL)
if m:
actual_anchor = content[m.start():m.end()]
content = content.replace(actual_anchor, ' RECENT_BOOKS.loadFromFile();\n' + lockscreen_block + '\n' + content[m.end()-len(m.group(2)):m.end()])
print(' main.cpp lockscreen block inserted via regex.')
else:
print(' WARNING: could not find RECENT_BOOKS anchor in main.cpp')
write_file(path, content)
has_block = "LockscreenActivity lockAct" in content
has_sleep = "enterDeepSleep" in content
if has_block:
idx2 = content.find("LockscreenActivity lockAct")
print(" DIAG lockscreen block context:", repr(content[max(0,idx2-200):idx2+50]))
print(f" main.cpp patched. lockscreen_block={has_block} enterDeepSleep={has_sleep}")
def patch_settings_html(repo_dir):
path = find_first("SettingsPage.html", repo_dir)
content = read_file(path)
if "lockscreenPin" in content:
print(" SettingsPage.html already patched, skipping.")
return
lines = [
"",
" <script>",
" (function() {",
" var _orig = loadSettings;",
" loadSettings = async function() {",
" await _orig.apply(this, arguments);",
" hideInternalRows();",
" injectLockscreenPin();",
" };",
"",
" function djb2Hash(str) {",
" var hash = 5381;",
" for (var i = 0; i < str.length; i++) {",
" hash = ((hash << 5) + hash) ^ str.charCodeAt(i);",
" hash = hash >>> 0;",
" }",
" hash = (hash ^ 0xA3F1C2B4) >>> 0;",
' return ("00000000" + hash.toString(16).toUpperCase()).slice(-8);',
" }",
"",
" function hideInternalRows() {",
' allSettings.forEach(function(s) {',
' if (s.hidden) {',
' var el = document.getElementById("setting-" + s.key);',
' if (el) { var row = el.closest(".setting-row"); if (row) row.style.display = "none"; }',
' }',
" });",
" }",
"",
" function injectLockscreenPin() {",
' var modeEl = document.getElementById("setting-lockscreenMode");',
" if (!modeEl) return;",
' var row = modeEl.closest(".setting-row");',
' if (!row || document.getElementById("lockscreen-pin-row")) return;',
' var pinRow = document.createElement("div");',
' pinRow.className = "setting-row";',
' pinRow.id = "lockscreen-pin-row";',
" pinRow.innerHTML =",
' \'<span class="setting-name">New PIN (4 digits)</span>\' +',
' \'<span class="setting-control">\' +',
' \'<input type="password" id="lockscreenPin" maxlength="4" \' +',
' \'pattern="[0-9]{4}" inputmode="numeric" \' +',
' \'placeholder="Leave blank to keep current" \' +',
' \'style="width:200px;" oninput="markChanged()">\' +',
" '</span>';",
" row.parentNode.insertBefore(pinRow, row.nextSibling);",
" updatePinRowVisibility();",
' modeEl.addEventListener("change", updatePinRowVisibility);',
" }",
"",
" function updatePinRowVisibility() {",
' var modeEl = document.getElementById("setting-lockscreenMode");',
' var pinRow = document.getElementById("lockscreen-pin-row");',
" if (!modeEl || !pinRow) return;",
' pinRow.style.display = parseInt(modeEl.value) === 1 ? "" : "none";',
" }",
"",
" var _origSave = saveSettings;",
" saveSettings = async function() {",
' var pinEl = document.getElementById("lockscreenPin");',
' var modeEl = document.getElementById("setting-lockscreenMode");',
" if (pinEl && modeEl) {",
" var pin = pinEl.value.trim();",
" if (parseInt(modeEl.value) === 0) {",
' var s = allSettings.find(function(x) { return x.key === "lockscreenPinHash"; });',
' if (s) { s.value = ""; originalValues["lockscreenPinHash"] = "CLEAR"; }',
" } else if (pin.length === 4 && /^[0-9]{4}$/.test(pin)) {",
" var hash = djb2Hash(pin);",
' var s = allSettings.find(function(x) { return x.key === "lockscreenPinHash"; });',
' if (s) { s.value = hash; originalValues["lockscreenPinHash"] = "CHANGED"; }',
' pinEl.value = "";',
" } else if (pin.length > 0) {",
' document.getElementById("message").textContent = "PIN must be exactly 4 digits.";',
' document.getElementById("message").className = "message error";',
' document.getElementById("message").style.display = "block";',
' setTimeout(function() { document.getElementById("message").style.display = "none"; }, 4000);',
" return;",
" }",
" }",
" await _origSave.apply(this, arguments);",
" };",
" })();",
" </script>",
"",
]
js = "\n".join(lines)
content = content.replace("</body>", js + "</body>")
write_file(path, content)
print(" SettingsPage.html patched.")
def patch(repo_dir: str):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching CrossPointSettings.h...")
patch_cross_point_settings_h(repo_dir)
print(" Patching SettingsList.h...")
patch_settings_list_h(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
print(" Patching CrossPointWebServer.cpp...")
patch_web_server(repo_dir)
print(" Patching main.cpp...")
patch_main_cpp(repo_dir)
print(" Patching SettingsPage.html (WUI PIN entry)...")
patch_settings_html(repo_dir)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+101
View File
@@ -0,0 +1,101 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingsField, PluginsTabEntry, ToggleHook, MainHook,
)
def get_manifest(ctx):
return PluginManifest(
name="lockscreen",
pretty_name="Lockscreen",
source_files=[
SourceFile("LockscreenPlugin.h", "src/activities/settings"),
SourceFile("LockscreenPlugin.cpp", "src/activities/settings"),
SourceFile("LockscreenActivity.h", "src/activities"),
SourceFile("LockscreenActivity.cpp", "src/activities"),
],
includes=[
Include("activities/settings/LockscreenPlugin.h", "cross_point_settings_h"),
Include("activities/settings/LockscreenPlugin.h", "settings_activity_cpp"),
Include("activities/LockscreenActivity.h", "settings_activity_cpp"),
Include("activities/settings/LockscreenPlugin.h", "main_cpp"),
Include("activities/LockscreenActivity.h", "main_cpp"),
],
settings_fields=[
SettingsField("uint8_t lockscreenMode = 0;"),
SettingsField("char lockscreenPinHash[9] = {};"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Lockscreen",
kind="enum",
key="lockscreenMode",
option_labels=["Disabled", "Enabled"],
value_text_expr="LockscreenPlugin::modeName(static_cast<LockscreenMode>(value))",
hidden_from_web=True,
),
PluginsTabEntry(
label="Lockscreen PIN",
kind="string",
key="lockscreenPinHash",
obfuscated=True,
show_on_device=False,
hidden_from_web=True,
),
],
toggle_hooks=[
ToggleHook(
key="lockscreenMode",
code=(
" if (SETTINGS.lockscreenMode == 0) {\n"
" memset(SETTINGS.lockscreenPinHash, 0, sizeof(SETTINGS.lockscreenPinHash));\n"
" } else if (SETTINGS.lockscreenPinHash[0] == '\\0') {\n"
" startActivityForResult(\n"
" std::make_unique<LockscreenActivity>(renderer, mappedInput, LockscreenActivity::Purpose::CREATE),\n"
" [](const ActivityResult&) { SETTINGS.saveToFile(); });\n"
" return;\n"
" }"
),
),
],
main_hooks=[
MainHook(
# Must run after setupDisplayAndFonts(): the lock screen calls
# renderDirect(), and before that point display.begin()/
# renderer.begin() haven't run and no fonts are registered, so
# the PIN prompt would silently draw nothing while still
# unlocking underneath - i.e. an invisible, bypassable lock.
point="post_display_setup",
code=(
" {\n"
" const auto lsMode = static_cast<LockscreenMode>(SETTINGS.lockscreenMode);\n"
" const bool hasPinSet = SETTINGS.lockscreenPinHash[0] != '\\0';\n"
" const bool shouldLock = hasPinSet && LockscreenPlugin::shouldLock(lsMode);\n"
" if (shouldLock) {\n"
" LockscreenActivity lockAct(renderer, mappedInputManager, LockscreenActivity::Purpose::UNLOCK);\n"
" lockAct.onEnter();\n"
" lockAct.renderDirect();\n"
" while (!lockAct.isDone()) {\n"
" gpio.update();\n"
" lockAct.loop();\n"
" if (lockAct.needsRender()) {\n"
" lockAct.renderDirect();\n"
" }\n"
" delay(10);\n"
" }\n"
" lockAct.onExit();\n"
" renderer.clearScreen();\n"
" renderer.displayBuffer();\n"
" if (!lockAct.wasSuccessful()) {\n"
" enterDeepSleep();\n"
" return;\n"
" }\n"
" }\n"
" }"
),
),
],
)
-269
View File
@@ -1,269 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import re
import shutil
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:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def copy_plugin_sources(plugin_dir, repo_dir):
activities_dest = os.path.join(repo_dir, "src", "activities")
os.makedirs(activities_dest, exist_ok=True)
for fname in os.listdir(plugin_dir):
if not fname.endswith((".h", ".cpp", ".hpp")):
continue
src = os.path.join(plugin_dir, fname)
dst = os.path.join(activities_dest, fname)
shutil.copy2(src, dst)
print(f" ✓ {fname}")
def patch_setting_action_enum(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
if "PongGame" in content:
print(" SettingAction enum already patched, skipping.")
return
content = re.sub(r'(\n};)', r'\n PongGame,\1', content, count=1)
write_file(path, content)
print(" SettingAction::PongGame added.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
already_has_tab = "categoryCount = 5" in content
already_has_vec = "pluginsSettings" in content
if already_has_tab and already_has_vec:
print(" SettingsActivity.h already patched, skipping.")
return
if not already_has_tab:
content = content.replace(
"static constexpr int categoryCount = 4;",
"static constexpr int categoryCount = 5;"
)
if not already_has_vec:
content = content.replace(
" std::vector<SettingInfo> systemSettings;",
" std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;"
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if "PongActivity" in content and "SettingAction::PongGame" in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "SettingsList.h"',
'#include "SettingsList.h"\n#include "activities/PongActivity.h"'
)
if "StrId::STR_NONE_OPT};" not in content:
content = content.replace(
"StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};",
"StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n"
" StrId::STR_NONE_OPT};"
)
if "pluginsSettings.clear();" not in content:
result = content.replace(
" systemSettings.clear();\n",
" systemSettings.clear();\n pluginsSettings.clear();\n"
)
if "pluginsSettings.clear();" in result:
content = result
print(" pluginsSettings.clear() inserted.")
else:
print(" WARNING: could not insert pluginsSettings.clear().")
pong_push = (
" pluginsSettings.push_back(SettingInfo::Action(\n"
" StrId::STR_NONE_OPT, SettingAction::PongGame\n"
" ));\n"
)
if "SettingAction::PongGame" not in content:
inserted = False
for anchor in [
" selectedCategoryIndex = 0;\n selectedSettingIndex = 0;",
" selectedCategoryIndex = 0;",
" currentSettings = &displaySettings;",
]:
if anchor in content:
content = content.replace(anchor, pong_push + anchor, 1)
inserted = True
print(" PongGame push_back inserted.")
break
if not inserted:
print(" WARNING: could not insert PongGame push_back.")
if "case 4:\n currentSettings = &pluginsSettings;" not in content:
result = content.replace(
" case 3:\n currentSettings = &systemSettings;\n break;",
" case 3:\n currentSettings = &systemSettings;\n break;\n"
" case 4:\n currentSettings = &pluginsSettings;\n break;"
)
if result != content:
content = result
print(" case 4 inserted.")
else:
print(" WARNING: case 3 anchor not found.")
if '"Plugins"' not in content:
result = content.replace(
" tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});\n"
" }",
' const char* tabLabel = (i == 4) ? "Plugins" : I18N.get(categoryNames[i]);\n'
" tabs.push_back({tabLabel, selectedCategoryIndex == i});\n"
" }"
)
if result != content:
content = result
print(" Plugins tab label inserted.")
else:
print(" WARNING: tab label anchor not found.")
if "nextCatLabel" not in content and "nextCatIdx" not in content:
result = content.replace(
" const auto confirmLabel = (selectedSettingIndex == 0)\n"
" ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n"
" : tr(STR_TOGGLE);",
" const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n"
' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n'
" const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);"
)
if result != content:
content = result
print(" nextCatLabel inserted.")
else:
print(" WARNING: confirmLabel anchor not found.")
pong_label = (
" if (s.type == SettingType::ACTION &&\n"
' s.action == SettingAction::PongGame) return "Pong Game";\n'
)
if "Pong Game" not in content:
if "selectedCategoryIndex == 4" in content:
result = re.sub(
r'( if \(selectedCategoryIndex == 4\) \{.*?)( \})',
lambda m: m.group(1) + pong_label + m.group(2),
content,
count=1,
flags=re.DOTALL,
)
if result != content:
content = result
print(" Pong Game label inserted.")
else:
print(" WARNING: category==4 block found but label insertion failed.")
else:
result = content.replace(
" [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); },",
" [&settings, this](int index) -> std::string {\n"
" if (selectedCategoryIndex == 4) {\n"
" const auto& s = settings[index];\n"
+ pong_label +
" }\n"
" return std::string(I18N.get(settings[index].nameId));\n"
" },"
)
if result != content:
content = result
print(" Pong Game label inserted (new block).")
else:
print(" WARNING: label lambda anchor not found.")
pong_value = (
" if (setting.type == SettingType::ACTION &&\n"
" setting.action == SettingAction::PongGame) {\n"
' valueText = "Launch";\n'
" } else "
)
if '"Launch"' not in content:
result = re.sub(
r'( if \(setting\.type == SettingType::(?:ACTION|TOGGLE|ENUM)\b)',
pong_value + r'\1',
content,
count=1,
)
if result != content:
content = result
print(" Launch value text inserted.")
else:
print(" WARNING: value text anchor not found.")
pong_case = (
" case SettingAction::PongGame:\n"
" startActivityForResult(std::make_unique<PongActivity>(renderer, mappedInput), resultHandler);\n"
" break;\n"
)
if "case SettingAction::PongGame:" not in content:
result = re.sub(
r'( case SettingAction::)',
pong_case + r'\1',
content,
count=1,
)
if result != content:
content = result
print(" PongGame case inserted.")
else:
print(" WARNING: SettingAction case anchor not found.")
write_file(path, content)
print(" SettingsActivity.cpp written.")
def patch(repo_dir: str):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching SettingAction enum...")
patch_setting_action_enum(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+33
View File
@@ -0,0 +1,33 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingActionEnumValue, PluginsTabEntry,
)
def get_manifest(ctx):
return PluginManifest(
name="pong",
pretty_name="Pong",
source_files=[
SourceFile("PongActivity.h", "src/activities"),
SourceFile("PongActivity.cpp", "src/activities"),
],
includes=[
Include("activities/PongActivity.h", "settings_activity_cpp"),
],
setting_actions=[
SettingActionEnumValue("PongGame"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Pong Game",
kind="action",
action_name="PongGame",
action_value_text="Launch",
activity_launch_expr="std::make_unique<PongActivity>(renderer, mappedInput)",
),
],
)
-388
View File
@@ -1,388 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
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:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def copy_plugin_sources(plugin_dir, repo_dir):
dest = os.path.join(repo_dir, "src", "activities", "settings")
os.makedirs(dest, exist_ok=True)
skip = {"SmallerFontsSettingsPage.h", "SmallerFontsSettingsPage.cpp"}
for fname in os.listdir(plugin_dir):
if fname.endswith((".h", ".cpp", ".hpp")) and fname not in skip:
src = os.path.join(plugin_dir, fname)
dst = os.path.join(dest, fname)
shutil.copy2(src, dst)
print(f" ✓ {fname}")
def patch_cross_point_settings_h(repo_dir):
path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path)
if "smallerFontsMode" in content:
print(" CrossPointSettings.h already patched, skipping.")
return
for include_anchor in [
'#pragma once\n#include <HalStorage.h>',
'#pragma once\n\n#include <HalStorage.h>',
'#pragma once',
]:
if include_anchor in content and '"SmallerFontsPlugin.h"' not in content:
content = content.replace(
include_anchor,
include_anchor + '\n#include "activities/settings/SmallerFontsPlugin.h"',
1
)
break
for member_anchor in [
' uint8_t imageRendering = IMAGES_DISPLAY;',
'\tuint8_t imageRendering = IMAGES_DISPLAY;',
'uint8_t imageRendering = IMAGES_DISPLAY;',
]:
if member_anchor in content:
content = content.replace(
member_anchor,
member_anchor + '\n uint8_t smallerFontsMode = 0;',
1
)
break
if "smallerFontsMode" not in content:
sys.exit("ERROR: Could not add smallerFontsMode to CrossPointSettings.h — anchor not found")
write_file(path, content)
print(" CrossPointSettings.h patched.")
def patch_cross_point_settings_cpp(repo_dir):
path = find_first("CrossPointSettings.cpp", repo_dir)
content = read_file(path)
if "resolveReaderFontId" in content:
print(" CrossPointSettings.cpp already patched, skipping.")
return
content = content.replace(
'int CrossPointSettings::getReaderFontId() const {',
'int CrossPointSettings::getReaderFontId() const {\n auto resolve = [this](int baseId) {\n return SmallerFontsPlugin::resolveReaderFontId(baseId, static_cast<SmallerFontsMode>(smallerFontsMode));\n };\n'
)
for name in ["NOTOSERIF_12", "NOTOSERIF_14", "NOTOSERIF_16", "NOTOSERIF_18",
"NOTOSANS_12", "NOTOSANS_14", "NOTOSANS_16", "NOTOSANS_18",
"OPENDYSLEXIC_8", "OPENDYSLEXIC_10", "OPENDYSLEXIC_12", "OPENDYSLEXIC_14",
"BOOKERLY_12"]:
content = content.replace(f'return {name}_FONT_ID;', f'return resolve({name}_FONT_ID);')
write_file(path, content)
print(" CrossPointSettings.cpp patched.")
def patch_settings_list_h(repo_dir):
path = find_first("SettingsList.h", repo_dir)
content = read_file(path)
if "smallerFontsMode" in content:
print(" SettingsList.h already patched, skipping.")
return
entry = (
' SettingInfo::Enum(StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"),\n'
)
content = content.replace(
' };\n return list;\n}\n',
entry + ' };\n return list;\n}\n'
)
write_file(path, content)
print(" SettingsList.h patched.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
already_has_plugins_tab = "categoryCount = 5" in content
already_has_plugins_vec = "pluginsSettings" in content
if already_has_plugins_tab and already_has_plugins_vec:
print(" SettingsActivity.h already patched, skipping.")
return
if '"SmallerFontsPlugin.h"' not in content:
content = content.replace(
'#include <I18n.h>',
'#include <I18n.h>\n#include "SmallerFontsPlugin.h"'
)
if not already_has_plugins_tab:
content = content.replace(
'static constexpr int categoryCount = 4;',
'static constexpr int categoryCount = 5;'
)
if not already_has_plugins_vec:
content = content.replace(
' std::vector<SettingInfo> systemSettings;',
' std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;'
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if "SmallerFontsPlugin::modeName" in content and "smallerFontsMode" in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
if "STR_NONE_OPT" not in content:
content = content.replace(
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};',
'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};'
)
if "pluginsSettings.clear();" not in content:
content = content.replace(
' systemSettings.clear();',
' systemSettings.clear();\n pluginsSettings.clear();'
)
plugins_block = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n'
' ));\n'
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n'
' ));\n'
)
sf_only_block = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n'
' ));\n'
)
broken_old_block = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT,\n'
' &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n'
' "smallerFontsMode"\n'
' ));\n'
)
if broken_old_block in content:
content = content.replace(broken_old_block, sf_only_block)
elif '"darkModeState"' in content and '"smallerFontsMode"' not in content:
content = content.replace(
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n'
' ));\n',
plugins_block
)
elif '"smallerFontsMode"' not in content:
content = content.replace(
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n',
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n'
+ sf_only_block
)
if 'case 4:\n currentSettings = &pluginsSettings;' not in content:
content = content.replace(
' case 3:\n currentSettings = &systemSettings;\n break;',
' case 3:\n currentSettings = &systemSettings;\n break;\n'
' case 4:\n currentSettings = &pluginsSettings;\n break;'
)
if '"Plugins"' not in content:
content = content.replace(
' 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'
' }',
' 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'
' }'
)
if 'nextCatLabel' not in content:
content = content.replace(
' const auto confirmLabel = (selectedSettingIndex == 0)\n'
' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n'
' : tr(STR_TOGGLE);',
' const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n'
' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n'
' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);'
)
if 'selectedCategoryIndex == 4' not in content:
content = content.replace(
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); },',
' [&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
' if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n'
' if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n'
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' },'
)
if "SmallerFontsPlugin::modeName" not in content:
if "DarkModePlugin::stateName" in content:
content = content.replace(
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }',
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }'
)
else:
content = content.replace(
' valueText = I18N.get(setting.enumValues[value]);',
' if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }'
)
write_file(path, content)
print(" SettingsActivity.cpp patched.")
def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path)
if '"Dark Mode"' in content or '"Smaller Fonts"' in content:
print(" CrossPointWebServer.cpp already patched, skipping.")
return
name_overrides = (
'\n'
' if (s.key) {\n'
' if (strcmp(s.key, "darkModeState") == 0) {\n'
' doc["name"] = "Dark Mode";\n'
' doc["category"] = "Plugins";\n'
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' doc["name"] = "Smaller Fonts";\n'
' doc["category"] = "Plugins";\n'
' }\n'
' }\n'
)
content = content.replace(
' doc["category"] = I18N.get(s.category);\n'
'\n'
' switch (s.type) {',
' doc["category"] = I18N.get(s.category);\n'
+ name_overrides +
'\n'
' switch (s.type) {'
)
enum_overrides = (
' if (s.key) {\n'
' if (strcmp(s.key, "darkModeState") == 0) {\n'
' JsonArray opts = doc["options"].to<JsonArray>();\n'
' opts.add("Disabled");\n'
' opts.add("Enabled");\n'
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' JsonArray opts = doc["options"].to<JsonArray>();\n'
' opts.add("Disabled");\n'
' opts.add("Smaller");\n'
' opts.add("Smallest");\n'
' }\n'
' }\n'
)
content = content.replace(
' for (const auto& opt : s.enumValues) {\n'
' options.add(I18N.get(opt));\n'
' }\n'
' break;\n',
' for (const auto& opt : s.enumValues) {\n'
' options.add(I18N.get(opt));\n'
' }\n'
+ enum_overrides +
' break;\n'
)
write_file(path, content)
print(" CrossPointWebServer.cpp patched.")
def patch(repo_dir: str):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching CrossPointSettings.h...")
patch_cross_point_settings_h(repo_dir)
print(" Patching CrossPointSettings.cpp...")
patch_cross_point_settings_cpp(repo_dir)
print(" Patching SettingsList.h...")
patch_settings_list_h(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
print(" Patching CrossPointWebServer.cpp (web UI)...")
patch_web_server(repo_dir)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+86
View File
@@ -0,0 +1,86 @@
import sys
import os
import re
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingsField, PluginsTabEntry,
)
from framework.engine import find_first, read_file, write_file, PatchError
# NOTE: the plugin's own SmallerFontsPlugin.h/.cpp currently implement only
# two real states (Off / Enabled - a single font-size step down), even
# though the settings list historically reserved a 3rd ("Smallest") slot and
# the README describes three tiers. We match what the code actually does
# (2 states) rather than exposing a 3rd option that behaves identically to
# the 2nd - see SmallerFontsPlugin.cpp: resolveReaderFontId() only special-
# cases MODE_OFF vs "anything else". Worth a real 3-tier implementation as a
# follow-up, but that's a feature gap, not part of the install/composability
# fix this framework addresses.
def _wrap_font_resolution(ctx):
"""Wrap every currently-present `return X_FONT_ID;` line in
CrossPointSettings::getReaderFontId() with SmallerFontsPlugin's resolver.
Regex-driven (not a hardcoded font name list) so it picks up whatever
font plugins (e.g. bookerly) already added earlier in the same run -
this plugin has phase=1 specifically so it runs after any font-provider
plugin's phase=0 step."""
path = find_first("CrossPointSettings.cpp", ctx.repo_dir)
content = read_file(path)
if "SmallerFontsPlugin::resolveReaderFontId" in content:
return
func_pattern = re.compile(r"(int CrossPointSettings::getReaderFontId\(\) const \{)")
m = func_pattern.search(content)
if not m:
raise PatchError("CrossPointSettings.cpp: could not find getReaderFontId()")
resolver = (
"\n auto resolve = [this](int baseId) {\n"
" return SmallerFontsPlugin::resolveReaderFontId(baseId, static_cast<SmallerFontsMode>(smallerFontsMode));\n"
" };"
)
content = content[:m.end()] + resolver + content[m.end():]
# Only wrap `return X_FONT_ID;` lines that occur after the point we just
# inserted the resolver (i.e. inside this function), up to its closing brace.
start = content.index(resolver) + len(resolver)
# crude but reliable: this function is the only remaining thing in the
# file after this point that returns *_FONT_ID literals directly.
def _wrap(match):
return f"return resolve({match.group(1)});"
content = content[:start] + re.sub(r"return (\w+_FONT_ID);", _wrap, content[start:])
write_file(path, content)
print(" CrossPointSettings.cpp: wrapped font-id returns with SmallerFontsPlugin::resolveReaderFontId.")
def get_manifest(ctx):
return PluginManifest(
name="smallerfonts",
pretty_name="Smaller Fonts",
phase=1, # after bookerly (phase 0), so its font-id return line exists to be wrapped
source_files=[
SourceFile("SmallerFontsPlugin.h", "src/activities/settings"),
SourceFile("SmallerFontsPlugin.cpp", "src/activities/settings"),
],
includes=[
Include("activities/settings/SmallerFontsPlugin.h", "cross_point_settings_h"),
],
settings_fields=[
SettingsField("uint8_t smallerFontsMode = 0;"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Smaller Fonts",
kind="enum",
key="smallerFontsMode",
option_labels=["Disabled", "Enabled"],
value_text_expr="SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value))",
),
],
post_patch=_wrap_font_resolution,
)
+1
View File
@@ -1,3 +1,4 @@
platformio platformio
pyserial pyserial
freetype-py freetype-py
fonttools
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Ad-hoc test harness (not part of the shipped installer): applies various
plugin subsets against a fresh copy of the crosspoint-reader source and
reports success/failure per combination. Requires a pristine repo template
directory and a scratch working directory as arguments.
"""
import itertools
import os
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from framework.discovery import discover_all, build_selected
from framework.engine import apply, Context, PatchError
PLUGINS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plugins")
def run_case(template_dir, scratch_dir, names):
if os.path.exists(scratch_dir):
shutil.rmtree(scratch_dir)
shutil.copytree(template_dir, scratch_dir)
ctx = Context(repo_dir=scratch_dir, yes_all=True, prompt=False)
selected = build_selected(names, PLUGINS_DIR, ctx)
apply(ctx, selected)
def main():
template_dir = sys.argv[1]
scratch_root = sys.argv[2]
cases_arg = sys.argv[3] if len(sys.argv) > 3 else "all"
all_names = sorted(discover_all(PLUGINS_DIR).keys())
print(f"Discovered plugins: {all_names}")
if cases_arg == "all":
cases = [[n] for n in all_names]
cases.append(all_names)
cases.append(["darkmode", "smallerfonts"])
cases.append(["bookerly", "smallerfonts"])
cases.append(["bookerly"])
cases.append(["githubsync"])
cases.append(["hardcover", "pong"])
cases.append(["lockscreen", "darkmode", "pong"])
else:
cases = [cases_arg.split(",")]
results = []
for names in cases:
label = "+".join(names)
scratch_dir = os.path.join(scratch_root, label.replace("/", "_"))
try:
run_case(template_dir, scratch_dir, names)
print(f"OK {label}")
results.append((label, True, ""))
except PatchError as e:
print(f"FAIL {label}: {e}")
results.append((label, False, str(e)))
except Exception as e:
print(f"ERROR {label}: {type(e).__name__}: {e}")
results.append((label, False, f"{type(e).__name__}: {e}"))
print("\n=== Summary ===")
n_ok = sum(1 for _, ok, _ in results if ok)
for label, ok, msg in results:
print(f"{'OK' if ok else 'FAIL'} {label}" + (f" -- {msg}" if not ok else ""))
print(f"\n{n_ok}/{len(results)} passed")
sys.exit(0 if n_ok == len(results) else 1)
if __name__ == "__main__":
main()