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:
@@ -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.
|
||||
|
||||
> **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
|
||||
|
||||
### Dark Mode
|
||||
@@ -30,10 +35,9 @@ Adds a Smaller Fonts option to the Plugins settings tab and the web interface Se
|
||||
Mode | Effect
|
||||
-----|--------
|
||||
Disabled | No change (default)
|
||||
Smaller | 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)
|
||||
Enabled | Drops the current font size down by one step (e.g. Bookerly 16 → 14)
|
||||
|
||||
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.
|
||||
|
||||
- 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
|
||||
|
||||
Requires the Smaller Fonts plugin to be installed if you want to use Bookerly at sizes below 12pt.
|
||||
- Works with the Smaller Fonts plugin to drop one size step down (e.g. 16 → 14)
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
- Skips files already on the device that haven't changed
|
||||
- Configurable via Settings → Plugins → GitHub Sync (username, personal access token, repo, branch)
|
||||
- Credentials can also be pre-configured during install
|
||||
- Configurable via Settings → Plugins → GitHub Sync (username, personal access token, repo, branch), after flashing
|
||||
|
||||
Requirements:
|
||||
- Active internet connection via WiFi
|
||||
@@ -148,34 +149,47 @@ Note: This script modifies and flashes custom firmware to your device. The autho
|
||||
|
||||
```
|
||||
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/
|
||||
├── darkmode/
|
||||
│ ├── patch.py
|
||||
│ ├── DarkModePlugin.h/.cpp
|
||||
│ └── DarkModeSettingsPage.h/.cpp
|
||||
│ ├── plugin.py
|
||||
│ └── DarkModePlugin.h/.cpp
|
||||
├── smallerfonts/
|
||||
│ ├── patch.py
|
||||
│ ├── SmallerFontsPlugin.h/.cpp
|
||||
│ └── SmallerFontsSettingsPage.h/.cpp
|
||||
│ ├── plugin.py
|
||||
│ └── SmallerFontsPlugin.h/.cpp
|
||||
├── lockscreen/
|
||||
│ ├── patch.py
|
||||
│ ├── plugin.py
|
||||
│ ├── LockscreenPlugin.h/.cpp
|
||||
│ ├── LockscreenSettingsPage.h/.cpp
|
||||
│ └── LockscreenActivity.h/.cpp
|
||||
├── bookerly/
|
||||
│ ├── patch.py
|
||||
│ ├── plugin.py
|
||||
│ └── BookerlyPlugin.h/.cpp
|
||||
├── hardcover/
|
||||
│ ├── patch.py
|
||||
│ ├── plugin.py
|
||||
│ ├── HardcoverPlugin.h/.cpp
|
||||
│ └── HardcoverSyncActivity.h/.cpp
|
||||
└── githubsync/
|
||||
├── patch.py
|
||||
├── GitHubSync.h/.cpp
|
||||
└── GitHubSyncSettingsActivity.h/.cpp
|
||||
├── githubsync/
|
||||
│ ├── plugin.py
|
||||
│ ├── GitHubSync.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
|
||||
|
||||
### Linux: Permission denied when flashing
|
||||
@@ -210,7 +224,9 @@ Restart your terminal and re-run install.py.
|
||||
|
||||
## Adding a Plugin
|
||||
|
||||
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.
|
||||
1. Create a new directory under `plugins/` with your plugin's name.
|
||||
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`.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
from .engine import SelectedPlugin
|
||||
|
||||
|
||||
def discover_all(plugins_dir):
|
||||
"""Return {name: (plugin_dir, get_manifest_callable)} for every plugin
|
||||
directory that has a plugin.py exposing get_manifest(ctx)."""
|
||||
found = {}
|
||||
for name in sorted(os.listdir(plugins_dir)):
|
||||
plugin_dir = os.path.join(plugins_dir, name)
|
||||
manifest_path = os.path.join(plugin_dir, "plugin.py")
|
||||
if not os.path.isdir(plugin_dir) or not os.path.exists(manifest_path):
|
||||
continue
|
||||
spec = importlib.util.spec_from_file_location(f"xteink_plugin_{name}", manifest_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
if not hasattr(module, "get_manifest"):
|
||||
continue
|
||||
found[name] = (plugin_dir, module.get_manifest)
|
||||
return found
|
||||
|
||||
|
||||
def build_selected(names, plugins_dir, ctx):
|
||||
"""Build a deterministic, sorted-by-name list of SelectedPlugin for the
|
||||
given plugin names. Order doesn't affect correctness (the engine derives
|
||||
shared files from the whole set at once) but keeping it stable makes
|
||||
output/diffs reproducible."""
|
||||
found = discover_all(plugins_dir)
|
||||
selected = []
|
||||
for name in sorted(names):
|
||||
if name not in found:
|
||||
raise KeyError(f"Unknown plugin: {name} (available: {', '.join(sorted(found))})")
|
||||
plugin_dir, get_manifest = found[name]
|
||||
manifest = get_manifest(ctx)
|
||||
selected.append(SelectedPlugin(name=name, plugin_dir=plugin_dir, manifest=manifest))
|
||||
return selected
|
||||
@@ -0,0 +1,858 @@
|
||||
"""
|
||||
The composition engine.
|
||||
|
||||
Design principle: every shared file is touched EXACTLY ONCE, in one pass,
|
||||
built from the full set of *currently selected* plugins. Nothing is derived
|
||||
from "is some other plugin's marker text already in this file" - so the
|
||||
result for {darkmode} alone, {smallerfonts} alone, or {darkmode,
|
||||
smallerfonts, hardcover, lockscreen, pong, githubsync, bookerly} together is
|
||||
always internally consistent, because it's always generated fresh from the
|
||||
same selection instead of being poked at incrementally by N independent
|
||||
scripts that each assume the others already ran (or didn't).
|
||||
|
||||
Anchors are matched against the *current* upstream crosspoint-reader source.
|
||||
If an anchor isn't found, we raise PatchError immediately with the file and
|
||||
a snippet of what we were looking for, rather than silently no-op'ing like
|
||||
the old patch.py scripts did (which is how upstream drift + missing plugins
|
||||
produced silently-broken installs before).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from .manifest import PluginManifest
|
||||
|
||||
|
||||
class PatchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectedPlugin:
|
||||
name: str
|
||||
plugin_dir: str
|
||||
manifest: PluginManifest
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
repo_dir: str
|
||||
yes_all: bool = False
|
||||
prompt: bool = True
|
||||
|
||||
|
||||
def read_file(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def write_file(path, content):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def find_first(filename, repo_dir):
|
||||
results = glob.glob(os.path.join(repo_dir, "**", filename), recursive=True)
|
||||
if not results:
|
||||
raise PatchError(f"Could not locate {filename} anywhere under {repo_dir}")
|
||||
return results[0]
|
||||
|
||||
|
||||
def replace_once(content, anchor, replacement, file_label):
|
||||
count = content.count(anchor)
|
||||
if count == 0:
|
||||
raise PatchError(
|
||||
f"{file_label}: expected anchor not found (upstream source may have "
|
||||
f"changed). Anchor was:\n{anchor!r}"
|
||||
)
|
||||
if count > 1:
|
||||
raise PatchError(
|
||||
f"{file_label}: anchor matched {count} times, expected exactly 1 "
|
||||
f"(ambiguous patch target). Anchor was:\n{anchor!r}"
|
||||
)
|
||||
return content.replace(anchor, replacement, 1)
|
||||
|
||||
|
||||
def contains_once(content, anchor, file_label):
|
||||
count = content.count(anchor)
|
||||
if count == 0:
|
||||
raise PatchError(f"{file_label}: expected anchor not found:\n{anchor!r}")
|
||||
if count > 1:
|
||||
raise PatchError(f"{file_label}: anchor matched {count} times, expected exactly 1:\n{anchor!r}")
|
||||
|
||||
|
||||
def uses_plugins_tab(selected: List[SelectedPlugin]) -> bool:
|
||||
return any(sp.manifest.plugins_tab_entries for sp in selected)
|
||||
|
||||
|
||||
def validate_manifests(selected: List[SelectedPlugin]):
|
||||
"""Catch self-inconsistent manifests before touching any file. This is
|
||||
exactly the class of bug the old patch.py scripts were prone to
|
||||
(referencing something that was never declared) - the difference now is
|
||||
it's a single, mechanical check instead of relying on every plugin
|
||||
author to notice by eye. Currently checks: every SettingAction an
|
||||
"action"-kind PluginsTabEntry refers to must have a matching
|
||||
SettingActionEnumValue declared somewhere in the selection (usually the
|
||||
same plugin)."""
|
||||
declared_actions = {a.name for sp in selected for a in sp.manifest.setting_actions}
|
||||
errors = []
|
||||
for sp in selected:
|
||||
for e in sp.manifest.plugins_tab_entries:
|
||||
if e.kind == "action" and e.action_name and e.action_name not in declared_actions:
|
||||
errors.append(
|
||||
f"plugin '{sp.name}' has a PluginsTabEntry with action_name="
|
||||
f"'{e.action_name}' but no matching SettingActionEnumValue('{e.action_name}') "
|
||||
f"is declared in its (or any selected plugin's) setting_actions - the generated "
|
||||
f"code would reference an undeclared SettingAction enum member and fail to compile."
|
||||
)
|
||||
if errors:
|
||||
raise PatchError("Manifest validation failed:\n - " + "\n - ".join(errors))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source file copying
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def copy_source_files(repo_dir, selected: List[SelectedPlugin]):
|
||||
for sp in selected:
|
||||
for sf in sp.manifest.source_files:
|
||||
src = os.path.join(sp.plugin_dir, sf.src_name)
|
||||
if not os.path.exists(src):
|
||||
raise PatchError(f"{sp.name}: declared source file not found: {src}")
|
||||
dest_dir = os.path.join(repo_dir, sf.dest_subdir)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
dst = os.path.join(dest_dir, sf.src_name)
|
||||
shutil.copy2(src, dst)
|
||||
print(f" ✓ [{sp.name}] {sf.src_name}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CrossPointSettings.h (persisted struct fields + includes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_cross_point_settings_h(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("CrossPointSettings.h", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "CrossPointSettings.h"
|
||||
|
||||
includes = [inc.header for sp in selected for inc in sp.manifest.includes
|
||||
if inc.target == "cross_point_settings_h"]
|
||||
fields = [f.decl for sp in selected for f in sp.manifest.settings_fields]
|
||||
|
||||
if not includes and not fields:
|
||||
return
|
||||
|
||||
if includes:
|
||||
include_anchor = "#include <HalStorage.h>"
|
||||
contains_once(content, include_anchor, label)
|
||||
include_block = include_anchor + "".join(f'\n#include "{h}"' for h in includes)
|
||||
content = content.replace(include_anchor, include_block, 1)
|
||||
|
||||
if fields:
|
||||
field_anchor = " uint8_t imageRendering = IMAGES_DISPLAY;"
|
||||
contains_once(content, field_anchor, label)
|
||||
field_block = field_anchor + "".join(f"\n {decl}" for decl in fields)
|
||||
content = content.replace(field_anchor, field_block, 1)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(includes)} include(s), {len(fields)} field(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SettingsList.h (master settings list used for save/load + web API dump)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _settings_list_entry_for_tab_entry(e):
|
||||
"""Build the categoryless SettingsList.h entry for a PluginsTabEntry."""
|
||||
if e.kind == "enum":
|
||||
n = len(e.option_labels) if e.option_labels else 2
|
||||
dummy_opts = ", ".join(["StrId::STR_NONE_OPT"] * n)
|
||||
obf = ".withObfuscated()" if e.obfuscated else ""
|
||||
return (f' SettingInfo::Enum(StrId::STR_NONE_OPT, &CrossPointSettings::{e.key},\n'
|
||||
f' {{{dummy_opts}}}, "{e.key}"){obf},')
|
||||
if e.kind == "toggle":
|
||||
obf = ".withObfuscated()" if e.obfuscated else ""
|
||||
return f' SettingInfo::Toggle(StrId::STR_NONE_OPT, &CrossPointSettings::{e.key}, "{e.key}"){obf},'
|
||||
if e.kind == "string":
|
||||
obf = ".withObfuscated()" if e.obfuscated else ""
|
||||
return (f' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.{e.key},\n'
|
||||
f' sizeof(SETTINGS.{e.key}), "{e.key}"){obf},')
|
||||
return None # "action" kind has no persisted entry
|
||||
|
||||
|
||||
def patch_settings_list_h(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("SettingsList.h", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "SettingsList.h"
|
||||
|
||||
lines = []
|
||||
for sp in selected:
|
||||
for e in sp.manifest.settings_list_entries:
|
||||
lines.append(f" {e.cpp}")
|
||||
for e in sp.manifest.plugins_tab_entries:
|
||||
entry = _settings_list_entry_for_tab_entry(e)
|
||||
if entry:
|
||||
lines.append(entry)
|
||||
|
||||
if not lines:
|
||||
return
|
||||
|
||||
anchor = " };\n // Only show tilt page turn setting when the QMI8658 IMU is present (X3)"
|
||||
contains_once(content, anchor, label)
|
||||
block = "\n".join(lines) + "\n };\n // Only show tilt page turn setting when the QMI8658 IMU is present (X3)"
|
||||
content = content.replace(anchor, block, 1)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(lines)} entrie(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SettingsActivity.h (SettingAction enum, categoryCount, pluginsSettings vec)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_settings_activity_h(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("SettingsActivity.h", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "SettingsActivity.h"
|
||||
|
||||
includes = [inc.header for sp in selected for inc in sp.manifest.includes
|
||||
if inc.target == "settings_activity_h"]
|
||||
if includes:
|
||||
include_anchor = "#include <I18n.h>"
|
||||
contains_once(content, include_anchor, label)
|
||||
content = content.replace(include_anchor,
|
||||
include_anchor + "".join(f'\n#include "{h}"' for h in includes), 1)
|
||||
|
||||
actions = [a.name for sp in selected for a in sp.manifest.setting_actions]
|
||||
if actions:
|
||||
m = re.search(r"enum class SettingAction \{.*?\n(\};)", content, re.DOTALL)
|
||||
if not m:
|
||||
raise PatchError(f"{label}: could not locate `enum class SettingAction {{ ... }};` block")
|
||||
insertion = "".join(f" {name},\n" for name in actions)
|
||||
content = content[:m.start(1)] + insertion + content[m.start(1):]
|
||||
|
||||
if uses_plugins_tab(selected):
|
||||
cat_anchor = "static constexpr int categoryCount = 4;"
|
||||
contains_once(content, cat_anchor, label)
|
||||
content = content.replace(cat_anchor, "static constexpr int categoryCount = 5;", 1)
|
||||
|
||||
vec_anchor = " std::vector<SettingInfo> systemSettings;"
|
||||
contains_once(content, vec_anchor, label)
|
||||
content = content.replace(vec_anchor,
|
||||
vec_anchor + "\n std::vector<SettingInfo> pluginsSettings;", 1)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(includes)} include(s), {len(actions)} action(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SettingsActivity.cpp (the big one: tab, list population, labels, values,
|
||||
# switch-case action dispatch)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_settings_activity_cpp(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("SettingsActivity.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "SettingsActivity.cpp"
|
||||
|
||||
includes = [inc.header for sp in selected for inc in sp.manifest.includes
|
||||
if inc.target == "settings_activity_cpp"]
|
||||
if includes:
|
||||
anchor = '#include "SettingsList.h"'
|
||||
contains_once(content, anchor, label)
|
||||
content = content.replace(anchor, anchor + "".join(f'\n#include "{h}"' for h in includes), 1)
|
||||
|
||||
tab_entries = [(sp, e) for sp in selected for e in sp.manifest.plugins_tab_entries]
|
||||
|
||||
if uses_plugins_tab(selected):
|
||||
# 1) categoryNames array gains a virtual "Plugins" slot (StrId::STR_NONE_OPT).
|
||||
cat_anchor = (
|
||||
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
|
||||
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};'
|
||||
)
|
||||
cat_replacement = (
|
||||
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
|
||||
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n'
|
||||
' StrId::STR_NONE_OPT};'
|
||||
)
|
||||
content = replace_once(content, cat_anchor, cat_replacement, label)
|
||||
|
||||
# 2) rebuildSettingsLists(): clear the plugins vector too.
|
||||
clear_anchor = " systemSettings.clear();"
|
||||
content = replace_once(content, clear_anchor, clear_anchor + "\n pluginsSettings.clear();", label)
|
||||
|
||||
# 3) Populate pluginsSettings once, from every selected entry that
|
||||
# shows on-device (all "action" entries; enum/toggle entries
|
||||
# unless explicitly hidden).
|
||||
push_lines = []
|
||||
for sp, e in tab_entries:
|
||||
if e.kind == "action":
|
||||
push_lines.append(
|
||||
" pluginsSettings.push_back(SettingInfo::Action(\n"
|
||||
f" StrId::STR_NONE_OPT, SettingAction::{e.action_name}\n"
|
||||
" ));"
|
||||
)
|
||||
elif e.kind in ("enum", "toggle") and e.show_on_device:
|
||||
n = len(e.option_labels) if e.option_labels else 2
|
||||
dummy_opts = ", ".join(["StrId::STR_NONE_OPT"] * n)
|
||||
if e.kind == "enum":
|
||||
push_lines.append(
|
||||
" pluginsSettings.push_back(SettingInfo::Enum(\n"
|
||||
f" StrId::STR_NONE_OPT, &CrossPointSettings::{e.key},\n"
|
||||
f" {{{dummy_opts}}}, \"{e.key}\", StrId::STR_NONE_OPT\n"
|
||||
" ));"
|
||||
)
|
||||
else:
|
||||
push_lines.append(
|
||||
" pluginsSettings.push_back(SettingInfo::Toggle(\n"
|
||||
f" StrId::STR_NONE_OPT, &CrossPointSettings::{e.key}, \"{e.key}\"\n"
|
||||
" ));"
|
||||
)
|
||||
populate_anchor = (
|
||||
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));'
|
||||
)
|
||||
content = replace_once(content, populate_anchor,
|
||||
populate_anchor + ("\n" + "\n".join(push_lines) if push_lines else ""), label)
|
||||
|
||||
# 4) The two `switch (selectedCategoryIndex) { case 0: ... case 3: ... }`
|
||||
# blocks (rebuildSettingsLists + loop()'s hasChangedCategory branch)
|
||||
# both need a case 4. Match structurally so indentation differences
|
||||
# between the two occurrences don't matter.
|
||||
case_pattern = re.compile(
|
||||
r"([ \t]*)case 3:\n([ \t]*)currentSettings = &systemSettings;\n[ \t]*break;\n"
|
||||
)
|
||||
|
||||
def _add_case4(m):
|
||||
case_indent, body_indent = m.group(1), m.group(2)
|
||||
return m.group(0) + f"{case_indent}case 4:\n{body_indent}currentSettings = &pluginsSettings;\n{body_indent}break;\n"
|
||||
|
||||
new_content, n_subs = case_pattern.subn(_add_case4, content)
|
||||
if n_subs != 2:
|
||||
raise PatchError(f"{label}: expected 2 `case 3:` switch blocks, found {n_subs}")
|
||||
content = new_content
|
||||
|
||||
# 5) Tab bar label: index 4 renders literally as "Plugins".
|
||||
tabs_anchor = (
|
||||
' std::vector<TabInfo> tabs;\n'
|
||||
' tabs.reserve(categoryCount);\n'
|
||||
' for (int i = 0; i < categoryCount; i++) {\n'
|
||||
' tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});\n'
|
||||
' }'
|
||||
)
|
||||
tabs_replacement = (
|
||||
' std::vector<TabInfo> tabs;\n'
|
||||
' tabs.reserve(categoryCount);\n'
|
||||
' for (int i = 0; i < categoryCount; i++) {\n'
|
||||
' const char* tabLabel = (i == 4) ? "Plugins" : I18N.get(categoryNames[i]);\n'
|
||||
' tabs.push_back({tabLabel, selectedCategoryIndex == i});\n'
|
||||
' }'
|
||||
)
|
||||
content = replace_once(content, tabs_anchor, tabs_replacement, label)
|
||||
|
||||
# 6) confirmLabel / "next category" hint also needs to say "Plugins".
|
||||
confirm_anchor = (
|
||||
' const auto confirmLabel =\n'
|
||||
' (selectedSettingIndex == 0)\n'
|
||||
' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n'
|
||||
' : (selectedSettingIndex > 0 && (*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP\n'
|
||||
' ? tr(STR_SELECT)\n'
|
||||
' : tr(STR_TOGGLE));'
|
||||
)
|
||||
confirm_replacement = (
|
||||
' const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n'
|
||||
' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n'
|
||||
' const auto confirmLabel =\n'
|
||||
' (selectedSettingIndex == 0)\n'
|
||||
' ? nextCatLabel\n'
|
||||
' : (selectedSettingIndex > 0 && (*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP\n'
|
||||
' ? tr(STR_SELECT)\n'
|
||||
' : tr(STR_TOGGLE));'
|
||||
)
|
||||
content = replace_once(content, confirm_anchor, confirm_replacement, label)
|
||||
|
||||
# 7) Row label lambda: plugin rows have nameId == STR_NONE_OPT, so they
|
||||
# need their real label supplied out-of-band.
|
||||
label_lines = []
|
||||
for sp, e in tab_entries:
|
||||
if e.kind == "action":
|
||||
label_lines.append(
|
||||
f' if (s.type == SettingType::ACTION && s.action == SettingAction::{e.action_name}) '
|
||||
f'return "{e.label}";'
|
||||
)
|
||||
elif e.key:
|
||||
label_lines.append(
|
||||
f' if (s.key && std::string(s.key) == "{e.key}") return "{e.label}";'
|
||||
)
|
||||
label_anchor = (
|
||||
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,'
|
||||
)
|
||||
label_replacement = (
|
||||
' [&settings, this](int index) -> std::string {\n'
|
||||
' if (selectedCategoryIndex == 4) {\n'
|
||||
' const auto& s = settings[index];\n'
|
||||
+ "\n".join(label_lines) + "\n"
|
||||
' }\n'
|
||||
' return std::string(I18N.get(settings[index].nameId));\n'
|
||||
' }, nullptr, nullptr,'
|
||||
)
|
||||
content = replace_once(content, label_anchor, label_replacement, label)
|
||||
|
||||
# 8) Value text: plugin-owned enum keys and action rows get custom
|
||||
# rendering; anything else falls through to the original chain
|
||||
# completely unchanged.
|
||||
value_branches = []
|
||||
for sp, e in tab_entries:
|
||||
if e.kind == "enum" and e.value_text_expr:
|
||||
value_branches.append(
|
||||
f' if (setting.key && std::string(setting.key) == "{e.key}") {{\n'
|
||||
f' const uint8_t value = SETTINGS.*(setting.valuePtr);\n'
|
||||
f' valueText = {e.value_text_expr};\n'
|
||||
f' }} else '
|
||||
)
|
||||
elif e.kind == "action":
|
||||
value_branches.append(
|
||||
f' if (setting.type == SettingType::ACTION && setting.action == SettingAction::{e.action_name}) {{\n'
|
||||
f' valueText = "{e.action_value_text}";\n'
|
||||
f' }} else '
|
||||
)
|
||||
if value_branches:
|
||||
value_anchor = ' if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {'
|
||||
value_replacement = "".join(value_branches) + value_anchor
|
||||
content = replace_once(content, value_anchor, value_replacement, label)
|
||||
|
||||
# 9) switch (setting.action) { ... } dispatch: launch the activity.
|
||||
case_lines = []
|
||||
for sp, e in tab_entries:
|
||||
if e.kind == "action" and e.activity_launch_expr:
|
||||
case_lines.append(
|
||||
f" case SettingAction::{e.action_name}:\n"
|
||||
f" startActivityForResult({e.activity_launch_expr}, resultHandler);\n"
|
||||
f" break;"
|
||||
)
|
||||
if case_lines:
|
||||
none_anchor = " case SettingAction::None:\n // Do nothing\n break;"
|
||||
content = replace_once(content, none_anchor, "\n".join(case_lines) + "\n" + none_anchor, label)
|
||||
|
||||
overrides = [o for sp in selected for o in sp.manifest.enum_value_overrides]
|
||||
if overrides:
|
||||
enum_anchor = (
|
||||
' } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {\n'
|
||||
' const uint8_t value = SETTINGS.*(setting.valuePtr);\n'
|
||||
' valueText = I18N.get(setting.enumValues[value]);\n'
|
||||
' } else if (setting.type == SettingType::ENUM && setting.valueGetter) {'
|
||||
)
|
||||
by_key = {}
|
||||
for o in overrides:
|
||||
by_key.setdefault(o.key, []).append(o)
|
||||
branches = ""
|
||||
for key, ovs in by_key.items():
|
||||
for i, o in enumerate(ovs):
|
||||
kw = "if" if i == 0 else "} else if"
|
||||
branches += (f' {kw} (setting.key && std::string(setting.key) == "{key}" && {o.condition_expr}) {{\n'
|
||||
f' valueText = "{o.text}";\n')
|
||||
branches += " } else {\n valueText = I18N.get(setting.enumValues[value]);\n }\n"
|
||||
enum_replacement = (
|
||||
' } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {\n'
|
||||
' const uint8_t value = SETTINGS.*(setting.valuePtr);\n'
|
||||
+ branches +
|
||||
' } else if (setting.type == SettingType::ENUM && setting.valueGetter) {'
|
||||
)
|
||||
content = replace_once(content, enum_anchor, enum_replacement, label)
|
||||
|
||||
toggle_hooks = [h for sp in selected for h in sp.manifest.toggle_hooks]
|
||||
if toggle_hooks:
|
||||
tail_anchor = (
|
||||
" syncQuickResumeTimeoutForSleepScreen(sleepScreenChanged, quickResumeTimeoutChanged);\n"
|
||||
" SETTINGS.saveToFile();\n"
|
||||
" rebuildSettingsLists();\n"
|
||||
" selectedSettingIndex = std::min(selectedSettingIndex, settingsCount);\n"
|
||||
"}"
|
||||
)
|
||||
hook_block = ""
|
||||
for h in toggle_hooks:
|
||||
hook_block += f' if (setting.key && std::string(setting.key) == "{h.key}") {{\n{h.code}\n }}\n'
|
||||
content = replace_once(content, tail_anchor, hook_block + tail_anchor, label)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(tab_entries)} Plugins-tab row(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CrossPointWebServer.cpp (web UI name/category + enum option overrides)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_web_server(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("CrossPointWebServer.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "CrossPointWebServer.cpp"
|
||||
|
||||
keyed_entries = []
|
||||
for sp in selected:
|
||||
for e in sp.manifest.plugins_tab_entries:
|
||||
if e.key:
|
||||
keyed_entries.append(e)
|
||||
# settings_list_entries with no PluginsTabEntry counterpart (e.g. a
|
||||
# plugin might declare a raw entry directly) don't get name overrides
|
||||
# automatically since we don't know their key/label here - plugins
|
||||
# should use PluginsTabEntry (with show_on_device=False) for that.
|
||||
|
||||
appends = [a for sp in selected for a in sp.manifest.web_option_appends]
|
||||
|
||||
if not keyed_entries and not appends:
|
||||
return
|
||||
|
||||
hidden = [e.key for e in keyed_entries if e.hidden_from_web]
|
||||
named = [e for e in keyed_entries if not e.hidden_from_web]
|
||||
|
||||
if hidden:
|
||||
hide_cond = " || ".join(f'strcmp(s.key, "{k}") == 0' for k in hidden)
|
||||
skip_line = f' if (s.key && ({hide_cond})) continue;\n'
|
||||
else:
|
||||
skip_line = ""
|
||||
|
||||
if named:
|
||||
name_block = " if (s.key) {\n"
|
||||
for i, e in enumerate(named):
|
||||
kw = "if" if i == 0 else "} else if"
|
||||
name_block += f' {kw} (strcmp(s.key, "{e.key}") == 0) {{\n'
|
||||
name_block += f' doc["name"] = "{e.label}";\n'
|
||||
name_block += ' doc["category"] = "Plugins";\n'
|
||||
name_block += " }\n }\n"
|
||||
else:
|
||||
name_block = ""
|
||||
|
||||
if skip_line or name_block:
|
||||
anchor = ' doc["category"] = I18N.get(s.category);\n\n switch (s.type) {'
|
||||
contains_once(content, anchor, label)
|
||||
replacement = ' doc["category"] = I18N.get(s.category);\n\n' + skip_line + name_block + '\n switch (s.type) {'
|
||||
content = content.replace(anchor, replacement, 1)
|
||||
|
||||
enum_entries = [e for e in named if e.kind == "enum" and e.option_labels]
|
||||
if enum_entries:
|
||||
opt_block = ""
|
||||
for i, e in enumerate(enum_entries):
|
||||
kw = "if" if i == 0 else "} else if"
|
||||
opt_block += f' {kw} (strcmp(s.key, "{e.key}") == 0) {{\n'
|
||||
opt_block += ' JsonArray opts = doc["options"].to<JsonArray>();\n'
|
||||
for lbl in e.option_labels:
|
||||
opt_block += f' opts.add("{lbl}");\n'
|
||||
opt_block += " }\n"
|
||||
opt_wrapped = " if (s.key) {\n" + opt_block + " }\n"
|
||||
|
||||
enum_anchor = (
|
||||
' for (const auto& opt : s.enumValues) {\n'
|
||||
' options.add(I18N.get(opt));\n'
|
||||
' }\n'
|
||||
' }\n'
|
||||
' break;\n'
|
||||
)
|
||||
contains_once(content, enum_anchor, label)
|
||||
content = content.replace(enum_anchor, enum_anchor[:-len(' break;\n')] + opt_wrapped + ' break;\n', 1)
|
||||
|
||||
if appends:
|
||||
by_key = {}
|
||||
for a in appends:
|
||||
by_key.setdefault(a.key, []).append(a.label)
|
||||
append_block = " if (s.key) {\n"
|
||||
for key, labels in by_key.items():
|
||||
append_block += f' if (strcmp(s.key, "{key}") == 0) {{\n'
|
||||
for lbl in labels:
|
||||
append_block += f' options.add("{lbl}");\n'
|
||||
append_block += " }\n"
|
||||
append_block += " }\n"
|
||||
|
||||
loop_anchor = (
|
||||
' for (const auto& opt : s.enumValues) {\n'
|
||||
' options.add(I18N.get(opt));\n'
|
||||
' }\n'
|
||||
' }\n'
|
||||
)
|
||||
contains_once(content, loop_anchor, label)
|
||||
content = content.replace(loop_anchor, loop_anchor + append_block, 1)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(keyed_entries)} key(s), {len(hidden)} hidden, {len(appends)} option append(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main.cpp (early_boot / post_boot hooks, includes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_main_cpp(repo_dir, selected: List[SelectedPlugin]):
|
||||
path = find_first("main.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "main.cpp"
|
||||
|
||||
includes = [inc.header for sp in selected for inc in sp.manifest.includes if inc.target == "main_cpp"]
|
||||
if includes:
|
||||
anchor = '#include "CrossPointSettings.h"'
|
||||
contains_once(content, anchor, label)
|
||||
content = content.replace(anchor, anchor + "".join(f'\n#include "{h}"' for h in includes), 1)
|
||||
|
||||
early_hooks = [h.code for sp in selected for h in sp.manifest.main_hooks if h.point == "early_boot"]
|
||||
if early_hooks:
|
||||
anchor = " RECENT_BOOKS.loadFromFile();\n I18N.setLanguage(static_cast<Language>(SETTINGS.language));"
|
||||
contains_once(content, anchor, label)
|
||||
block = " RECENT_BOOKS.loadFromFile();\n" + "\n".join(early_hooks) + \
|
||||
"\n I18N.setLanguage(static_cast<Language>(SETTINGS.language));"
|
||||
content = content.replace(anchor, block, 1)
|
||||
|
||||
display_hooks = [h.code for sp in selected for h in sp.manifest.main_hooks if h.point == "post_display_setup"]
|
||||
if display_hooks:
|
||||
anchor = " setupDisplayAndFonts(resume != BootResume::Splash);\n\n switch (resume) {"
|
||||
contains_once(content, anchor, label)
|
||||
block = " setupDisplayAndFonts(resume != BootResume::Splash);\n\n" + \
|
||||
"\n".join(display_hooks) + "\n\n switch (resume) {"
|
||||
content = content.replace(anchor, block, 1)
|
||||
|
||||
post_hooks = [h.code for sp in selected for h in sp.manifest.main_hooks if h.point == "post_boot"]
|
||||
if post_hooks:
|
||||
anchor = " // Ensure we're not still holding the power button before leaving setup\n waitForPowerRelease();"
|
||||
contains_once(content, anchor, label)
|
||||
block = "\n".join(post_hooks) + "\n\n" + \
|
||||
" // Ensure we're not still holding the power button before leaving setup\n waitForPowerRelease();"
|
||||
content = content.replace(anchor, block, 1)
|
||||
|
||||
write_file(path, content)
|
||||
print(f" {label} patched ({len(includes)} include(s), {len(early_hooks)} early hook(s), "
|
||||
f"{len(display_hooks)} post-display hook(s), {len(post_hooks)} post hook(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reader dark-mode-style invert hook (Epub/Txt/Xtc)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_reader_invert_hooks(repo_dir, selected: List[SelectedPlugin]):
|
||||
hooks = [sp.manifest.reader_invert_hook for sp in selected if sp.manifest.reader_invert_hook]
|
||||
if not hooks:
|
||||
return
|
||||
if len(hooks) > 1:
|
||||
raise PatchError("More than one plugin declared a reader_invert_hook - only one is supported at a time.")
|
||||
hook = hooks[0]
|
||||
predicate = hook.predicate_expr
|
||||
|
||||
# --- EpubReaderActivity.cpp ---
|
||||
path = find_first("EpubReaderActivity.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "EpubReaderActivity.cpp"
|
||||
|
||||
inc_anchor = '#include "EpubReaderActivity.h"'
|
||||
contains_once(content, inc_anchor, label)
|
||||
content = content.replace(inc_anchor, inc_anchor + f'\n#include "{hook.include_header}"', 1)
|
||||
|
||||
main_anchor = (
|
||||
' page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);\n'
|
||||
' renderStatusBar();\n'
|
||||
' const auto tBwRender = millis();'
|
||||
)
|
||||
main_replacement = (
|
||||
' page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);\n'
|
||||
' renderStatusBar();\n'
|
||||
f' if ({predicate}) renderer.invertScreen();\n'
|
||||
' const auto tBwRender = millis();'
|
||||
)
|
||||
content = replace_once(content, main_anchor, main_replacement, label)
|
||||
|
||||
image_anchor = (
|
||||
' int16_t imgX, imgY, imgW, imgH;\n'
|
||||
' if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {\n'
|
||||
' renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false);\n'
|
||||
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
|
||||
'\n'
|
||||
' // Re-render page content to restore images into the blanked area\n'
|
||||
' // Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)\n'
|
||||
' page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);\n'
|
||||
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
|
||||
' } else {\n'
|
||||
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
|
||||
' }'
|
||||
)
|
||||
image_replacement = (
|
||||
' int16_t imgX, imgY, imgW, imgH;\n'
|
||||
' if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {\n'
|
||||
f' const bool pluginDarkMode = ({predicate});\n'
|
||||
' renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, pluginDarkMode);\n'
|
||||
' if (pluginDarkMode) renderer.invertScreen();\n'
|
||||
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
|
||||
'\n'
|
||||
' // Re-render page content to restore images into the blanked area\n'
|
||||
' // Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)\n'
|
||||
' page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);\n'
|
||||
' if (pluginDarkMode) renderer.invertScreen();\n'
|
||||
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
|
||||
' } else {\n'
|
||||
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
|
||||
' }'
|
||||
)
|
||||
content = replace_once(content, image_anchor, image_replacement, label)
|
||||
write_file(path, content)
|
||||
print(f" {label} patched (invert hook).")
|
||||
|
||||
# --- TxtReaderActivity.cpp ---
|
||||
path = find_first("TxtReaderActivity.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "TxtReaderActivity.cpp"
|
||||
|
||||
inc_anchor = '#include "TxtReaderActivity.h"'
|
||||
contains_once(content, inc_anchor, label)
|
||||
content = content.replace(inc_anchor, inc_anchor + f'\n#include "{hook.include_header}"', 1)
|
||||
|
||||
txt_anchor = (
|
||||
' // BW rendering\n'
|
||||
' renderLines();\n'
|
||||
' renderStatusBar();\n'
|
||||
'\n'
|
||||
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);'
|
||||
)
|
||||
txt_replacement = (
|
||||
' // BW rendering\n'
|
||||
' renderLines();\n'
|
||||
' renderStatusBar();\n'
|
||||
f' if ({predicate}) renderer.invertScreen();\n'
|
||||
'\n'
|
||||
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);'
|
||||
)
|
||||
content = replace_once(content, txt_anchor, txt_replacement, label)
|
||||
write_file(path, content)
|
||||
print(f" {label} patched (invert hook).")
|
||||
|
||||
# --- XtcReaderActivity.cpp (1-bit path only; see README note on the
|
||||
# 2-bit grayscale pipeline) ---
|
||||
path = find_first("XtcReaderActivity.cpp", repo_dir)
|
||||
content = read_file(path)
|
||||
label = "XtcReaderActivity.cpp"
|
||||
|
||||
inc_anchor = '#include "XtcReaderActivity.h"'
|
||||
contains_once(content, inc_anchor, label)
|
||||
content = content.replace(inc_anchor, inc_anchor + f'\n#include "{hook.include_header}"', 1)
|
||||
|
||||
xtc_anchor = (
|
||||
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);\n'
|
||||
'\n'
|
||||
' LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth);'
|
||||
)
|
||||
xtc_replacement = (
|
||||
f' if ({predicate}) renderer.invertScreen();\n'
|
||||
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);\n'
|
||||
'\n'
|
||||
' LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth);'
|
||||
)
|
||||
content = replace_once(content, xtc_anchor, xtc_replacement, label)
|
||||
write_file(path, content)
|
||||
print(f" {label} patched (invert hook, 1-bit path only).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# platformio.ini / translation files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def patch_platformio_ini(repo_dir, selected: List[SelectedPlugin]):
|
||||
flags = []
|
||||
for sp in selected:
|
||||
for f in sp.manifest.platformio_flags:
|
||||
if f.line not in flags:
|
||||
flags.append(f.line)
|
||||
if not flags:
|
||||
return
|
||||
|
||||
path = os.path.join(repo_dir, "platformio.ini")
|
||||
if not os.path.exists(path):
|
||||
raise PatchError("platformio.ini not found")
|
||||
content = read_file(path)
|
||||
if all(f in content for f in flags):
|
||||
return
|
||||
|
||||
anchor = "[env:default]\nextends = base\nbuild_flags =\n"
|
||||
if anchor not in content:
|
||||
raise PatchError("platformio.ini: could not find [env:default] build_flags anchor")
|
||||
addition = "".join(f" {f}\n" for f in flags if f not in content)
|
||||
content = content.replace(anchor, anchor + addition, 1)
|
||||
write_file(path, content)
|
||||
print(f" platformio.ini patched ({len(flags)} flag(s)).")
|
||||
|
||||
|
||||
def patch_translation_files(repo_dir, selected: List[SelectedPlugin]):
|
||||
entries = [e for sp in selected for e in sp.manifest.translation_entries]
|
||||
if not entries:
|
||||
return
|
||||
|
||||
yaml_dir = os.path.join(repo_dir, "lib", "I18n", "translations")
|
||||
if not os.path.isdir(yaml_dir):
|
||||
raise PatchError(f"translation directory not found: {yaml_dir}")
|
||||
|
||||
yaml_files = glob.glob(os.path.join(yaml_dir, "*.yaml"))
|
||||
if not yaml_files:
|
||||
raise PatchError("no translation YAML files found")
|
||||
|
||||
patched = 0
|
||||
for yf in yaml_files:
|
||||
content = read_file(yf)
|
||||
lines = content.splitlines(keepends=True)
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
new_lines.append(line)
|
||||
for e in entries:
|
||||
if line.startswith(e.after_key + ":") and f'{e.key}:' not in content:
|
||||
new_lines.append(f'{e.key}: "{e.value}"\n')
|
||||
write_file(yf, "".join(new_lines))
|
||||
patched += 1
|
||||
print(f" Translation files patched ({patched} file(s)).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply(ctx: Context, selected: List[SelectedPlugin]):
|
||||
"""Apply every selected plugin's contributions to repo_dir in one
|
||||
consistent pass. `selected` should already be in a stable, deterministic
|
||||
order (install.py sorts by plugin name)."""
|
||||
repo_dir = ctx.repo_dir
|
||||
|
||||
validate_manifests(selected)
|
||||
|
||||
ordered_pre = sorted(selected, key=lambda sp: (sp.manifest.phase, sp.name))
|
||||
for sp in ordered_pre:
|
||||
if sp.manifest.pre_patch:
|
||||
print(f" [{sp.name}] running pre-patch step...")
|
||||
sp.manifest.pre_patch(ctx)
|
||||
|
||||
print(" Copying plugin source files...")
|
||||
copy_source_files(repo_dir, selected)
|
||||
|
||||
print(" Patching CrossPointSettings.h...")
|
||||
patch_cross_point_settings_h(repo_dir, selected)
|
||||
|
||||
print(" Patching SettingsList.h...")
|
||||
patch_settings_list_h(repo_dir, selected)
|
||||
|
||||
print(" Patching SettingsActivity.h...")
|
||||
patch_settings_activity_h(repo_dir, selected)
|
||||
|
||||
print(" Patching SettingsActivity.cpp...")
|
||||
patch_settings_activity_cpp(repo_dir, selected)
|
||||
|
||||
print(" Patching CrossPointWebServer.cpp...")
|
||||
patch_web_server(repo_dir, selected)
|
||||
|
||||
print(" Patching main.cpp...")
|
||||
patch_main_cpp(repo_dir, selected)
|
||||
|
||||
print(" Patching reader activities (invert hook)...")
|
||||
patch_reader_invert_hooks(repo_dir, selected)
|
||||
|
||||
print(" Patching platformio.ini...")
|
||||
patch_platformio_ini(repo_dir, selected)
|
||||
|
||||
print(" Patching translation files...")
|
||||
patch_translation_files(repo_dir, selected)
|
||||
|
||||
ordered_post = sorted(selected, key=lambda sp: (sp.manifest.phase, sp.name))
|
||||
for sp in ordered_post:
|
||||
if sp.manifest.post_patch:
|
||||
print(f" [{sp.name}] running post-patch step...")
|
||||
sp.manifest.post_patch(ctx)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Declarative contribution model for xteink plugins.
|
||||
|
||||
The old design had every plugin's patch.py directly rewrite shared CrossPoint
|
||||
source files with hand-written string/regex surgery, and each plugin's patch
|
||||
script *also* hardcoded literal snippets belonging to other plugins (e.g.
|
||||
darkmode's patch.py contained the exact text of the smallerfonts plugin's
|
||||
settings entry, and referenced SmallerFontsPlugin/CrossPointSettings::
|
||||
smallerFontsMode directly) so that things would still line up if both were
|
||||
installed together. That makes every plugin's correctness depend on which
|
||||
*other* plugins happen to be installed and in what order their patches ran -
|
||||
which is exactly why installing a subset (or adding a new plugin) breaks
|
||||
things.
|
||||
|
||||
The fix: plugins no longer touch shared files at all. They describe *what*
|
||||
they need in a shared UI/struct/behaviour via small declarative objects
|
||||
below. A single engine (framework/engine.py) collects the contributions of
|
||||
whichever plugins were actually selected, and applies each shared file
|
||||
exactly once, deriving the whole "Plugins" tab / settings struct / web UI
|
||||
from the current selection instead of guessing at a partially-patched file's
|
||||
history.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceFile:
|
||||
"""A plugin-owned .h/.cpp file to copy into the firmware tree."""
|
||||
src_name: str
|
||||
dest_subdir: str # relative to repo_dir, e.g. "src/activities/settings"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Include:
|
||||
"""An extra #include line needed in one of the shared files."""
|
||||
header: str
|
||||
target: str # "cross_point_settings_h" | "settings_activity_h" | "settings_activity_cpp" | "main_cpp"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsField:
|
||||
"""A member to add to the CrossPointSettings struct (persisted state)."""
|
||||
decl: str # e.g. "uint8_t darkModeState = 0;"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsListEntry:
|
||||
"""A literal SettingInfo::... initializer added to the master settings list
|
||||
(src/SettingsList.h). Used by save/load and by the web UI's full settings
|
||||
dump. Do NOT give these a StrId category other than STR_NONE_OPT - visible
|
||||
Plugins-tab rows are declared via PluginsTabEntry below instead, so they
|
||||
aren't double-counted."""
|
||||
cpp: str # trailing comma, no trailing newline needed
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingActionEnumValue:
|
||||
"""Requests a new enum class SettingAction member."""
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginsTabEntry:
|
||||
"""One row rendered in the on-device 'Plugins' settings tab (and mirrored
|
||||
into the web UI). kind is one of:
|
||||
- "enum": cycles setting.key through option_labels; value_text_expr
|
||||
(a C++ expression using `value`) renders the current value.
|
||||
- "toggle": on/off backed by a uint8_t field.
|
||||
- "string": free text field (e.g. an API token), usually obfuscated.
|
||||
- "action": launches an activity; doesn't read/write a settings field.
|
||||
"""
|
||||
label: str
|
||||
kind: str
|
||||
key: Optional[str] = None
|
||||
option_labels: Optional[List[str]] = None # web UI + on-device generic fallback text
|
||||
value_text_expr: Optional[str] = None # e.g. 'DarkModePlugin::stateName(static_cast<DarkModeState>(value))'
|
||||
obfuscated: bool = False
|
||||
hidden_from_web: bool = False
|
||||
show_on_device: bool = True # False = web/API-only (e.g. an API token field)
|
||||
action_name: Optional[str] = None # must match a SettingActionEnumValue.name
|
||||
action_value_text: str = "Launch"
|
||||
activity_launch_expr: Optional[str] = None # e.g. 'std::make_unique<PongActivity>(renderer, mappedInput)'
|
||||
activity_needs_rebuild: bool = False # result handler should call rebuildSettingsLists()
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnumValueOverride:
|
||||
"""Overrides the on-device valueText for one specific value of an
|
||||
EXISTING built-in enum setting (as opposed to PluginsTabEntry, which
|
||||
describes a brand new plugin-owned setting). Used by e.g. bookerly to
|
||||
make the built-in `fontFamily` setting show "Bookerly" for its new
|
||||
enum value, while every other value keeps rendering normally."""
|
||||
key: str
|
||||
condition_expr: str # e.g. "value == CrossPointSettings::BOOKERLY"
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebOptionAppend:
|
||||
"""Appends one extra dropdown option string to an EXISTING built-in
|
||||
enum setting's web-UI options array (paired with EnumValueOverride
|
||||
above for the on-device side)."""
|
||||
key: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToggleHook:
|
||||
"""Custom logic that runs inside SettingsActivity::toggleCurrentSetting()
|
||||
right after a matching setting's value has been changed, before the
|
||||
generic save+rebuild tail. Used for things like lockscreen's "just
|
||||
enabled, no PIN set yet -> launch PIN-creation activity instead of a
|
||||
plain save" behaviour. `code` may end with `return;` to skip the
|
||||
generic tail entirely for that keypress."""
|
||||
key: str
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MainHook:
|
||||
"""A code block inserted into main.cpp's setup(). Multiple plugins'
|
||||
hooks at the same point are concatenated in manifest order - none of
|
||||
them need to know about each other."""
|
||||
point: str # "early_boot" (right after RECENT_BOOKS load, before routing -
|
||||
# display/renderer/fonts are NOT ready yet at this point, so
|
||||
# nothing that renders to screen can run here)
|
||||
# or "post_display_setup" (right after setupDisplayAndFonts(),
|
||||
# before the boot-resume routing/splash paints anything -
|
||||
# display+fonts ARE ready; use this for anything that needs
|
||||
# to draw a screen, e.g. a lock gate, before Home/Reader
|
||||
# becomes visible)
|
||||
# or "post_boot" (once, after the boot-resume routing decision, every boot)
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformioFlag:
|
||||
line: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranslationEntry:
|
||||
after_key: str # insert immediately after this existing YAML key, in every translation file
|
||||
key: str
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReaderInvertHook:
|
||||
"""Generalized version of darkmode's screen-invert behaviour: a runtime
|
||||
predicate that, when true, XORs the framebuffer right after each reader
|
||||
page paints (Epub/Txt/Xtc)."""
|
||||
predicate_expr: str
|
||||
include_header: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginManifest:
|
||||
name: str
|
||||
pretty_name: str
|
||||
source_files: List[SourceFile] = field(default_factory=list)
|
||||
includes: List[Include] = field(default_factory=list)
|
||||
settings_fields: List[SettingsField] = field(default_factory=list)
|
||||
settings_list_entries: List[SettingsListEntry] = field(default_factory=list)
|
||||
setting_actions: List[SettingActionEnumValue] = field(default_factory=list)
|
||||
plugins_tab_entries: List[PluginsTabEntry] = field(default_factory=list)
|
||||
enum_value_overrides: List[EnumValueOverride] = field(default_factory=list)
|
||||
web_option_appends: List[WebOptionAppend] = field(default_factory=list)
|
||||
toggle_hooks: List[ToggleHook] = field(default_factory=list)
|
||||
main_hooks: List[MainHook] = field(default_factory=list)
|
||||
platformio_flags: List[PlatformioFlag] = field(default_factory=list)
|
||||
translation_entries: List[TranslationEntry] = field(default_factory=list)
|
||||
reader_invert_hook: Optional[ReaderInvertHook] = None
|
||||
|
||||
# Escape hatches for genuinely plugin-specific work that doesn't touch
|
||||
# shared files (font generation, credential prompts, copying non-source
|
||||
# assets). ctx is a framework.engine.Context.
|
||||
pre_patch: Optional[Callable] = None
|
||||
post_patch: Optional[Callable] = None
|
||||
|
||||
# Ordering hint only (lower runs earlier among pre_patch/post_patch
|
||||
# callbacks - e.g. bookerly's font generation must run before
|
||||
# smallerfonts' generic font-resolution wrapping). Shared-file structural
|
||||
# patches themselves are NOT order-dependent between plugins.
|
||||
phase: int = 0
|
||||
+183
-69
@@ -5,11 +5,13 @@ import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
import glob
|
||||
import importlib.util
|
||||
import argparse
|
||||
import inspect
|
||||
import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from framework.discovery import discover_all, build_selected
|
||||
from framework.engine import apply as apply_plugin_framework, Context, PatchError
|
||||
|
||||
REPO_URL = "https://github.com/crosspoint-reader/crosspoint-reader.git"
|
||||
REPO_DIR = "crosspoint-reader"
|
||||
PLUGINS_DIR = "plugins"
|
||||
@@ -64,10 +66,96 @@ def get_local_head_sha():
|
||||
return None
|
||||
|
||||
|
||||
def backup_device():
|
||||
print(" Backup your current firmware before flashing? [y/N]: ", end="", flush=True)
|
||||
answer = input().strip().lower()
|
||||
def find_backup_files():
|
||||
return sorted(glob.glob("backup.*.bin"), reverse=True)
|
||||
|
||||
|
||||
def restore_from_backup():
|
||||
"""If any backup.*.bin files are sitting in the current directory, offer
|
||||
to write one back to the device instead of proceeding with a fresh
|
||||
install. This always runs before backup_device() and, if the user goes
|
||||
through with a restore, exits the script afterward rather than
|
||||
continuing on to clone/patch/build/flash."""
|
||||
backups = find_backup_files()
|
||||
if not backups:
|
||||
return
|
||||
|
||||
print(f" Found {len(backups)} existing backup{'s' if len(backups) != 1 else ''}:")
|
||||
for i, b in enumerate(backups, 1):
|
||||
tag = " ← most recent" if i == 1 else ""
|
||||
try:
|
||||
size_mb = os.path.getsize(b) / (1024 * 1024)
|
||||
size_label = f" ({size_mb:.1f} MB)"
|
||||
except OSError:
|
||||
size_label = ""
|
||||
print(f" {i}. {b}{size_label}{tag}")
|
||||
|
||||
answer = input("\n Restore firmware from backup? [N/y]: ").strip().lower()
|
||||
if answer not in ("y", "yes"):
|
||||
return
|
||||
|
||||
if len(backups) == 1:
|
||||
chosen = backups[0]
|
||||
else:
|
||||
choice = input(f"\n Which backup do you want to restore? [1-{len(backups)}] (default: 1): ").strip()
|
||||
if not choice:
|
||||
chosen = backups[0]
|
||||
elif choice.isdigit() and 1 <= int(choice) <= len(backups):
|
||||
chosen = backups[int(choice) - 1]
|
||||
else:
|
||||
sys.exit("ERROR: Invalid choice — aborting restore.")
|
||||
|
||||
print()
|
||||
print(" Ensure your xteink device is connected via USB and awake.")
|
||||
input(" Press Enter when ready... ")
|
||||
|
||||
ports = detect_serial_ports()
|
||||
if not ports:
|
||||
sys.exit("ERROR: No USB serial ports detected — cannot restore.")
|
||||
|
||||
cu_ports = [(d, desc) for d, desc in ports if d.startswith("/dev/cu.")]
|
||||
recommended = cu_ports[0][0] if cu_ports else ports[0][0]
|
||||
|
||||
print(f"\n Found {len(ports)} serial port(s):")
|
||||
for i, (device, desc) in enumerate(ports, 1):
|
||||
tag = " ← recommended" if device == recommended else ""
|
||||
print(f" {i}. {device} — {desc}{tag}")
|
||||
|
||||
port_choice = input(f"\n Press ENTER to use {recommended}, or enter 1-{len(ports)}: ").strip()
|
||||
if not port_choice:
|
||||
port = recommended
|
||||
elif port_choice.isdigit() and 0 <= int(port_choice) - 1 < len(ports):
|
||||
port = ports[int(port_choice) - 1][0]
|
||||
else:
|
||||
sys.exit("ERROR: Invalid port choice — aborting restore.")
|
||||
|
||||
while True:
|
||||
print(f"\n Writing {chosen} → {port} ...")
|
||||
result = subprocess.run(
|
||||
f"esptool --port {port} --baud 921600 write-flash 0x0 {chosen}",
|
||||
shell=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f"\n ✓ Restored {chosen} to the device.")
|
||||
sys.exit(0)
|
||||
|
||||
print(" ✗ Restore failed (USB dropout, wrong port, or device asleep are common causes).")
|
||||
retry_choice = input(" [r]etry or [A]bort? [r/A]: ").strip().lower()
|
||||
|
||||
if retry_choice == "r":
|
||||
print("\n Ensure your xteink device is still connected via USB and awake")
|
||||
print(" (try a different cable/port if it keeps dropping out).")
|
||||
input(" Press Enter to retry... ")
|
||||
continue
|
||||
else:
|
||||
sys.exit("Aborted.")
|
||||
|
||||
|
||||
def backup_device():
|
||||
print(" Backup your current firmware before flashing? [Y/n]: ", end="", flush=True)
|
||||
answer = input().strip().lower()
|
||||
if answer not in ("", "y", "yes"):
|
||||
print(" Skipping backup.")
|
||||
return
|
||||
|
||||
@@ -75,11 +163,20 @@ def backup_device():
|
||||
print(" Ensure your xteink device is connected via USB and awake.")
|
||||
input(" Press Enter when ready... ")
|
||||
|
||||
ports = detect_serial_ports()
|
||||
|
||||
if not ports:
|
||||
print(" No USB serial ports detected — skipping backup.")
|
||||
return
|
||||
while True:
|
||||
ports = detect_serial_ports()
|
||||
if ports:
|
||||
break
|
||||
print(" No USB serial ports detected.")
|
||||
retry_choice = input(" Check the USB connection, then [r]etry or [C]ontinue without backup? [r/C]: ").strip().lower()
|
||||
if retry_choice == "r":
|
||||
print("\n Ensure your xteink device is still connected via USB and awake")
|
||||
print(" (try a different cable/port if it isn't being detected).")
|
||||
input(" Press Enter to retry... ")
|
||||
continue
|
||||
else:
|
||||
print(" Continuing without a backup.")
|
||||
return
|
||||
|
||||
cu_ports = [(d, desc) for d, desc in ports if d.startswith("/dev/cu.")]
|
||||
recommended = cu_ports[0][0] if cu_ports else ports[0][0]
|
||||
@@ -99,23 +196,43 @@ def backup_device():
|
||||
print(" Invalid choice — skipping backup.")
|
||||
return
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d.%H%M%S")
|
||||
filename = f"backup.{timestamp}.bin"
|
||||
print(f"\n Reading flash from {port} → {filename} ...")
|
||||
while True:
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d.%H%M%S")
|
||||
filename = f"backup.{timestamp}.bin"
|
||||
print(f"\n Reading flash from {port} → {filename} ...")
|
||||
|
||||
result = subprocess.run(
|
||||
f"esptool --port {port} --baud 921600 read-flash 0x0 ALL {filename}",
|
||||
shell=True,
|
||||
)
|
||||
result = subprocess.run(
|
||||
f"esptool --port {port} --baud 921600 read-flash 0x0 ALL {filename}",
|
||||
shell=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f" ✓ Backup saved to {filename}")
|
||||
print()
|
||||
print(f" To restore your device to this backup, run:")
|
||||
print(f" esptool --port {port} --baud 921600 write-flash 0x0 {filename}")
|
||||
else:
|
||||
print(" ✗ Backup failed. You can continue or abort and retry manually.")
|
||||
if input(" Continue without backup? [y/N]: ").strip().lower() not in ("y", "yes"):
|
||||
if result.returncode == 0:
|
||||
print(f" ✓ Backup saved to {filename}")
|
||||
print()
|
||||
print(f" To restore your device to this backup, run:")
|
||||
print(f" esptool --port {port} --baud 921600 write-flash 0x0 {filename}")
|
||||
break
|
||||
|
||||
# Remove the partial/corrupt file left behind by the failed attempt
|
||||
if os.path.exists(filename):
|
||||
try:
|
||||
os.remove(filename)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
print(" ✗ Backup failed (USB dropout, wrong port, or device asleep are common causes).")
|
||||
choice = input(" [r]etry, [A]bort, or [c]ontinue without backup? [r/A/c]: ").strip().lower()
|
||||
|
||||
if choice == "r":
|
||||
print("\n Ensure your xteink device is still connected via USB and awake")
|
||||
print(" (try a different cable/port if it keeps dropping out).")
|
||||
input(" Press Enter to retry... ")
|
||||
continue
|
||||
elif choice == "c":
|
||||
print(" Continuing without a backup.")
|
||||
break
|
||||
else:
|
||||
# Empty input or anything else defaults to the safe choice: abort.
|
||||
print(" Aborted.")
|
||||
sys.exit(0)
|
||||
print()
|
||||
@@ -145,28 +262,6 @@ def clone_repo(force: bool = True):
|
||||
run(f"git clone --recursive {REPO_URL} {REPO_DIR}")
|
||||
|
||||
|
||||
def run_plugin_patch(plugin_dir: str, yes_all: bool = False) -> bool:
|
||||
patch_path = os.path.join(plugin_dir, "patch.py")
|
||||
if not os.path.exists(patch_path):
|
||||
print(f" WARNING: No patch.py found in {plugin_dir}, skipping.")
|
||||
return False
|
||||
|
||||
spec = importlib.util.spec_from_file_location("patch", patch_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
if hasattr(module, "patch"):
|
||||
sig = inspect.signature(module.patch)
|
||||
if "yes_all" in sig.parameters:
|
||||
module.patch(os.path.abspath(REPO_DIR), yes_all=yes_all)
|
||||
else:
|
||||
module.patch(os.path.abspath(REPO_DIR))
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f" ERROR while running patch.py for {plugin_dir}: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def clear_plugin_caches():
|
||||
for plugin_name in os.listdir(PLUGINS_DIR):
|
||||
cache_dir = os.path.join(PLUGINS_DIR, plugin_name, "__pycache__")
|
||||
@@ -174,25 +269,20 @@ def clear_plugin_caches():
|
||||
rmtree(cache_dir)
|
||||
|
||||
|
||||
def apply_plugins(yes_all: bool = False):
|
||||
print("[2/3] Applying plugins...")
|
||||
|
||||
def select_plugins(yes_all: bool = False):
|
||||
"""Ask the user which plugins to install. Selection only - no file
|
||||
touched yet, so nothing here depends on which other plugins are chosen."""
|
||||
if not os.path.exists(PLUGINS_DIR):
|
||||
print(f" No '{PLUGINS_DIR}' directory found — skipping plugin step.")
|
||||
return
|
||||
return []
|
||||
|
||||
plugin_dirs = sorted(
|
||||
d for d in os.listdir(PLUGINS_DIR)
|
||||
if os.path.isdir(os.path.join(PLUGINS_DIR, d))
|
||||
)
|
||||
|
||||
if not plugin_dirs:
|
||||
print(" No plugin directories found.")
|
||||
return
|
||||
|
||||
for plugin_name in plugin_dirs:
|
||||
plugin_path = os.path.join(PLUGINS_DIR, plugin_name)
|
||||
available = discover_all(PLUGINS_DIR)
|
||||
if not available:
|
||||
print(" No plugins with a plugin.py manifest found.")
|
||||
return []
|
||||
|
||||
chosen = []
|
||||
for plugin_name in sorted(available):
|
||||
print(f"\n Plugin: {plugin_name}")
|
||||
if yes_all:
|
||||
answer = "y"
|
||||
@@ -201,15 +291,37 @@ def apply_plugins(yes_all: bool = False):
|
||||
answer = input(f" Install '{plugin_name}'? [Y/n]: ").strip().lower()
|
||||
|
||||
if answer in ("", "y", "yes"):
|
||||
print(f" Installing {plugin_name}...")
|
||||
success = run_plugin_patch(plugin_path, yes_all=yes_all)
|
||||
if success:
|
||||
print(f" ✓ {plugin_name} installed.")
|
||||
else:
|
||||
print(f" ✗ {plugin_name} failed or was skipped.")
|
||||
chosen.append(plugin_name)
|
||||
else:
|
||||
print(f" Skipping {plugin_name}.")
|
||||
|
||||
return chosen
|
||||
|
||||
|
||||
def apply_plugins(plugin_names, yes_all: bool = False):
|
||||
print("[2/3] Applying plugins...")
|
||||
|
||||
if not plugin_names:
|
||||
print(" No plugins selected.")
|
||||
return
|
||||
|
||||
print(f" Selected: {', '.join(plugin_names)}")
|
||||
ctx = Context(repo_dir=os.path.abspath(REPO_DIR), yes_all=yes_all, prompt=not yes_all)
|
||||
|
||||
try:
|
||||
selected = build_selected(plugin_names, PLUGINS_DIR, ctx)
|
||||
apply_plugin_framework(ctx, selected)
|
||||
except (PatchError, KeyError) as exc:
|
||||
sys.exit(
|
||||
f"\nERROR: {exc}\n\n"
|
||||
"This usually means the upstream crosspoint-reader source has "
|
||||
"changed in a way this plugin set doesn't recognize yet. No "
|
||||
"half-applied changes were left in place beyond this point - "
|
||||
"please report this so the anchor can be updated."
|
||||
)
|
||||
|
||||
print(f"\n ✓ {len(plugin_names)} plugin(s) installed: {', '.join(plugin_names)}")
|
||||
|
||||
|
||||
def list_likely_serial_ports():
|
||||
patterns = [
|
||||
@@ -371,10 +483,12 @@ def main():
|
||||
print(" Aborted.")
|
||||
sys.exit(0)
|
||||
print()
|
||||
restore_from_backup()
|
||||
backup_device()
|
||||
clone_repo(force=not args.no_reclone)
|
||||
clear_plugin_caches()
|
||||
apply_plugins(yes_all=args.yes)
|
||||
chosen_plugins = select_plugins(yes_all=args.yes)
|
||||
apply_plugins(chosen_plugins, yes_all=args.yes)
|
||||
build_and_flash(args.environment)
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
@@ -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])
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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"
|
||||
" }"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -119,7 +119,7 @@ std::string getIsbnFromEpub(Epub& epub, const std::string& path) {
|
||||
// ---- Progress reading -------------------------------------------------------
|
||||
|
||||
int epubProgressPercent(const std::string& cachePath, int& outPage, Epub& epub) {
|
||||
FsFile f;
|
||||
HalFile f;
|
||||
if (!Storage.openFileForRead("HCV", (cachePath + "/progress.bin").c_str(), f))
|
||||
return -1;
|
||||
uint8_t data[6] = {};
|
||||
@@ -178,7 +178,7 @@ void collectBooks(std::vector<InProgressBook>& inProgress, std::vector<InProgres
|
||||
std::string cachePath = buildCachePath(path);
|
||||
|
||||
// Check progress.bin exists before loading the EPUB
|
||||
FsFile f;
|
||||
HalFile f;
|
||||
if (!Storage.openFileForRead("HCV", (cachePath + "/progress.bin").c_str(), f)) continue;
|
||||
f.close();
|
||||
|
||||
@@ -351,7 +351,7 @@ std::string readSessionCachePath(int userBookId) {
|
||||
}
|
||||
|
||||
int loadCachedReadId(int userBookId) {
|
||||
FsFile f;
|
||||
HalFile f;
|
||||
std::string path = readSessionCachePath(userBookId);
|
||||
if (!Storage.openFileForRead("HCV", path.c_str(), f)) return 0;
|
||||
uint8_t data[4] = {};
|
||||
@@ -362,7 +362,7 @@ int loadCachedReadId(int userBookId) {
|
||||
}
|
||||
|
||||
void saveCachedReadId(int userBookId, int readId) {
|
||||
FsFile f;
|
||||
HalFile f;
|
||||
std::string path = readSessionCachePath(userBookId);
|
||||
if (!Storage.openFileForWrite("HCV", path.c_str(), f)) {
|
||||
LOG_ERR("HCV", "Could not save read session cache for user_book_id=%d", userBookId);
|
||||
|
||||
@@ -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])
|
||||
@@ -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)",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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"
|
||||
" }"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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)",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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,3 +1,4 @@
|
||||
platformio
|
||||
pyserial
|
||||
freetype-py
|
||||
fonttools
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user