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

This commit is contained in:
Sterling Archer
2026-07-16 15:39:10 -07:00
parent 3ad7a7ab39
commit fec251f4d1
23 changed files with 2079 additions and 3243 deletions
-425
View File
@@ -1,425 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
import hashlib
import subprocess
def read_file(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
def write_file(path, content):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def find_first(filename, repo_dir):
results = glob.glob(os.path.join(repo_dir, "**", filename), recursive=True)
if not results:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def font_id_from_files(paths):
total = 0
for p in paths:
h = hashlib.sha256(open(p, "rb").read()).hexdigest()
total += int(h, 16)
return total % (2 ** 32) - (2 ** 31)
def generate_fonts(plugin_dir, repo_dir):
scripts_dir = os.path.join(repo_dir, "lib", "EpdFont", "scripts")
fonts_dir = os.path.join(repo_dir, "lib", "EpdFont", "builtinFonts")
source_dir = os.path.join(plugin_dir, "fonts")
fontconvert = os.path.join(scripts_dir, "fontconvert.py")
subprocess.run(
[sys.executable, "-m", "pip", "install", "freetype-py==2.5.1", "fonttools", "--break-system-packages", "-q"],
check=True
)
sizes = [12, 14, 16, 18]
styles = ["Regular"]
for size in sizes:
for style in styles:
fname = f"bookerly_{size}_{style.lower()}"
out_path = os.path.join(fonts_dir, f"{fname}.h")
if os.path.exists(out_path):
print(f" already exists: {fname}.h")
continue
src_path = os.path.join(source_dir, f"Bookerly-{style}.ttf")
if not os.path.exists(src_path):
sys.exit(f"ERROR: Missing font file: {src_path}")
with open(out_path, "w") as f:
result = subprocess.run(
[sys.executable, fontconvert, fname, str(size), src_path, "--2bit", "--compress"],
capture_output=True, text=True
)
if result.returncode != 0 or not result.stdout.strip():
print(f" ERROR generating {fname}: {result.stderr[:200]}")
if os.path.exists(out_path):
os.remove(out_path)
continue
f.write(result.stdout)
print(f" ✓ generated {fname}.h")
return fonts_dir
def compute_font_ids(fonts_dir):
ids = {}
sizes = [12, 14, 16, 18]
for size in sizes:
path = os.path.join(fonts_dir, f"bookerly_{size}_regular.h")
if os.path.exists(path):
h = hashlib.sha256(open(path, "rb").read()).hexdigest()
ids[f"BOOKERLY_{size}_FONT_ID"] = int(h, 16) % (2 ** 32) - (2 ** 31)
return ids
def patch_font_ids_h(repo_dir, new_ids):
path = find_first("fontIds.h", repo_dir)
content = read_file(path)
lines = []
for name, val in new_ids.items():
if name not in content:
lines.append(f"#define {name} ({val})")
if not lines:
print(" fontIds.h already patched, skipping.")
return
write_file(path, content.rstrip() + "\n" + "\n".join(lines) + "\n")
print(f" fontIds.h: added {len(lines)} new font IDs.")
def patch_all_h(repo_dir, new_ids):
path = find_first("all.h", repo_dir)
content = read_file(path)
added = 0
sizes = [12, 14, 16, 18]
for size in sizes:
key = f"BOOKERLY_{size}_FONT_ID"
if key not in new_ids:
continue
fname = f"bookerly_{size}_regular"
inc = f'#include <builtinFonts/{fname}.h>'
if inc not in content:
content += inc + "\n"
added += 1
if added:
write_file(path, content)
print(f" all.h: added {added} includes.")
else:
print(" all.h already patched, skipping.")
def patch_main_cpp(repo_dir, new_ids):
path = find_first("main.cpp", repo_dir)
content = read_file(path)
sizes = [8, 10, 12, 14, 16, 18]
decl_lines = []
insert_lines = []
for size in sizes:
key = f"BOOKERLY_{size}_FONT_ID"
varbase = f"bookerly{size}"
if key not in new_ids:
continue
if f"EpdFontFamily {varbase}FontFamily" in content:
continue
decl_lines += [
f'EpdFont {varbase}RegularFont(&bookerly_{size}_regular);',
f'EpdFontFamily {varbase}FontFamily(&{varbase}RegularFont, &{varbase}RegularFont, &{varbase}RegularFont, &{varbase}RegularFont);',
]
insert_lines.append(f' renderer.insertFont({key}, {varbase}FontFamily);')
if not decl_lines:
print(" main.cpp already patched, skipping.")
return
anchor_decl = '#ifndef OMIT_FONTS'
content = content.replace(anchor_decl, "\n".join(decl_lines) + "\n" + anchor_decl, 1)
anchor_insert = ' renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);'
content = content.replace(anchor_insert, "\n".join(insert_lines) + "\n" + anchor_insert, 1)
write_file(path, content)
print(f" main.cpp: added {len(decl_lines) // 2} font families.")
def patch_cross_point_settings_h(repo_dir):
path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path)
if "BOOKERLY = 3" in content:
print(" CrossPointSettings.h already patched, skipping.")
return
if '"activities/settings/BookerlyPlugin.h"' not in content:
content = content.replace(
'#include "activities/settings/SmallerFontsPlugin.h"',
'#include "activities/settings/SmallerFontsPlugin.h"\n#include "activities/settings/BookerlyPlugin.h"'
)
if 'BOOKERLY = 3' not in content:
content = content.replace(
'enum FONT_FAMILY { NOTOSERIF = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, FONT_FAMILY_COUNT };',
'enum FONT_FAMILY { NOTOSERIF = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, BOOKERLY = 3, FONT_FAMILY_COUNT };'
)
write_file(path, content)
print(" CrossPointSettings.h patched.")
def patch_cross_point_settings_cpp(repo_dir, new_ids):
path = find_first("CrossPointSettings.cpp", repo_dir)
content = read_file(path)
if "BOOKERLY" in content:
print(" CrossPointSettings.cpp already patched, skipping.")
return
content = content.replace(
' case OPENDYSLEXIC:\n switch (lineSpacing) {\n case TIGHT:\n return 0.90f;\n case NORMAL:\n default:\n return 0.95f;\n case WIDE:\n return 1.0f;\n }\n }\n}\n\nunsigned long',
' case OPENDYSLEXIC:\n switch (lineSpacing) {\n case TIGHT:\n return 0.90f;\n case NORMAL:\n default:\n return 0.95f;\n case WIDE:\n return 1.0f;\n }\n case BOOKERLY:\n switch (lineSpacing) {\n case TIGHT:\n return 0.95f;\n case NORMAL:\n default:\n return 1.0f;\n case WIDE:\n return 1.1f;\n }\n }\n}\n\nunsigned long'
)
use_resolve = 'resolve' in content
bookerly_return = 'resolve(BOOKERLY_12_FONT_ID)' if use_resolve else 'BOOKERLY_12_FONT_ID'
bookerly_case = (
' case BOOKERLY:\n'
f' return {bookerly_return};\n'
)
anchor = None
for candidate in [
' case EXTRA_LARGE:\n return resolve(OPENDYSLEXIC_14_FONT_ID);\n }\n }\n}\n',
' case EXTRA_LARGE:\n return OPENDYSLEXIC_14_FONT_ID;\n }\n }\n}\n',
]:
if candidate in content:
anchor = candidate
break
if anchor:
content = content.replace(
anchor,
' case EXTRA_LARGE:\n return ' + ('resolve(OPENDYSLEXIC_14_FONT_ID)' if 'resolve' in anchor else 'OPENDYSLEXIC_14_FONT_ID') + ';\n }\n'
+ bookerly_case +
' }\n}\n'
)
write_file(path, content)
print(" CrossPointSettings.cpp patched.")
def patch_settings_list_h(repo_dir):
path = find_first("SettingsList.h", repo_dir)
content = read_file(path)
already_patched = 'STR_NONE_OPT}, "fontFamily"' in content and 'bookerlyInstalled' in content
if already_patched:
print(" SettingsList.h already patched, skipping.")
return
if 'STR_NONE_OPT}, "fontFamily"' not in content:
content = content.replace(
'{StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, "fontFamily"',
'{StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC, StrId::STR_NONE_OPT}, "fontFamily"'
)
write_file(path, content)
print(" SettingsList.h patched.")
def patch_settings_activity_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
if 'BookerlyInstalled' in content:
print(" SettingsActivity.h already patched, skipping.")
return
if 'categoryCount = 4' in content:
content = content.replace(
'static constexpr int categoryCount = 4;',
'static constexpr int categoryCount = 5;'
)
if 'pluginsSettings' not in content:
content = content.replace(
' std::vector<SettingInfo> systemSettings;',
' std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;'
)
content = content.replace(
' PongGame,\n};',
' PongGame,\n BookerlyInstalled,\n};'
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_activity_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if '"Bookerly Font"' in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "fontIds.h"',
'#include "fontIds.h"\n#include "BookerlyPlugin.h"'
)
if 'SettingAction::BookerlyInstalled' not in content:
content = content.replace(
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n',
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n'
' pluginsSettings.push_back(SettingInfo::Action(\n'
' StrId::STR_NONE_OPT, SettingAction::BookerlyInstalled\n'
' ));\n'
)
if '"Bookerly Font"' not in content:
content = content.replace(
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,',
' [&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
' if (s.type == SettingType::ACTION && s.action == SettingAction::BookerlyInstalled) return "Bookerly Font";\n'
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' }, nullptr, nullptr,'
)
if '"Bookerly"' not in content:
content = content.replace(
' const uint8_t value = SETTINGS.*(setting.valuePtr);\n'
' valueText = I18N.get(setting.enumValues[value]);\n',
' const uint8_t value = SETTINGS.*(setting.valuePtr);\n'
' if (setting.key && std::string(setting.key) == "fontFamily" && value == CrossPointSettings::BOOKERLY) {\n'
' valueText = "Bookerly";\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }\n'
)
if '"Installed"' not in content:
content = content.replace(
' } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {\n'
' valueText = std::to_string(SETTINGS.*(setting.valuePtr));\n'
' }',
' } else if (setting.type == SettingType::ACTION && setting.action == SettingAction::BookerlyInstalled) {\n'
' valueText = "Installed";\n'
' } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {\n'
' valueText = std::to_string(SETTINGS.*(setting.valuePtr));\n'
' }'
)
write_file(path, content)
print(" SettingsActivity.cpp patched.")
def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path)
if '"Bookerly"' in content:
print(" CrossPointWebServer.cpp already patched, skipping.")
return
content = content.replace(
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' doc["name"] = "Smaller Fonts";\n'
' doc["category"] = "Plugins";\n'
' }\n'
' }\n',
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' doc["name"] = "Smaller Fonts";\n'
' doc["category"] = "Plugins";\n'
' } else if (strcmp(s.key, "fontFamily") == 0) {\n'
' JsonArray opts = doc["options"].to<JsonArray>();\n'
' opts.add("Noto Serif");\n'
' opts.add("Noto Sans");\n'
' opts.add("OpenDyslexic");\n'
' opts.add("Bookerly");\n'
' }\n'
' }\n'
)
write_file(path, content)
print(" CrossPointWebServer.cpp patched.")
def copy_plugin_sources(plugin_dir, repo_dir):
dest = os.path.join(repo_dir, "src", "activities", "settings")
os.makedirs(dest, exist_ok=True)
for fname in os.listdir(plugin_dir):
if fname.endswith((".h", ".cpp", ".hpp")):
src = os.path.join(plugin_dir, fname)
dst = os.path.join(dest, fname)
shutil.copy2(src, dst)
print(f" ✓ {fname}")
def patch(repo_dir: str):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Generating Bookerly font files...")
fonts_dir = generate_fonts(plugin_dir, repo_dir)
print(" Computing font IDs...")
new_ids = compute_font_ids(fonts_dir)
for k, v in new_ids.items():
print(f" {k} = {v}")
print(" Patching fontIds.h...")
patch_font_ids_h(repo_dir, new_ids)
print(" Patching all.h...")
patch_all_h(repo_dir, new_ids)
print(" Patching main.cpp...")
patch_main_cpp(repo_dir, new_ids)
print(" Patching CrossPointSettings.h...")
patch_cross_point_settings_h(repo_dir)
print(" Patching CrossPointSettings.cpp...")
patch_cross_point_settings_cpp(repo_dir, new_ids)
print(" Patching SettingsList.h...")
patch_settings_list_h(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_activity_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_activity_cpp(repo_dir)
print(" Patching CrossPointWebServer.cpp (web UI)...")
patch_web_server(repo_dir)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+288
View File
@@ -0,0 +1,288 @@
import sys
import os
import re
import hashlib
import subprocess
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, PluginsTabEntry, EnumValueOverride, WebOptionAppend, SettingActionEnumValue,
)
from framework.engine import find_first, read_file, write_file, PatchError, replace_once, contains_once
PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
# NOTE: this only extends the *static* built-in font-family list
# (SettingsList.h's plain SettingInfo::Enum entry). CrossPoint also has a
# second, SD-card-font-aware variant (buildFontFamilySetting()) that swaps in
# when the user has SD-card fonts installed - Bookerly won't appear there.
# That's a narrow pre-existing edge case (SD-card fonts + Bookerly at once)
# left as a known follow-up rather than something this pass fully covers.
def _generate_fonts(ctx):
fonts_dir = os.path.join(ctx.repo_dir, "lib", "EpdFont", "builtinFonts")
scripts_dir = os.path.join(ctx.repo_dir, "lib", "EpdFont", "scripts")
source_dir = os.path.join(PLUGIN_DIR, "fonts")
fontconvert = os.path.join(scripts_dir, "fontconvert.py")
subprocess.run(
[sys.executable, "-m", "pip", "install", "freetype-py==2.5.1", "fonttools", "--break-system-packages", "-q"],
check=True,
)
sizes = [12, 14, 16, 18]
for size in sizes:
fname = f"bookerly_{size}_regular"
out_path = os.path.join(fonts_dir, f"{fname}.h")
if os.path.exists(out_path):
print(f" already exists: {fname}.h")
continue
src_path = os.path.join(source_dir, "Bookerly-Regular.ttf")
if not os.path.exists(src_path):
raise PatchError(f"Missing font file: {src_path}")
result = subprocess.run(
[sys.executable, fontconvert, fname, str(size), src_path, "--2bit", "--compress"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
raise PatchError(f"Failed generating {fname}: {result.stderr[:300]}")
with open(out_path, "w") as f:
f.write(result.stdout)
print(f" ✓ generated {fname}.h")
ids = {}
for size in sizes:
path = os.path.join(fonts_dir, f"bookerly_{size}_regular.h")
h = hashlib.sha256(open(path, "rb").read()).hexdigest()
ids[f"BOOKERLY_{size}_FONT_ID"] = int(h, 16) % (2 ** 32) - (2 ** 31)
ctx.bookerly_font_ids = ids
def _patch_font_registration(ctx):
ids = ctx.bookerly_font_ids
# fontIds.h
path = find_first("fontIds.h", ctx.repo_dir)
content = read_file(path)
lines = [f"#define {name} ({val})" for name, val in ids.items() if name not in content]
if lines:
write_file(path, content.rstrip() + "\n" + "\n".join(lines) + "\n")
print(f" fontIds.h: added {len(lines)} font ID(s).")
# all.h
path = find_first("all.h", ctx.repo_dir)
content = read_file(path)
added = 0
for size in [12, 14, 16, 18]:
inc = f"#include <builtinFonts/bookerly_{size}_regular.h>"
if inc not in content:
content += inc + "\n"
added += 1
if added:
write_file(path, content)
print(f" all.h: added {added} include(s).")
# main.cpp: font family declarations + registration
path = find_first("main.cpp", ctx.repo_dir)
content = read_file(path)
decl_lines, insert_lines = [], []
for size in [12, 14, 16, 18]:
var = f"bookerly{size}"
if f"EpdFontFamily {var}FontFamily" in content:
continue
decl_lines += [
f"EpdFont {var}RegularFont(&bookerly_{size}_regular);",
f"EpdFontFamily {var}FontFamily(&{var}RegularFont, &{var}RegularFont, &{var}RegularFont, &{var}RegularFont);",
]
insert_lines.append(f" renderer.insertFont(BOOKERLY_{size}_FONT_ID, {var}FontFamily);")
if decl_lines:
# "#ifndef OMIT_FONTS" legitimately appears twice (global font
# declarations, then again inside setup() around insertFont calls) -
# we specifically want the *first* (global declaration) occurrence,
# which is what replace(..., count=1) targets.
anchor_decl = "#ifndef OMIT_FONTS"
if anchor_decl not in content:
raise PatchError("main.cpp: could not find `#ifndef OMIT_FONTS` (font declarations section)")
content = content.replace(anchor_decl, "\n".join(decl_lines) + "\n" + anchor_decl, 1)
anchor_insert = " renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);"
contains_once(content, anchor_insert, "main.cpp")
content = content.replace(anchor_insert, "\n".join(insert_lines) + "\n" + anchor_insert, 1)
write_file(path, content)
print(f" main.cpp: registered {len(decl_lines) // 2} Bookerly size(s).")
def _patch_font_family_enum(ctx):
path = find_first("CrossPointSettings.h", ctx.repo_dir)
content = read_file(path)
if "BOOKERLY" in content:
return
m = re.search(r"enum FONT_FAMILY \{(.*?)FONT_FAMILY_COUNT\s*\};", content)
if not m:
raise PatchError("CrossPointSettings.h: could not find `enum FONT_FAMILY { ... FONT_FAMILY_COUNT };`")
content = content[:m.start()] + m.group(0).replace("FONT_FAMILY_COUNT", "BOOKERLY, FONT_FAMILY_COUNT", 1) + content[m.end():]
write_file(path, content)
print(" CrossPointSettings.h: added FONT_FAMILY::BOOKERLY.")
def _patch_line_spacing_and_reader_font_id(ctx):
path = find_first("CrossPointSettings.cpp", ctx.repo_dir)
content = read_file(path)
label = "CrossPointSettings.cpp"
if "case BOOKERLY:" not in content:
spacing_anchor = (
" case NOTOSANS:\n"
" switch (lineSpacing) {\n"
" case TIGHT:\n"
" return 0.90f;\n"
" case NORMAL:\n"
" default:\n"
" return 0.95f;\n"
" case WIDE:\n"
" return 1.0f;\n"
" }\n"
" }\n"
"}\n"
)
spacing_replacement = (
" case NOTOSANS:\n"
" switch (lineSpacing) {\n"
" case TIGHT:\n"
" return 0.90f;\n"
" case NORMAL:\n"
" default:\n"
" return 0.95f;\n"
" case WIDE:\n"
" return 1.0f;\n"
" }\n"
" case BOOKERLY:\n"
" switch (lineSpacing) {\n"
" case TIGHT:\n"
" return 0.95f;\n"
" case NORMAL:\n"
" default:\n"
" return 1.0f;\n"
" case WIDE:\n"
" return 1.1f;\n"
" }\n"
" }\n"
"}\n"
)
content = replace_once(content, spacing_anchor, spacing_replacement, label)
if "BOOKERLY_12_FONT_ID" not in content:
font_anchor = (
" case NOTOSANS:\n"
" switch (fontSize) {\n"
" case SMALL:\n"
" return NOTOSANS_12_FONT_ID;\n"
" case MEDIUM:\n"
" default:\n"
" return NOTOSANS_14_FONT_ID;\n"
" case LARGE:\n"
" return NOTOSANS_16_FONT_ID;\n"
" case EXTRA_LARGE:\n"
" return NOTOSANS_18_FONT_ID;\n"
" }\n"
" }\n"
"}\n"
)
font_replacement = (
" case NOTOSANS:\n"
" switch (fontSize) {\n"
" case SMALL:\n"
" return NOTOSANS_12_FONT_ID;\n"
" case MEDIUM:\n"
" default:\n"
" return NOTOSANS_14_FONT_ID;\n"
" case LARGE:\n"
" return NOTOSANS_16_FONT_ID;\n"
" case EXTRA_LARGE:\n"
" return NOTOSANS_18_FONT_ID;\n"
" }\n"
" case BOOKERLY:\n"
" switch (fontSize) {\n"
" case SMALL:\n"
" return BOOKERLY_12_FONT_ID;\n"
" case MEDIUM:\n"
" default:\n"
" return BOOKERLY_14_FONT_ID;\n"
" case LARGE:\n"
" return BOOKERLY_16_FONT_ID;\n"
" case EXTRA_LARGE:\n"
" return BOOKERLY_18_FONT_ID;\n"
" }\n"
" }\n"
"}\n"
)
content = replace_once(content, font_anchor, font_replacement, label)
write_file(path, content)
print(" CrossPointSettings.cpp: Bookerly line-spacing + font-id cases added.")
def _patch_settings_list_option(ctx):
path = find_first("SettingsList.h", ctx.repo_dir)
content = read_file(path)
label = "SettingsList.h"
anchor = (
'SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,\n'
' {StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS}, "fontFamily", StrId::STR_CAT_READER),'
)
if "StrId::STR_NONE_OPT}, \"fontFamily\"" in content:
return
replacement = (
'SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,\n'
' {StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_NONE_OPT}, "fontFamily", StrId::STR_CAT_READER),'
)
content = replace_once(content, anchor, replacement, label)
write_file(path, content)
print(" SettingsList.h: fontFamily gained a 3rd (Bookerly) option.")
def _pre_patch(ctx):
print(" Generating Bookerly font files...")
_generate_fonts(ctx)
def _post_patch(ctx):
print(" Registering Bookerly font family...")
_patch_font_registration(ctx)
_patch_font_family_enum(ctx)
_patch_line_spacing_and_reader_font_id(ctx)
_patch_settings_list_option(ctx)
def get_manifest(ctx):
return PluginManifest(
name="bookerly",
pretty_name="Bookerly Font",
phase=0, # must run before smallerfonts (phase 1) wraps getReaderFontId's returns
source_files=[
SourceFile("BookerlyPlugin.h", "src/activities/settings"),
SourceFile("BookerlyPlugin.cpp", "src/activities/settings"),
],
setting_actions=[
SettingActionEnumValue("BookerlyInstalled"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Bookerly Font",
kind="action",
action_name="BookerlyInstalled",
action_value_text="Installed",
activity_launch_expr=None, # informational row only, matches original behaviour
),
],
enum_value_overrides=[
EnumValueOverride(key="fontFamily", condition_expr="value == CrossPointSettings::BOOKERLY", text="Bookerly"),
],
web_option_appends=[
WebOptionAppend(key="fontFamily", label="Bookerly"),
],
pre_patch=_pre_patch,
post_patch=_post_patch,
)
-536
View File
@@ -1,536 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
def read_file(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
def write_file(path, content):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def find_first(filename, repo_dir):
results = glob.glob(os.path.join(repo_dir, "**", filename), recursive=True)
if not results:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def copy_plugin_sources(plugin_dir, repo_dir):
dest = os.path.join(repo_dir, "src", "activities", "settings")
os.makedirs(dest, exist_ok=True)
skip = {"DarkModeSettingsPage.h", "DarkModeSettingsPage.cpp"}
for fname in os.listdir(plugin_dir):
if fname.endswith((".h", ".cpp", ".hpp")) and fname not in skip:
src = os.path.join(plugin_dir, fname)
dst = os.path.join(dest, fname)
shutil.copy2(src, dst)
print(f" ✓ {fname}")
def patch_cross_point_settings_h(repo_dir):
path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path)
if "darkModeState" in content:
print(" CrossPointSettings.h already patched, skipping.")
return
content = content.replace(
'#pragma once\n#include <HalStorage.h>',
'#pragma once\n#include <HalStorage.h>\n#include "activities/settings/DarkModePlugin.h"'
)
content = content.replace(
' uint8_t imageRendering = IMAGES_DISPLAY;',
' uint8_t imageRendering = IMAGES_DISPLAY;\n uint8_t darkModeState = 0;'
)
write_file(path, content)
print(" CrossPointSettings.h patched.")
def patch_settings_list_h(repo_dir):
path = find_first("SettingsList.h", repo_dir)
content = read_file(path)
if "darkModeState" in content:
print(" SettingsList.h already patched, skipping.")
return
entries = (
' SettingInfo::Enum(StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"),\n'
' SettingInfo::Enum(StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"),\n'
)
content = content.replace(
' };\n return list;\n}\n',
entries + ' };\n return list;\n}\n'
)
write_file(path, content)
print(" SettingsList.h patched.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
already_has_plugins_tab = "categoryCount = 5" in content
already_has_plugins_vec = "pluginsSettings" in content
if already_has_plugins_tab and already_has_plugins_vec:
print(" SettingsActivity.h already patched, skipping.")
return
if '"DarkModePlugin.h"' not in content:
content = content.replace(
'#include <I18n.h>',
'#include <I18n.h>\n#include "DarkModePlugin.h"'
)
if not already_has_plugins_tab:
content = content.replace(
'static constexpr int categoryCount = 4;',
'static constexpr int categoryCount = 5;'
)
if not already_has_plugins_vec:
content = content.replace(
' std::vector<SettingInfo> systemSettings;',
' std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;'
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if '"DarkModePlugin.h"' not in content:
content = content.replace(
'#include "SettingsList.h"',
'#include "SettingsList.h"\n#include "DarkModePlugin.h"'
)
if "STR_NONE_OPT" not in content:
content = content.replace(
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};',
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n'
' StrId::STR_NONE_OPT};'
)
if "pluginsSettings.clear();" not in content:
content = content.replace(
' systemSettings.clear();',
' systemSettings.clear();\n pluginsSettings.clear();'
)
plugins_block = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n'
' ));\n'
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n'
' ));\n'
)
broken_block = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT,\n'
' &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n'
' "darkModeState"\n'
' ));\n'
)
if broken_block in content:
content = content.replace(broken_block, plugins_block)
elif '"darkModeState"' in content and '"smallerFontsMode"' not in content:
content = content.replace(
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n'
' ));\n',
plugins_block
)
elif '"darkModeState"' not in content:
content = content.replace(
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n',
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n'
+ plugins_block
)
if 'case 4:\n currentSettings = &pluginsSettings;' not in content:
content = content.replace(
' case 3:\n currentSettings = &systemSettings;\n break;',
' case 3:\n currentSettings = &systemSettings;\n break;\n'
' case 4:\n currentSettings = &pluginsSettings;\n break;'
)
if '"Plugins"' not in content:
content = content.replace(
' std::vector<TabInfo> tabs;\n'
' tabs.reserve(categoryCount);\n'
' for (int i = 0; i < categoryCount; i++) {\n'
' tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});\n'
' }',
' std::vector<TabInfo> tabs;\n'
' tabs.reserve(categoryCount);\n'
' for (int i = 0; i < categoryCount; i++) {\n'
' const char* tabLabel = (i == 4) ? "Plugins" : I18N.get(categoryNames[i]);\n'
' tabs.push_back({tabLabel, selectedCategoryIndex == i});\n'
' }'
)
if 'nextCatLabel' not in content:
content = content.replace(
' const auto confirmLabel = (selectedSettingIndex == 0)\n'
' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n'
' : tr(STR_TOGGLE);',
' const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n'
' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n'
' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);'
)
if 'selectedCategoryIndex == 4' not in content:
content = content.replace(
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); },',
' [&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
' if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n'
' if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n'
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' },'
)
else:
for s_decl in [
' const auto& s = settings[index];\n',
' const auto& s = settings[index];\n',
]:
if s_decl in content and '"darkModeState") return "Dark Mode"' not in content:
content = content.replace(
s_decl,
s_decl +
s_decl[:len(s_decl) - len(s_decl.lstrip())] +
'if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n' +
s_decl[:len(s_decl) - len(s_decl.lstrip())] +
'if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n',
1
)
break
both_present = 'DarkModePlugin::stateName' in content and 'SmallerFontsPlugin::modeName' in content
only_dark = 'DarkModePlugin::stateName' in content and 'SmallerFontsPlugin::modeName' not in content
neither = 'DarkModePlugin::stateName' not in content
if only_dark:
content = content.replace(
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }',
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }'
)
elif neither:
if 'SmallerFontsPlugin::modeName' in content:
content = content.replace(
' if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }',
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }'
)
else:
content = content.replace(
' valueText = I18N.get(setting.enumValues[value]);',
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }'
)
write_file(path, content)
print(" SettingsActivity.cpp patched.")
def patch_epub_reader(repo_dir):
path = find_first("EpubReaderActivity.cpp", repo_dir)
content = read_file(path)
image_block_patched = (
'renderer.fillRect' in content and
'isDarkMode' in content.split('renderer.fillRect')[1].split('FAST_REFRESH')[0]
)
if "isDarkMode" in content and image_block_patched:
print(" EpubReaderActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "EpubReaderActivity.h"',
'#include "EpubReaderActivity.h"\n#include "activities/settings/DarkModePlugin.h"'
)
content = content.replace(
' page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);\n'
' renderStatusBar();\n'
' fcm->logStats("bw_render");\n',
' page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);\n'
' renderStatusBar();\n'
' if (DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState))) renderer.invertScreen();\n'
' fcm->logStats("bw_render");\n'
)
old_image_block = (
' if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {\n'
' renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false);\n'
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
'\n'
' // Re-render page content to restore images into the blanked area\n'
' // Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)\n'
' page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);\n'
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
' } else {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' }\n'
)
new_image_block = (
' if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) {\n'
' const bool darkMode = DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState));\n'
' renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, darkMode);\n'
' if (darkMode) renderer.invertScreen();\n'
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
'\n'
' page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);\n'
' if (darkMode) renderer.invertScreen();\n'
' renderer.displayBuffer(HalDisplay::FAST_REFRESH);\n'
' } else {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' }\n'
)
if old_image_block in content:
content = content.replace(old_image_block, new_image_block)
write_file(path, content)
print(" EpubReaderActivity.cpp patched.")
def patch_txt_reader(repo_dir):
path = find_first("TxtReaderActivity.cpp", repo_dir)
content = read_file(path)
if "isDarkMode" in content:
print(" TxtReaderActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "TxtReaderActivity.h"',
'#include "TxtReaderActivity.h"\n#include "activities/settings/DarkModePlugin.h"'
)
content = content.replace(
' renderLines();\n'
' renderStatusBar();\n'
'\n'
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);',
' renderLines();\n'
' renderStatusBar();\n'
' if (DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState))) renderer.invertScreen();\n'
'\n'
' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);'
)
write_file(path, content)
print(" TxtReaderActivity.cpp patched.")
def patch_xtc_reader(repo_dir):
path = find_first("XtcReaderActivity.cpp", repo_dir)
content = read_file(path)
if "isDarkMode" in content:
print(" XtcReaderActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "XtcReaderActivity.h"',
'#include "XtcReaderActivity.h"\n#include "activities/settings/DarkModePlugin.h"'
)
content = content.replace(
' if (pagesUntilFullRefresh <= 1) {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n'
' } else {\n'
' renderer.displayBuffer();\n'
' pagesUntilFullRefresh--;\n'
' }\n'
'\n'
' // Pass 2: LSB buffer',
' if (DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState))) renderer.invertScreen();\n'
' if (pagesUntilFullRefresh <= 1) {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n'
' } else {\n'
' renderer.displayBuffer();\n'
' pagesUntilFullRefresh--;\n'
' }\n'
'\n'
' // Pass 2: LSB buffer'
)
content = content.replace(
' if (pagesUntilFullRefresh <= 1) {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n'
' } else {\n'
' renderer.displayBuffer();\n'
' pagesUntilFullRefresh--;\n'
' }\n'
'\n'
' LOG_DBG("XTR"',
' if (DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState))) renderer.invertScreen();\n'
' if (pagesUntilFullRefresh <= 1) {\n'
' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n'
' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n'
' } else {\n'
' renderer.displayBuffer();\n'
' pagesUntilFullRefresh--;\n'
' }\n'
'\n'
' LOG_DBG("XTR"'
)
write_file(path, content)
print(" XtcReaderActivity.cpp patched.")
def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path)
if '"Dark Mode"' in content or '"Smaller Fonts"' in content:
print(" CrossPointWebServer.cpp already patched, skipping.")
return
name_overrides = (
'\n'
' if (s.key) {\n'
' if (strcmp(s.key, "darkModeState") == 0) {\n'
' doc["name"] = "Dark Mode";\n'
' doc["category"] = "Plugins";\n'
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' doc["name"] = "Smaller Fonts";\n'
' doc["category"] = "Plugins";\n'
' }\n'
' }\n'
)
content = content.replace(
' doc["category"] = I18N.get(s.category);\n'
'\n'
' switch (s.type) {',
' doc["category"] = I18N.get(s.category);\n'
+ name_overrides +
'\n'
' switch (s.type) {'
)
enum_overrides = (
' if (s.key) {\n'
' if (strcmp(s.key, "darkModeState") == 0) {\n'
' JsonArray opts = doc["options"].to<JsonArray>();\n'
' opts.add("Disabled");\n'
' opts.add("Enabled");\n'
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' JsonArray opts = doc["options"].to<JsonArray>();\n'
' opts.add("Disabled");\n'
' opts.add("Smaller");\n'
' opts.add("Smallest");\n'
' }\n'
' }\n'
)
content = content.replace(
' for (const auto& opt : s.enumValues) {\n'
' options.add(I18N.get(opt));\n'
' }\n'
' break;\n',
' for (const auto& opt : s.enumValues) {\n'
' options.add(I18N.get(opt));\n'
' }\n'
+ enum_overrides +
' break;\n'
)
write_file(path, content)
print(" CrossPointWebServer.cpp patched.")
def patch(repo_dir: str):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching CrossPointSettings.h...")
patch_cross_point_settings_h(repo_dir)
print(" Patching SettingsList.h...")
patch_settings_list_h(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
print(" Patching EpubReaderActivity.cpp...")
patch_epub_reader(repo_dir)
print(" Patching TxtReaderActivity.cpp...")
patch_txt_reader(repo_dir)
print(" Patching XtcReaderActivity.cpp...")
patch_xtc_reader(repo_dir)
print(" Patching CrossPointWebServer.cpp (web UI)...")
patch_web_server(repo_dir)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+37
View File
@@ -0,0 +1,37 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingsField, PluginsTabEntry, ReaderInvertHook,
)
def get_manifest(ctx):
return PluginManifest(
name="darkmode",
pretty_name="Dark Mode",
source_files=[
SourceFile("DarkModePlugin.h", "src/activities/settings"),
SourceFile("DarkModePlugin.cpp", "src/activities/settings"),
],
includes=[
Include("activities/settings/DarkModePlugin.h", "cross_point_settings_h"),
],
settings_fields=[
SettingsField("uint8_t darkModeState = 0;"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Dark Mode",
kind="enum",
key="darkModeState",
option_labels=["Disabled", "Enabled"],
value_text_expr="DarkModePlugin::stateName(static_cast<DarkModeState>(value))",
),
],
reader_invert_hook=ReaderInvertHook(
predicate_expr="DarkModePlugin::isDarkMode(static_cast<DarkModeState>(SETTINGS.darkModeState))",
include_header="activities/settings/DarkModePlugin.h",
),
)
-564
View File
@@ -1,564 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
import subprocess
import getpass
import csv
import tempfile
import ssl
import urllib.request
import urllib.error
import json
from pathlib import Path
NVS_NAMESPACE = "github_sync"
XTEINK_CONFIG_FILE = Path.home() / ".xteink"
def read_file(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
def write_file(path, content):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def find_first(filename, repo_dir):
results = glob.glob(os.path.join(repo_dir, "**", filename), recursive=True)
if not results:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def load_xteink_config() -> dict:
if not XTEINK_CONFIG_FILE.exists():
return {}
try:
with open(XTEINK_CONFIG_FILE, "r") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def save_xteink_config(cfg: dict) -> None:
try:
saved = load_xteink_config()
if "username" in cfg:
saved["githubuser"] = cfg["username"]
if "repo" in cfg:
saved["githubrepo"] = cfg["repo"]
if "branch" in cfg:
saved["githubbranch"] = cfg["branch"]
with open(XTEINK_CONFIG_FILE, "w") as f:
json.dump(saved, f, indent=2)
print(f" ✓ Config saved to {XTEINK_CONFIG_FILE}")
except Exception as e:
print(f" ! Could not save config: {e}")
_github_https_deps_ready = False
def ensure_python_module(module_name: str, pip_package: str | None = None, *, description: str = "") -> None:
pip_package = pip_package or module_name
try:
__import__(module_name)
return
except ImportError:
pass
desc = f" — {description}" if description else ""
print(f" ! Python module '{module_name}' is not installed{desc}.")
input(f" Press Enter to install {pip_package}... ")
result = subprocess.run(
[sys.executable, "-m", "pip", "install", pip_package],
capture_output=True, text=True,
)
if result.returncode != 0:
sys.exit(f"ERROR: Failed to install {pip_package}:\n{result.stderr.strip()}")
print(f" ✓ {pip_package} installed")
try:
__import__(module_name)
except ImportError:
sys.exit(f"ERROR: '{module_name}' still not importable after install")
def ensure_github_https_dependencies() -> None:
global _github_https_deps_ready
if _github_https_deps_ready:
return
if os.environ.get("GITHUB_SYNC_SSL_NO_VERIFY", "").strip().lower() in ("1", "true", "yes", "on"):
_github_https_deps_ready = True
return
cf = os.environ.get("SSL_CERT_FILE", "").strip()
if cf and os.path.isfile(cf):
_github_https_deps_ready = True
return
try:
import certifi # noqa: F401
_github_https_deps_ready = True
return
except ImportError:
pass
ensure_python_module(
"certifi",
description="recommended for GitHub HTTPS (fixes macOS SSL certificate errors)",
)
_github_https_deps_ready = True
def get_github_ssl_context():
flag = os.environ.get("GITHUB_SYNC_SSL_NO_VERIFY", "").strip().lower()
if flag in ("1", "true", "yes", "on"):
print(" ! SSL verification is OFF (GITHUB_SYNC_SSL_NO_VERIFY). Only use if you trust this network.")
return ssl._create_unverified_context()
cert_file = os.environ.get("SSL_CERT_FILE", "").strip()
if cert_file and os.path.isfile(cert_file):
return ssl.create_default_context(cafile=cert_file)
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
pass
return ssl.create_default_context()
def ssl_troubleshoot_hint(err_txt: str | None) -> str:
if not err_txt:
return ""
if "CERTIFICATE_VERIFY_FAILED" in err_txt or "SSL" in err_txt:
return (
" Try: pip install certifi (then re-run), or run macOS "
"'Install Certificates.command' for your Python, or set SSL_CERT_FILE to a CA bundle. "
"Last resort: GITHUB_SYNC_SSL_NO_VERIFY=1 (insecure)."
)
return ""
def github_api_get(path: str, token: str | None = None, timeout: int = 10):
url = f"https://api.github.com{path}"
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "xteink-github-sync-patcher",
}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
ctx = get_github_ssl_context()
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
body = resp.read().decode("utf-8", errors="replace")
data = json.loads(body) if body else {}
return resp.status, data, None
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
try:
data = json.loads(body) if body else {}
except Exception:
data = {"message": body.strip()} if body else {}
return e.code, data, None
except Exception as e:
return None, None, str(e)
def validate_github_username(username: str) -> tuple[bool, str]:
status, data, err_txt = github_api_get(f"/users/{username}")
if err_txt:
return False, f"Could not reach GitHub API: {err_txt}{ssl_troubleshoot_hint(err_txt)}"
if status == 200:
return True, "GitHub username is reachable."
if status == 404:
return False, "Username not found on GitHub."
msg = data.get("message", "Unknown API error") if isinstance(data, dict) else "Unknown API error"
return False, f"GitHub API error ({status}): {msg}"
def validate_pat(token: str) -> tuple[bool, str, str | None]:
status, data, err_txt = github_api_get("/user", token=token)
if err_txt:
return False, f"Could not validate PAT: {err_txt}{ssl_troubleshoot_hint(err_txt)}", None
if status == 200 and isinstance(data, dict):
login = data.get("login")
return True, f"PAT is valid (authenticated as {login}).", login
if status in (401, 403):
msg = data.get("message", "Unauthorized") if isinstance(data, dict) else "Unauthorized"
return False, f"PAT rejected: {msg}", None
msg = data.get("message", "Unknown API error") if isinstance(data, dict) else "Unknown API error"
return False, f"PAT validation failed ({status}): {msg}", None
def validate_repo_access(owner: str, repo: str, token: str) -> tuple[bool, str]:
status, data, err_txt = github_api_get(f"/repos/{owner}/{repo}", token=token)
if err_txt:
return False, f"Could not validate repo access: {err_txt}{ssl_troubleshoot_hint(err_txt)}"
if status == 200:
return True, "Repo is reachable with this PAT."
if status == 404:
return False, "Repo not found, or PAT cannot access it."
if status in (401, 403):
msg = data.get("message", "Unauthorized") if isinstance(data, dict) else "Unauthorized"
return False, f"Access denied: {msg}"
msg = data.get("message", "Unknown API error") if isinstance(data, dict) else "Unknown API error"
return False, f"Repo validation failed ({status}): {msg}"
def prompt_github_config() -> dict:
print("\n GitHub Sync Configuration by Justin Oros")
print(" Press Enter on any field to use the saved value, or type a new one.")
print(" Leave GitHub username blank to skip and configure on-device later.\n")
saved = load_xteink_config()
saved_user = saved.get("githubuser", "")
saved_repo = saved.get("githubrepo", "xteink")
saved_branch = saved.get("githubbranch", "main")
while True:
if saved_user:
raw = input(f" GitHub username ({saved_user}): ").strip()
username = raw or saved_user
else:
username = input(" GitHub username: ").strip()
if not username:
return {}
ensure_github_https_dependencies()
ok_user, msg = validate_github_username(username)
if ok_user:
print(f" ✓ {msg}")
break
print(f" ! {msg}")
retry = input(" Try another username? [Y/n]: ").strip().lower()
if retry == "n":
return {}
print("\n To generate a Personal Access Token (PAT):")
print(" 1. Go to github.com -> Settings -> Developer settings")
print(" 2. Personal access tokens -> Fine-grained tokens")
print(" 3. Click 'Generate new token'")
print(" 4. Token name: xteink")
print(" 5. Expiration: No expiration")
print(" 6. Repository access: Only selected repositories -> select 'xteink'")
print(" 7. Permissions -> Add permissions -> Contents: Read-only")
print(" 8. Click 'Generate token' then copy it - GitHub only shows it once\n")
authenticated_login = None
while True:
pat = getpass.getpass(" Personal Access Token (PAT): ").strip()
if not pat:
return {}
ok_pat, msg, authenticated_login = validate_pat(pat)
if ok_pat:
print(f" ✓ {msg}")
break
print(f" ! {msg}")
retry = input(" Try entering PAT again? [Y/n]: ").strip().lower()
if retry == "n":
return {}
while True:
raw = input(f" Repo name ({saved_repo}): ").strip()
repo = raw or saved_repo
owner_for_repo = authenticated_login or username
ok_repo, msg = validate_repo_access(owner_for_repo, repo, pat)
if ok_repo:
print(f" ✓ {msg}")
if authenticated_login and authenticated_login != username:
print(f" ! Username '{username}' differs from PAT owner '{authenticated_login}'. Repo was validated under '{owner_for_repo}'.")
break
print(f" ! {msg}")
retry = input(" Try another repo name? [Y/n]: ").strip().lower()
if retry == "n":
return {}
raw = input(f" Branch ({saved_branch}): ").strip()
branch = raw or saved_branch
cfg = {"username": username, "pat": pat, "repo": repo, "branch": branch}
save_xteink_config(cfg)
return cfg
def check_nvs_gen() -> bool:
result = subprocess.run(
["python3", "-m", "esp_idf_nvs_partition_gen", "--help"],
capture_output=True, text=True
)
if result.returncode == 0:
return True
result2 = subprocess.run(
[sys.executable, "-m", "pip", "install", "esp-idf-nvs-partition-gen"],
capture_output=True, text=True
)
result3 = subprocess.run(
["python3", "-m", "esp_idf_nvs_partition_gen", "--help"],
capture_output=True, text=True
)
return result3.returncode == 0
def write_nvs_partition(cfg: dict) -> Path | None:
if not check_nvs_gen():
print(" ! esp-idf-nvs-partition-gen unavailable — enter credentials on-device via Settings -> GitHub Sync.")
return None
nvs_csv = Path(tempfile.mkdtemp()) / "github_sync_nvs.csv"
rows = [
["key", "type", "encoding", "value"],
[NVS_NAMESPACE, "namespace", "", ""],
["username", "data", "string", cfg["username"]],
["pat", "data", "string", cfg["pat"]],
["repo", "data", "string", cfg["repo"]],
["branch", "data", "string", cfg["branch"]],
]
with open(nvs_csv, "w", newline="") as f:
writer = csv.writer(f)
writer.writerows(rows)
nvs_bin = nvs_csv.with_suffix(".bin")
result = subprocess.run(
["python3", "-m", "esp_idf_nvs_partition_gen", "generate",
str(nvs_csv), str(nvs_bin), "0x3000"],
capture_output=True, text=True
)
if result.returncode != 0:
print(f" ! nvs_partition_gen failed — enter credentials on-device via Settings -> GitHub Sync.\n{result.stderr.strip()}")
return None
print(f" ✓ NVS partition written to {nvs_bin}")
return nvs_bin
def copy_plugin_sources(plugin_dir, repo_dir):
copies = [
(os.path.join(plugin_dir, "GitHubSync.h"),
os.path.join(repo_dir, "include", "GitHubSync.h")),
(os.path.join(plugin_dir, "GitHubSyncSettingsActivity.h"),
os.path.join(repo_dir, "include", "GitHubSyncSettingsActivity.h")),
(os.path.join(plugin_dir, "GitHubSync.cpp"),
os.path.join(repo_dir, "src", "github_sync", "GitHubSync.cpp")),
(os.path.join(plugin_dir, "GitHubSyncSettingsActivity.cpp"),
os.path.join(repo_dir, "src", "activities", "settings", "GitHubSyncSettingsActivity.cpp")),
]
for src, dst in copies:
if not os.path.exists(src):
sys.exit(f"ERROR: Patch file missing: {src}")
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
print(f" ✓ {os.path.basename(dst)}")
def patch_platformio_ini(repo_dir):
ini = os.path.join(repo_dir, "platformio.ini")
if not os.path.exists(ini):
print(" platformio.ini not found, skipping.")
return
content = read_file(ini)
if "ArduinoJson" in content:
print(" platformio.ini already patched, skipping.")
return
content = content.replace(
"lib_deps",
"lib_deps\n\tbblanchon/ArduinoJson @ ^7",
1
)
write_file(ini, content)
print(" platformio.ini patched.")
def patch_main_cpp(repo_dir):
candidates = [
os.path.join(repo_dir, "src", "main.cpp"),
os.path.join(repo_dir, "src", "Main.cpp"),
]
main_path = next((p for p in candidates if os.path.exists(p)), None)
if not main_path:
print(" ! Could not find main.cpp — add GitHub sync call manually.")
return
content = read_file(main_path)
if '#include "GitHubSync.h"' in content:
print(" main.cpp already patched, skipping.")
return
content = content.replace(
'#include "CrossPointSettings.h"',
'#include "GitHubSync.h"\n#include "CrossPointSettings.h"',
1
)
sync_call = (
'\n if (GitHubSync::isConfigured()) {\n'
' GitHubSyncResult result = GitHubSync::sync();\n'
' if (result != GitHubSyncResult::OK) {\n'
' LOG_ERR("SYNC", "%s", GitHubSync::resultMessage(result));\n'
' }\n'
' }\n'
)
if "GitHubSync::isConfigured" not in content:
content = content.replace(
"activityManager.goToBoot();",
"activityManager.goToBoot();" + sync_call,
1
)
write_file(main_path, content)
print(" main.cpp patched.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
if "GitHubSync," in content:
print(" SettingsActivity.h already patched, skipping.")
return
content = content.replace(
" CheckForUpdates,",
" CheckForUpdates,\n GitHubSync,",
1
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if '#include "GitHubSyncSettingsActivity.h"' in content and "SettingAction::GitHubSync" in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
if '#include "GitHubSyncSettingsActivity.h"' not in content:
content = content.replace(
'#include "SettingsActivity.h"',
'#include "SettingsActivity.h"\n#include "GitHubSyncSettingsActivity.h"',
1
)
if "SettingAction::GitHubSync" not in content:
content = content.replace(
"SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));",
"SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));\n"
" pluginsSettings.push_back(SettingInfo::Action(StrId::STR_NONE_OPT, SettingAction::GitHubSync));",
1
)
if "case SettingAction::GitHubSync:" not in content:
content = content.replace(
"case SettingAction::CheckForUpdates:",
"case SettingAction::CheckForUpdates:\n"
" case SettingAction::GitHubSync:\n"
" startActivityForResult(std::make_unique<GitHubSyncSettingsActivity>(renderer, mappedInput), resultHandler);\n"
" break;",
1
)
if '"GitHub Sync"' not in content:
content = content.replace(
'[&settings, this](int index) -> std::string {\n',
'[&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
' if (s.type == SettingType::ACTION && s.action == SettingAction::GitHubSync) return "GitHub Sync";\n'
' }\n',
1
)
if '"Installed"' not in content:
content = content.replace(
'} else if (setting.type == SettingType::ACTION && setting.action == SettingAction::BookerlyInstalled) {\n'
' valueText = "Installed";\n',
'} else if (setting.type == SettingType::ACTION && setting.action == SettingAction::BookerlyInstalled) {\n'
' valueText = "Installed";\n'
' } else if (setting.type == SettingType::ACTION && setting.action == SettingAction::GitHubSync) {\n'
' valueText = "Sync";\n',
1
)
elif "GitHubSync" not in content.split('valueText = "Installed"')[0].split('BookerlyInstalled')[-1]:
content = content.replace(
'} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {\n'
' valueText = std::to_string(SETTINGS.*(setting.valuePtr));\n'
' }',
'} else if (setting.type == SettingType::ACTION && setting.action == SettingAction::GitHubSync) {\n'
' valueText = "Sync";\n'
' } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {\n'
' valueText = std::to_string(SETTINGS.*(setting.valuePtr));\n'
' }',
1
)
write_file(path, content)
print(" SettingsActivity.cpp patched.")
def patch_translation_files(repo_dir):
yaml_dir = os.path.join(repo_dir, "lib", "I18n", "translations")
if not os.path.isdir(yaml_dir):
print(" ! Could not find translation YAML files — add STR_GITHUB_SYNC manually.")
return
yaml_files = glob.glob(os.path.join(yaml_dir, "*.yaml"))
if not yaml_files:
print(" ! No translation YAML files found.")
return
patched = 0
for yf in yaml_files:
content = read_file(yf)
if "STR_GITHUB_SYNC" in content:
patched += 1
continue
lines = content.splitlines(keepends=True)
new_lines = []
inserted = False
for line in lines:
new_lines.append(line)
if line.startswith("STR_CHECK_UPDATES:"):
new_lines.append('STR_GITHUB_SYNC: "GitHub Sync"\n')
inserted = True
if inserted:
write_file(yf, "".join(new_lines))
patched += 1
else:
print(f" ! STR_CHECK_UPDATES not found in {os.path.basename(yf)} — add STR_GITHUB_SYNC manually.")
print(f" Translation files patched ({patched} files).")
def patch(repo_dir: str, yes_all: bool = False):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching platformio.ini...")
patch_platformio_ini(repo_dir)
print(" Patching main.cpp...")
patch_main_cpp(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
print(" Patching translation files...")
patch_translation_files(repo_dir)
if not yes_all:
print("\n GitHub Sync credentials (optional — can also be set on-device).")
answer = input(" Configure GitHub credentials now? [Y/n]: ").strip().lower()
if answer in ("", "y", "yes"):
cfg = prompt_github_config()
if cfg:
nvs_bin = write_nvs_partition(cfg)
if nvs_bin:
print(f"\n NVS partition ready: {nvs_bin}")
print(" Flash it after firmware upload with:")
print(f" esptool.py --chip esp32c3 write_flash 0x9000 {nvs_bin}")
else:
print(" Skipping credential prompt (--yes mode). Configure on-device via Settings -> GitHub Sync.")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+66
View File
@@ -0,0 +1,66 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingActionEnumValue, PluginsTabEntry, MainHook,
)
# NOTE on scope: the original patch.py also pre-provisioned GitHub credentials
# directly into a reserved NVS flash partition at install time (via a
# generated nvs.csv + `esptool ... write-flash 0x3000`), so users wouldn't
# have to type a PAT on-device. That's flash-time, not source-patch-time
# logic, and GitHubSyncSettingsActivity already lets you configure
# username/PAT/repo/branch on-device (or you can add web-UI editing later),
# so this rewrite drops the NVS pre-seeding step for simplicity/safety and
# has you configure GitHub Sync via Settings -> Plugins -> GitHub Sync after
# first boot instead. Ask if you want the NVS pre-seeding restored.
#
# Also fixed in this rewrite: the original main.cpp hook only ran
# GitHubSync::sync() inside one narrow boot branch (quick-resume-with-no-
# saved-frame), so on most ordinary boots it silently never synced. It's now
# a "post_boot" hook that runs exactly once, every boot, after the routing
# decision - regardless of which plugins are installed alongside it.
def get_manifest(ctx):
return PluginManifest(
name="githubsync",
pretty_name="GitHub Sync",
source_files=[
SourceFile("GitHubSync.h", "src/activities/settings"),
SourceFile("GitHubSync.cpp", "src/activities/settings"),
SourceFile("GitHubSyncSettingsActivity.h", "src/activities/settings"),
SourceFile("GitHubSyncSettingsActivity.cpp", "src/activities/settings"),
],
includes=[
Include("GitHubSync.h", "settings_activity_cpp"),
Include("GitHubSyncSettingsActivity.h", "settings_activity_cpp"),
Include("activities/settings/GitHubSync.h", "main_cpp"),
],
setting_actions=[
SettingActionEnumValue("GitHubSync"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="GitHub Sync",
kind="action",
action_name="GitHubSync",
action_value_text="Sync",
activity_launch_expr="std::make_unique<GitHubSyncSettingsActivity>(renderer, mappedInput)",
),
],
main_hooks=[
MainHook(
point="post_boot",
code=(
" if (GitHubSync::isConfigured()) {\n"
" GitHubSyncResult ghSyncResult = GitHubSync::sync();\n"
" if (ghSyncResult != GitHubSyncResult::OK) {\n"
' LOG_ERR("SYNC", "%s", GitHubSync::resultMessage(ghSyncResult));\n'
" }\n"
" }"
),
),
],
)
+4 -4
View File
@@ -119,7 +119,7 @@ std::string getIsbnFromEpub(Epub& epub, const std::string& path) {
// ---- Progress reading -------------------------------------------------------
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);
-415
View File
@@ -1,415 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
def read_file(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
def write_file(path, content):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def find_first(filename, repo_dir):
results = glob.glob(os.path.join(repo_dir, "**", filename), recursive=True)
if not results:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def copy_plugin_sources(plugin_dir, repo_dir):
dest = os.path.join(repo_dir, "src", "activities", "settings")
os.makedirs(dest, exist_ok=True)
for fname in os.listdir(plugin_dir):
if fname.endswith((".h", ".cpp", ".hpp")):
src = os.path.join(plugin_dir, fname)
dst = os.path.join(dest, fname)
shutil.copy2(src, dst)
print(f" \u2713 {fname}")
def patch_cross_point_settings_h(repo_dir, token=""):
path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path)
if "hardcoverApiToken" in content:
print(" CrossPointSettings.h already patched, skipping.")
return
safe_token = token.replace('\\', '\\\\').replace('"', '\\"')
content = content.replace(
' uint8_t imageRendering = IMAGES_DISPLAY;',
f' uint8_t imageRendering = IMAGES_DISPLAY;\n char hardcoverApiToken[640] = "{safe_token}";'
)
write_file(path, content)
print(f" CrossPointSettings.h patched{' (token embedded)' if token else ' (no token)'}.")
def patch_settings_list_h(repo_dir):
path = find_first("SettingsList.h", repo_dir)
content = read_file(path)
if "hardcoverApiToken" in content:
print(" SettingsList.h already patched, skipping.")
return
content = content.replace(
' };\n return list;\n}\n',
' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.hardcoverApiToken,\n'
' sizeof(SETTINGS.hardcoverApiToken), "hardcoverApiToken")\n'
' .withObfuscated(),\n'
' };\n return list;\n}\n'
)
write_file(path, content)
print(" SettingsList.h patched.")
def patch_setting_action_enum(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
if "HardcoverSync" in content:
print(" SettingAction enum already patched, skipping.")
return
for anchor in [
' Language,\n};',
' BookerlyInstalled,\n};',
' GitHubSync,\n Language,\n};',
' Language,\n BookerlyInstalled,\n};',
' GitHubSync,\n Language,\n BookerlyInstalled,\n};',
]:
if anchor in content:
content = content.replace(anchor, anchor[:-2] + ' HardcoverSync,\n};')
break
else:
import re
content = re.sub(r'(\n};)', r'\n HardcoverSync,\1', content, count=1)
write_file(path, content)
print(" SettingAction::HardcoverSync added.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
already_has_tab = "categoryCount = 5" in content
already_has_vec = "pluginsSettings" in content
if already_has_tab and already_has_vec:
print(" SettingsActivity.h already patched, skipping.")
return
if not already_has_tab:
content = content.replace(
'static constexpr int categoryCount = 4;',
'static constexpr int categoryCount = 5;'
)
if not already_has_vec:
content = content.replace(
' std::vector<SettingInfo> systemSettings;',
' std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;'
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if "HardcoverPlugin::syncProgress" in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "SettingsList.h"',
'#include "SettingsList.h"\n#include "HardcoverPlugin.h"\n#include "HardcoverSyncActivity.h"'
)
if 'StrId::STR_NONE_OPT};' not in content and 'STR_NONE_OPT' not in content.split('categoryNames')[1].split(';')[0]:
content = content.replace(
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};',
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n'
' StrId::STR_NONE_OPT};'
)
if "pluginsSettings.clear();" not in content:
content = content.replace(
' systemSettings.clear();',
' systemSettings.clear();\n pluginsSettings.clear();'
)
hardcover_push = (
' pluginsSettings.push_back(SettingInfo::Action(\n'
' StrId::STR_NONE_OPT, SettingAction::HardcoverSync\n'
' ));\n'
)
if 'SettingAction::HardcoverSync' not in content:
anchors = [
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n'
' ));\n',
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n'
' ));\n',
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n',
]
for anchor in anchors:
if anchor in content:
content = content.replace(anchor, anchor + hardcover_push)
break
if 'case 4:\n currentSettings = &pluginsSettings;' not in content:
content = content.replace(
' case 3:\n currentSettings = &systemSettings;\n break;',
' case 3:\n currentSettings = &systemSettings;\n break;\n'
' case 4:\n currentSettings = &pluginsSettings;\n break;'
)
if '"Plugins"' not in content:
content = content.replace(
' std::vector<TabInfo> tabs;\n'
' tabs.reserve(categoryCount);\n'
' for (int i = 0; i < categoryCount; i++) {\n'
' tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});\n'
' }',
' std::vector<TabInfo> tabs;\n'
' tabs.reserve(categoryCount);\n'
' for (int i = 0; i < categoryCount; i++) {\n'
' const char* tabLabel = (i == 4) ? "Plugins" : I18N.get(categoryNames[i]);\n'
' tabs.push_back({tabLabel, selectedCategoryIndex == i});\n'
' }'
)
if 'nextCatLabel' not in content:
content = content.replace(
' const auto confirmLabel = (selectedSettingIndex == 0)\n'
' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n'
' : tr(STR_TOGGLE);',
' const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n'
' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n'
' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);'
)
hardcover_label_line = (
' if (s.type == SettingType::ACTION &&\n'
' s.action == SettingAction::HardcoverSync) return "Hardcover Progress";\n'
)
if 'Hardcover Progress' not in content:
if 'selectedCategoryIndex == 4' in content:
closing = (
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' }, nullptr, nullptr,'
)
closing_bookerly = (
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' }, nullptr, nullptr,'
)
for last_key_line in [
' if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n',
' if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n',
' s.action == SettingAction::GitHubSync) return "GitHub Sync";\n',
' s.action == SettingAction::BookerlyInstalled) return "Bookerly Font";\n',
]:
for cl in [closing, closing_bookerly]:
anchor = last_key_line + cl
if anchor in content:
content = content.replace(anchor, last_key_line + hardcover_label_line + cl)
break
else:
continue
break
else:
for cl in [closing, closing_bookerly]:
if cl in content:
content = content.replace(cl, hardcover_label_line + cl, 1)
break
else:
old_lambda = (
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,'
)
new_lambda = (
' [&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
+ hardcover_label_line +
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' }, nullptr, nullptr,'
)
content = content.replace(old_lambda, new_lambda)
hardcover_value_block = (
' if (setting.type == SettingType::ACTION &&\n'
' setting.action == SettingAction::HardcoverSync) {\n'
' valueText = "Sync";\n'
' } else '
)
if 'SettingAction::HardcoverSync) {\n valueText = "Sync"' not in content:
for value_anchor in [
' if (setting.type == SettingType::ACTION &&\n'
' setting.action == SettingAction::GoodreadsSync) {\n'
' valueText = "Sync";\n'
' } else if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {',
' if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {',
]:
if value_anchor in content:
content = content.replace(value_anchor, hardcover_value_block + value_anchor)
break
hardcover_case = (
' case SettingAction::HardcoverSync:\n'
' startActivityForResult(std::make_unique<HardcoverSyncActivity>(renderer, mappedInput), resultHandler);\n'
' break;\n'
)
if 'case SettingAction::HardcoverSync:' not in content:
for anchor in [
' case SettingAction::GoodreadsSync:\n'
' startActivityForResult(std::make_unique<GoodreadsSyncActivity>(renderer, mappedInput), resultHandler);\n'
' break;\n'
' case SettingAction::None:',
' case SettingAction::Language:\n'
' startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);\n'
' break;\n'
' case SettingAction::None:',
]:
if anchor in content:
content = content.replace(anchor, hardcover_case + anchor)
break
write_file(path, content)
print(" SettingsActivity.cpp patched.")
def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path)
if '"hardcoverApiToken"' in content or '"Hardcover API Token"' in content:
print(" CrossPointWebServer.cpp already patched, skipping.")
return
hardcover_block = (
' if (strcmp(s.key, "hardcoverApiToken") == 0) {\n'
' doc["name"] = "Hardcover API Token";\n'
' doc["category"] = "Plugins";\n'
' }\n'
)
if 'if (s.key) {' in content and 'darkModeState' in content:
content = content.replace(
' if (strcmp(s.key, "darkModeState") == 0) {\n',
hardcover_block +
' if (strcmp(s.key, "darkModeState") == 0) {\n'
)
else:
content = content.replace(
' doc["category"] = I18N.get(s.category);\n'
'\n'
' switch (s.type) {',
' doc["category"] = I18N.get(s.category);\n'
'\n'
' if (s.key) {\n'
+ hardcover_block +
' }\n'
'\n'
' switch (s.type) {'
)
write_file(path, content)
print(" CrossPointWebServer.cpp patched.")
def patch(repo_dir: str, yes_all: bool = False):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
hardcover_file = os.path.expanduser("~/.hardcover")
token = ""
if os.path.exists(hardcover_file):
raw = open(hardcover_file).read().strip()
if raw.startswith("Bearer "):
raw = raw[len("Bearer "):]
token = raw
if token:
print(f" Using Hardcover API token from ~/.hardcover.")
else:
print(" Hardcover API token required.")
print(" Get yours from https://hardcover.app/account/api")
print(" Paste your Hardcover API token below.")
while not token:
raw = input(" Hardcover API token: ").strip()
if raw.startswith("Bearer "):
raw = raw[len("Bearer "):]
token = raw
if not token:
print(" Token cannot be empty. Please enter your Hardcover API token.")
with open(hardcover_file, "w") as f:
f.write(f"Bearer {token}\n")
print(f" Token saved to ~/.hardcover for future installs.")
if len(token) >= 640:
sys.exit(f"ERROR: Hardcover API token is too long ({len(token)} chars, max 639).")
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching CrossPointSettings.h...")
patch_cross_point_settings_h(repo_dir, token)
print(" Patching SettingsList.h...")
patch_settings_list_h(repo_dir)
print(" Patching SettingAction enum...")
patch_setting_action_enum(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
print(" Patching CrossPointWebServer.cpp (web UI)...")
patch_web_server(repo_dir)
import glob as _glob
cpp_results = _glob.glob(os.path.join(repo_dir, "**", "SettingsActivity.cpp"), recursive=True)
if cpp_results:
cpp_content = open(cpp_results[0]).read()
checks = [
("label lambda: 'Hardcover Progress'", "Hardcover Progress" in cpp_content),
("switch case: HardcoverSync", "case SettingAction::HardcoverSync:" in cpp_content),
("HardcoverSyncActivity launched", "HardcoverSyncActivity" in cpp_content),
("pluginsSettings.clear()", "pluginsSettings.clear();" in cpp_content),
("case 4 -> pluginsSettings", "case 4:" in cpp_content),
]
all_ok = True
for label, ok in checks:
print(f" [{'PASS' if ok else 'FAIL'}] {label}")
if not ok:
all_ok = False
if not all_ok:
sys.exit("ERROR: One or more Hardcover patch checks failed.")
print(" Hardcover plugin patch verified OK.")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+83
View File
@@ -0,0 +1,83 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingsField, PluginsTabEntry, SettingActionEnumValue,
)
HARDCOVER_TOKEN_FILE = os.path.expanduser("~/.hardcover")
def _get_token(ctx):
token = ""
if os.path.exists(HARDCOVER_TOKEN_FILE):
raw = open(HARDCOVER_TOKEN_FILE).read().strip()
if raw.startswith("Bearer "):
raw = raw[len("Bearer "):]
token = raw
if token:
print(f" [hardcover] Using API token from {HARDCOVER_TOKEN_FILE}.")
return token
if not ctx.prompt:
return ""
print(" [hardcover] Hardcover API token required.")
print(" [hardcover] Get yours from https://hardcover.app/account/api")
while not token:
raw = input(" Hardcover API token: ").strip()
if raw.startswith("Bearer "):
raw = raw[len("Bearer "):]
token = raw
if not token:
print(" Token cannot be empty.")
with open(HARDCOVER_TOKEN_FILE, "w") as f:
f.write(f"Bearer {token}\n")
print(f" [hardcover] Token saved to {HARDCOVER_TOKEN_FILE} for future installs.")
return token
def get_manifest(ctx):
token = _get_token(ctx)
if len(token) >= 640:
raise SystemExit(f"ERROR: Hardcover API token is too long ({len(token)} chars, max 639).")
safe_token = token.replace("\\", "\\\\").replace('"', '\\"')
return PluginManifest(
name="hardcover",
pretty_name="Hardcover Sync",
source_files=[
SourceFile("HardcoverPlugin.h", "src/activities/settings"),
SourceFile("HardcoverPlugin.cpp", "src/activities/settings"),
SourceFile("HardcoverSyncActivity.h", "src/activities/settings"),
SourceFile("HardcoverSyncActivity.cpp", "src/activities/settings"),
],
includes=[
Include("HardcoverPlugin.h", "settings_activity_cpp"),
Include("HardcoverSyncActivity.h", "settings_activity_cpp"),
],
settings_fields=[
SettingsField(f'char hardcoverApiToken[640] = "{safe_token}";'),
],
setting_actions=[
SettingActionEnumValue("HardcoverSync"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Hardcover API Token",
kind="string",
key="hardcoverApiToken",
obfuscated=True,
show_on_device=False,
),
PluginsTabEntry(
label="Hardcover Progress",
kind="action",
action_name="HardcoverSync",
action_value_text="Sync",
activity_launch_expr="std::make_unique<HardcoverSyncActivity>(renderer, mappedInput)",
),
],
)
-547
View File
@@ -1,547 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
def read_file(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
def write_file(path, content):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def find_first(filename, repo_dir):
results = glob.glob(os.path.join(repo_dir, "**", filename), recursive=True)
if not results:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def copy_plugin_sources(plugin_dir, repo_dir):
settings_dest = os.path.join(repo_dir, "src", "activities", "settings")
activities_dest = os.path.join(repo_dir, "src", "activities")
os.makedirs(settings_dest, exist_ok=True)
settings_files = {
"LockscreenSettingsPage.h", "LockscreenSettingsPage.cpp",
"LockscreenPlugin.h", "LockscreenPlugin.cpp",
}
for fname in os.listdir(plugin_dir):
if not fname.endswith((".h", ".cpp", ".hpp")):
continue
src = os.path.join(plugin_dir, fname)
dst = os.path.join(settings_dest if fname in settings_files else activities_dest, fname)
shutil.copy2(src, dst)
print(f" ✓ {fname}")
def patch_cross_point_settings_h(repo_dir):
path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path)
if "lockscreenMode" in content:
print(" CrossPointSettings.h already patched, skipping.")
return
content = content.replace(
'#pragma once\n#include <HalStorage.h>',
'#pragma once\n#include <HalStorage.h>\n#include "activities/settings/LockscreenPlugin.h"'
)
content = content.replace(
' uint8_t imageRendering = IMAGES_DISPLAY;',
' uint8_t imageRendering = IMAGES_DISPLAY;\n'
' uint8_t lockscreenMode = 0;\n'
' char lockscreenPinHash[9] = {};'
)
write_file(path, content)
print(" CrossPointSettings.h patched.")
def patch_settings_list_h(repo_dir):
path = find_first("SettingsList.h", repo_dir)
content = read_file(path)
if "lockscreenMode" in content and "lockscreenPinHash" in content:
if '{StrId::STR_NONE_OPT, StrId::STR_NONE_OPT,\n StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}' in content:
content = content.replace(
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT,\n'
' StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n',
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n'
)
write_file(path, content)
print(" SettingsList.h patched (reduced lockscreenMode to 2 options).")
else:
print(" SettingsList.h already patched, skipping.")
return
if "lockscreenMode" in content and "lockscreenPinHash" not in content:
content = content.replace(
' "lockscreenMode", StrId::STR_NONE_OPT),\n',
' "lockscreenMode", StrId::STR_NONE_OPT),\n'
' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.lockscreenPinHash,\n'
' sizeof(SETTINGS.lockscreenPinHash),\n'
' "lockscreenPinHash", StrId::STR_NONE_OPT),\n'
)
write_file(path, content)
print(" SettingsList.h patched (added lockscreenPinHash).")
return
entry = (
'\n'
' SettingInfo::Enum(StrId::STR_NONE_OPT, &CrossPointSettings::lockscreenMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n'
' "lockscreenMode", StrId::STR_NONE_OPT),\n'
' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.lockscreenPinHash,\n'
' sizeof(SETTINGS.lockscreenPinHash),\n'
' "lockscreenPinHash", StrId::STR_NONE_OPT),\n'
)
content = content.replace(
' };\n return list;\n}\n',
entry + ' };\n return list;\n}\n'
)
write_file(path, content)
print(" SettingsList.h patched.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
already_has_plugins_tab = "categoryCount = 5" in content
already_has_plugins_vec = "pluginsSettings" in content
if already_has_plugins_tab and already_has_plugins_vec:
print(" SettingsActivity.h already patched, skipping.")
return
if not already_has_plugins_tab:
content = content.replace(
'static constexpr int categoryCount = 4;',
'static constexpr int categoryCount = 5;'
)
if not already_has_plugins_vec:
content = content.replace(
' std::vector<SettingInfo> systemSettings;',
' std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;'
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if '"LockscreenPlugin.h"' in content and 'lockscreenPinHash' in content and '"lockscreenMode") return "Lockscreen"' in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
changed = False
if '"LockscreenPlugin.h"' not in content:
content = content.replace(
'#include "SettingsList.h"',
'#include "SettingsList.h"\n'
'#include "activities/settings/LockscreenPlugin.h"\n'
'#include "activities/LockscreenActivity.h"'
)
changed = True
if 'STR_NONE_OPT};' not in content:
content = content.replace(
'StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};',
'StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n'
' StrId::STR_NONE_OPT};'
)
changed = True
if 'pluginsSettings.clear()' not in content:
result = content.replace(
' systemSettings.clear();\n',
' systemSettings.clear();\n pluginsSettings.clear();\n'
)
if 'pluginsSettings.clear()' in result:
content = result
changed = True
lockscreen_push = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::lockscreenMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n'
' "lockscreenMode", StrId::STR_NONE_OPT\n'
' ));\n'
)
if '"lockscreenMode", StrId::STR_NONE_OPT' not in content:
for anchor in [
' selectedCategoryIndex = 0;\n selectedSettingIndex = 0;',
' selectedCategoryIndex = 0;',
' currentSettings = &displaySettings;',
]:
if anchor in content:
content = content.replace(anchor, lockscreen_push + anchor, 1)
changed = True
break
if 'case 4:' not in content:
content = content.replace(
' case 3:\n'
' currentSettings = &systemSettings;\n'
' break;\n',
' case 3:\n'
' currentSettings = &systemSettings;\n'
' break;\n'
' case 4:\n'
' currentSettings = &pluginsSettings;\n'
' break;\n'
)
changed = True
if '"Plugins"' not in content:
content = content.replace(
' tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});\n'
' }',
' const char* tabLabel = (i == 4) ? "Plugins" : I18N.get(categoryNames[i]);\n'
' tabs.push_back({tabLabel, selectedCategoryIndex == i});\n'
' }'
)
changed = True
if 'nextCatIdx' not in content:
content = content.replace(
' const auto confirmLabel = (selectedSettingIndex == 0)\n'
' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n'
' : tr(STR_TOGGLE);',
' const int nextCatIdx = (selectedCategoryIndex + 1) % categoryCount;\n'
' const char* nextCatLabel = (nextCatIdx == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIdx]);\n'
' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);'
)
changed = True
if 'selectedCategoryIndex == 4' not in content:
content = content.replace(
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); },',
' [&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
' if (s.key && std::string(s.key) == "lockscreenMode") return "Lockscreen";\n'
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' },'
)
changed = True
elif '"lockscreenMode") return "Lockscreen"' not in content:
import re
result = re.sub(
r'( if \(selectedCategoryIndex == 4\) \{.*?)( \})',
lambda m: m.group(1) + ' if (s.key && std::string(s.key) == "lockscreenMode") return "Lockscreen";\n' + m.group(2),
content,
count=1,
flags=re.DOTALL,
)
if result != content:
content = result
changed = True
if 'LockscreenPlugin::modeName' not in content:
import re
lockscreen_value = (
' if (setting.type == SettingType::ENUM &&\n'
' setting.key && std::string(setting.key) == "lockscreenMode") {\n'
' valueText = LockscreenPlugin::modeName(static_cast<LockscreenMode>(SETTINGS.lockscreenMode));\n'
' } else '
)
result = re.sub(
r'( if \(setting\.type == SettingType::(?:ACTION|TOGGLE|ENUM))',
lockscreen_value + r'\1',
content,
count=1,
)
if result != content:
content = result
changed = True
if 'lockscreenPinHash' not in content:
content = content.replace(
' SETTINGS.saveToFile();\n'
'}\n'
'\n'
'void SettingsActivity::render',
' if (setting.key && std::string(setting.key) == "lockscreenMode") {\n'
' if (SETTINGS.lockscreenMode == 0) {\n'
' memset(SETTINGS.lockscreenPinHash, 0, sizeof(SETTINGS.lockscreenPinHash));\n'
" } else if (SETTINGS.lockscreenPinHash[0] == '\\0') {\n"
' startActivityForResult(\n'
' std::make_unique<LockscreenActivity>(renderer, mappedInput, LockscreenActivity::Purpose::CREATE),\n'
' [](const ActivityResult&) { SETTINGS.saveToFile(); });\n'
' return;\n'
' }\n'
' }\n'
'\n'
' SETTINGS.saveToFile();\n'
'}\n'
'\n'
'void SettingsActivity::render'
)
changed = True
if changed:
write_file(path, content)
print(" SettingsActivity.cpp patched.")
else:
print(" SettingsActivity.cpp already patched, skipping.")
def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path)
if 'lockscreenMode") == 0 || strcmp(s.key, "lockscreenPinHash") == 0)) continue' in content:
print(" CrossPointWebServer.cpp already patched, skipping.")
return
target = (
' if (s.key && strcmp(s.key, "lockscreenMode") == 0) {\n'
' doc["name"] = "Lockscreen";\n'
' doc["category"] = "Plugins";\n'
' }\n'
)
if target in content:
content = content.replace(
target,
' if (s.key && (strcmp(s.key, "lockscreenMode") == 0 || strcmp(s.key, "lockscreenPinHash") == 0)) continue;\n'
)
write_file(path, content)
print(" CrossPointWebServer.cpp patched (lockscreen hidden from WUI).")
return
skip = ' if (s.key && (strcmp(s.key, "lockscreenMode") == 0 || strcmp(s.key, "lockscreenPinHash") == 0)) continue;\n'
# Insert continue right after category line regardless of what follows
anchor = ' doc["category"] = I18N.get(s.category);\n'
if anchor in content:
content = content.replace(anchor, anchor + '\n' + skip, 1)
write_file(path, content)
print(" CrossPointWebServer.cpp patched (lockscreen hidden from WUI).")
def patch_main_cpp(repo_dir):
path = find_first("main.cpp", repo_dir)
content = read_file(path)
already_has_includes = "LockscreenPlugin" in content
already_has_block = "LockscreenActivity lockAct" in content
if already_has_includes and already_has_block:
print(" main.cpp already patched, skipping.")
return
content = content.replace(
'#include "CrossPointSettings.h"',
'#include "CrossPointSettings.h"\n'
'#include "activities/settings/LockscreenPlugin.h"\n'
'#include "activities/LockscreenActivity.h"'
)
lockscreen_block = (
'\n'
' {\n'
' const auto lsMode = static_cast<LockscreenMode>(SETTINGS.lockscreenMode);\n'
' const bool hasPinSet = SETTINGS.lockscreenPinHash[0] != \'\\0\';\n'
' const bool shouldLock = hasPinSet && LockscreenPlugin::shouldLock(lsMode);\n'
'\n'
' if (shouldLock) {\n'
' LockscreenActivity lockAct(renderer, mappedInputManager, LockscreenActivity::Purpose::UNLOCK);\n'
' lockAct.onEnter();\n'
' lockAct.renderDirect();\n'
' while (!lockAct.isDone()) {\n'
' gpio.update();\n'
' lockAct.loop();\n'
' if (lockAct.needsRender()) {\n'
' lockAct.renderDirect();\n'
' }\n'
' delay(10);\n'
' }\n'
' lockAct.onExit();\n'
' renderer.clearScreen();\n'
' renderer.displayBuffer();\n'
' if (!lockAct.wasSuccessful()) {\n'
' enterDeepSleep();\n'
' return;\n'
' }\n'
' }\n'
' }\n'
)
anchor = ' RECENT_BOOKS.loadFromFile();\n\n if (HalSystem::isRebootFromPanic())'
if anchor in content:
content = content.replace(
anchor,
' RECENT_BOOKS.loadFromFile();\n' + lockscreen_block + '\n if (HalSystem::isRebootFromPanic())'
)
else:
idx = content.find('RECENT_BOOKS')
print(' DIAG main.cpp around RECENT_BOOKS:', repr(content[max(0,idx-50):idx+300]))
import re
m = re.search(r'RECENT_BOOKS\.loadFromFile\(\);.*?\n(\s*\n)*(\s*if)', content, re.DOTALL)
if m:
actual_anchor = content[m.start():m.end()]
content = content.replace(actual_anchor, ' RECENT_BOOKS.loadFromFile();\n' + lockscreen_block + '\n' + content[m.end()-len(m.group(2)):m.end()])
print(' main.cpp lockscreen block inserted via regex.')
else:
print(' WARNING: could not find RECENT_BOOKS anchor in main.cpp')
write_file(path, content)
has_block = "LockscreenActivity lockAct" in content
has_sleep = "enterDeepSleep" in content
if has_block:
idx2 = content.find("LockscreenActivity lockAct")
print(" DIAG lockscreen block context:", repr(content[max(0,idx2-200):idx2+50]))
print(f" main.cpp patched. lockscreen_block={has_block} enterDeepSleep={has_sleep}")
def patch_settings_html(repo_dir):
path = find_first("SettingsPage.html", repo_dir)
content = read_file(path)
if "lockscreenPin" in content:
print(" SettingsPage.html already patched, skipping.")
return
lines = [
"",
" <script>",
" (function() {",
" var _orig = loadSettings;",
" loadSettings = async function() {",
" await _orig.apply(this, arguments);",
" hideInternalRows();",
" injectLockscreenPin();",
" };",
"",
" function djb2Hash(str) {",
" var hash = 5381;",
" for (var i = 0; i < str.length; i++) {",
" hash = ((hash << 5) + hash) ^ str.charCodeAt(i);",
" hash = hash >>> 0;",
" }",
" hash = (hash ^ 0xA3F1C2B4) >>> 0;",
' return ("00000000" + hash.toString(16).toUpperCase()).slice(-8);',
" }",
"",
" function hideInternalRows() {",
' allSettings.forEach(function(s) {',
' if (s.hidden) {',
' var el = document.getElementById("setting-" + s.key);',
' if (el) { var row = el.closest(".setting-row"); if (row) row.style.display = "none"; }',
' }',
" });",
" }",
"",
" function injectLockscreenPin() {",
' var modeEl = document.getElementById("setting-lockscreenMode");',
" if (!modeEl) return;",
' var row = modeEl.closest(".setting-row");',
' if (!row || document.getElementById("lockscreen-pin-row")) return;',
' var pinRow = document.createElement("div");',
' pinRow.className = "setting-row";',
' pinRow.id = "lockscreen-pin-row";',
" pinRow.innerHTML =",
' \'<span class="setting-name">New PIN (4 digits)</span>\' +',
' \'<span class="setting-control">\' +',
' \'<input type="password" id="lockscreenPin" maxlength="4" \' +',
' \'pattern="[0-9]{4}" inputmode="numeric" \' +',
' \'placeholder="Leave blank to keep current" \' +',
' \'style="width:200px;" oninput="markChanged()">\' +',
" '</span>';",
" row.parentNode.insertBefore(pinRow, row.nextSibling);",
" updatePinRowVisibility();",
' modeEl.addEventListener("change", updatePinRowVisibility);',
" }",
"",
" function updatePinRowVisibility() {",
' var modeEl = document.getElementById("setting-lockscreenMode");',
' var pinRow = document.getElementById("lockscreen-pin-row");',
" if (!modeEl || !pinRow) return;",
' pinRow.style.display = parseInt(modeEl.value) === 1 ? "" : "none";',
" }",
"",
" var _origSave = saveSettings;",
" saveSettings = async function() {",
' var pinEl = document.getElementById("lockscreenPin");',
' var modeEl = document.getElementById("setting-lockscreenMode");',
" if (pinEl && modeEl) {",
" var pin = pinEl.value.trim();",
" if (parseInt(modeEl.value) === 0) {",
' var s = allSettings.find(function(x) { return x.key === "lockscreenPinHash"; });',
' if (s) { s.value = ""; originalValues["lockscreenPinHash"] = "CLEAR"; }',
" } else if (pin.length === 4 && /^[0-9]{4}$/.test(pin)) {",
" var hash = djb2Hash(pin);",
' var s = allSettings.find(function(x) { return x.key === "lockscreenPinHash"; });',
' if (s) { s.value = hash; originalValues["lockscreenPinHash"] = "CHANGED"; }',
' pinEl.value = "";',
" } else if (pin.length > 0) {",
' document.getElementById("message").textContent = "PIN must be exactly 4 digits.";',
' document.getElementById("message").className = "message error";',
' document.getElementById("message").style.display = "block";',
' setTimeout(function() { document.getElementById("message").style.display = "none"; }, 4000);',
" return;",
" }",
" }",
" await _origSave.apply(this, arguments);",
" };",
" })();",
" </script>",
"",
]
js = "\n".join(lines)
content = content.replace("</body>", js + "</body>")
write_file(path, content)
print(" SettingsPage.html patched.")
def patch(repo_dir: str):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching CrossPointSettings.h...")
patch_cross_point_settings_h(repo_dir)
print(" Patching SettingsList.h...")
patch_settings_list_h(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
print(" Patching CrossPointWebServer.cpp...")
patch_web_server(repo_dir)
print(" Patching main.cpp...")
patch_main_cpp(repo_dir)
print(" Patching SettingsPage.html (WUI PIN entry)...")
patch_settings_html(repo_dir)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+101
View File
@@ -0,0 +1,101 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingsField, PluginsTabEntry, ToggleHook, MainHook,
)
def get_manifest(ctx):
return PluginManifest(
name="lockscreen",
pretty_name="Lockscreen",
source_files=[
SourceFile("LockscreenPlugin.h", "src/activities/settings"),
SourceFile("LockscreenPlugin.cpp", "src/activities/settings"),
SourceFile("LockscreenActivity.h", "src/activities"),
SourceFile("LockscreenActivity.cpp", "src/activities"),
],
includes=[
Include("activities/settings/LockscreenPlugin.h", "cross_point_settings_h"),
Include("activities/settings/LockscreenPlugin.h", "settings_activity_cpp"),
Include("activities/LockscreenActivity.h", "settings_activity_cpp"),
Include("activities/settings/LockscreenPlugin.h", "main_cpp"),
Include("activities/LockscreenActivity.h", "main_cpp"),
],
settings_fields=[
SettingsField("uint8_t lockscreenMode = 0;"),
SettingsField("char lockscreenPinHash[9] = {};"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Lockscreen",
kind="enum",
key="lockscreenMode",
option_labels=["Disabled", "Enabled"],
value_text_expr="LockscreenPlugin::modeName(static_cast<LockscreenMode>(value))",
hidden_from_web=True,
),
PluginsTabEntry(
label="Lockscreen PIN",
kind="string",
key="lockscreenPinHash",
obfuscated=True,
show_on_device=False,
hidden_from_web=True,
),
],
toggle_hooks=[
ToggleHook(
key="lockscreenMode",
code=(
" if (SETTINGS.lockscreenMode == 0) {\n"
" memset(SETTINGS.lockscreenPinHash, 0, sizeof(SETTINGS.lockscreenPinHash));\n"
" } else if (SETTINGS.lockscreenPinHash[0] == '\\0') {\n"
" startActivityForResult(\n"
" std::make_unique<LockscreenActivity>(renderer, mappedInput, LockscreenActivity::Purpose::CREATE),\n"
" [](const ActivityResult&) { SETTINGS.saveToFile(); });\n"
" return;\n"
" }"
),
),
],
main_hooks=[
MainHook(
# Must run after setupDisplayAndFonts(): the lock screen calls
# renderDirect(), and before that point display.begin()/
# renderer.begin() haven't run and no fonts are registered, so
# the PIN prompt would silently draw nothing while still
# unlocking underneath - i.e. an invisible, bypassable lock.
point="post_display_setup",
code=(
" {\n"
" const auto lsMode = static_cast<LockscreenMode>(SETTINGS.lockscreenMode);\n"
" const bool hasPinSet = SETTINGS.lockscreenPinHash[0] != '\\0';\n"
" const bool shouldLock = hasPinSet && LockscreenPlugin::shouldLock(lsMode);\n"
" if (shouldLock) {\n"
" LockscreenActivity lockAct(renderer, mappedInputManager, LockscreenActivity::Purpose::UNLOCK);\n"
" lockAct.onEnter();\n"
" lockAct.renderDirect();\n"
" while (!lockAct.isDone()) {\n"
" gpio.update();\n"
" lockAct.loop();\n"
" if (lockAct.needsRender()) {\n"
" lockAct.renderDirect();\n"
" }\n"
" delay(10);\n"
" }\n"
" lockAct.onExit();\n"
" renderer.clearScreen();\n"
" renderer.displayBuffer();\n"
" if (!lockAct.wasSuccessful()) {\n"
" enterDeepSleep();\n"
" return;\n"
" }\n"
" }\n"
" }"
),
),
],
)
-269
View File
@@ -1,269 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import re
import shutil
def read_file(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
def write_file(path, content):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def find_first(filename, repo_dir):
results = glob.glob(os.path.join(repo_dir, "**", filename), recursive=True)
if not results:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def copy_plugin_sources(plugin_dir, repo_dir):
activities_dest = os.path.join(repo_dir, "src", "activities")
os.makedirs(activities_dest, exist_ok=True)
for fname in os.listdir(plugin_dir):
if not fname.endswith((".h", ".cpp", ".hpp")):
continue
src = os.path.join(plugin_dir, fname)
dst = os.path.join(activities_dest, fname)
shutil.copy2(src, dst)
print(f" ✓ {fname}")
def patch_setting_action_enum(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
if "PongGame" in content:
print(" SettingAction enum already patched, skipping.")
return
content = re.sub(r'(\n};)', r'\n PongGame,\1', content, count=1)
write_file(path, content)
print(" SettingAction::PongGame added.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
already_has_tab = "categoryCount = 5" in content
already_has_vec = "pluginsSettings" in content
if already_has_tab and already_has_vec:
print(" SettingsActivity.h already patched, skipping.")
return
if not already_has_tab:
content = content.replace(
"static constexpr int categoryCount = 4;",
"static constexpr int categoryCount = 5;"
)
if not already_has_vec:
content = content.replace(
" std::vector<SettingInfo> systemSettings;",
" std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;"
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if "PongActivity" in content and "SettingAction::PongGame" in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "SettingsList.h"',
'#include "SettingsList.h"\n#include "activities/PongActivity.h"'
)
if "StrId::STR_NONE_OPT};" not in content:
content = content.replace(
"StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};",
"StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n"
" StrId::STR_NONE_OPT};"
)
if "pluginsSettings.clear();" not in content:
result = content.replace(
" systemSettings.clear();\n",
" systemSettings.clear();\n pluginsSettings.clear();\n"
)
if "pluginsSettings.clear();" in result:
content = result
print(" pluginsSettings.clear() inserted.")
else:
print(" WARNING: could not insert pluginsSettings.clear().")
pong_push = (
" pluginsSettings.push_back(SettingInfo::Action(\n"
" StrId::STR_NONE_OPT, SettingAction::PongGame\n"
" ));\n"
)
if "SettingAction::PongGame" not in content:
inserted = False
for anchor in [
" selectedCategoryIndex = 0;\n selectedSettingIndex = 0;",
" selectedCategoryIndex = 0;",
" currentSettings = &displaySettings;",
]:
if anchor in content:
content = content.replace(anchor, pong_push + anchor, 1)
inserted = True
print(" PongGame push_back inserted.")
break
if not inserted:
print(" WARNING: could not insert PongGame push_back.")
if "case 4:\n currentSettings = &pluginsSettings;" not in content:
result = content.replace(
" case 3:\n currentSettings = &systemSettings;\n break;",
" case 3:\n currentSettings = &systemSettings;\n break;\n"
" case 4:\n currentSettings = &pluginsSettings;\n break;"
)
if result != content:
content = result
print(" case 4 inserted.")
else:
print(" WARNING: case 3 anchor not found.")
if '"Plugins"' not in content:
result = content.replace(
" tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});\n"
" }",
' const char* tabLabel = (i == 4) ? "Plugins" : I18N.get(categoryNames[i]);\n'
" tabs.push_back({tabLabel, selectedCategoryIndex == i});\n"
" }"
)
if result != content:
content = result
print(" Plugins tab label inserted.")
else:
print(" WARNING: tab label anchor not found.")
if "nextCatLabel" not in content and "nextCatIdx" not in content:
result = content.replace(
" const auto confirmLabel = (selectedSettingIndex == 0)\n"
" ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n"
" : tr(STR_TOGGLE);",
" const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n"
' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n'
" const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);"
)
if result != content:
content = result
print(" nextCatLabel inserted.")
else:
print(" WARNING: confirmLabel anchor not found.")
pong_label = (
" if (s.type == SettingType::ACTION &&\n"
' s.action == SettingAction::PongGame) return "Pong Game";\n'
)
if "Pong Game" not in content:
if "selectedCategoryIndex == 4" in content:
result = re.sub(
r'( if \(selectedCategoryIndex == 4\) \{.*?)( \})',
lambda m: m.group(1) + pong_label + m.group(2),
content,
count=1,
flags=re.DOTALL,
)
if result != content:
content = result
print(" Pong Game label inserted.")
else:
print(" WARNING: category==4 block found but label insertion failed.")
else:
result = content.replace(
" [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); },",
" [&settings, this](int index) -> std::string {\n"
" if (selectedCategoryIndex == 4) {\n"
" const auto& s = settings[index];\n"
+ pong_label +
" }\n"
" return std::string(I18N.get(settings[index].nameId));\n"
" },"
)
if result != content:
content = result
print(" Pong Game label inserted (new block).")
else:
print(" WARNING: label lambda anchor not found.")
pong_value = (
" if (setting.type == SettingType::ACTION &&\n"
" setting.action == SettingAction::PongGame) {\n"
' valueText = "Launch";\n'
" } else "
)
if '"Launch"' not in content:
result = re.sub(
r'( if \(setting\.type == SettingType::(?:ACTION|TOGGLE|ENUM)\b)',
pong_value + r'\1',
content,
count=1,
)
if result != content:
content = result
print(" Launch value text inserted.")
else:
print(" WARNING: value text anchor not found.")
pong_case = (
" case SettingAction::PongGame:\n"
" startActivityForResult(std::make_unique<PongActivity>(renderer, mappedInput), resultHandler);\n"
" break;\n"
)
if "case SettingAction::PongGame:" not in content:
result = re.sub(
r'( case SettingAction::)',
pong_case + r'\1',
content,
count=1,
)
if result != content:
content = result
print(" PongGame case inserted.")
else:
print(" WARNING: SettingAction case anchor not found.")
write_file(path, content)
print(" SettingsActivity.cpp written.")
def patch(repo_dir: str):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching SettingAction enum...")
patch_setting_action_enum(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+33
View File
@@ -0,0 +1,33 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingActionEnumValue, PluginsTabEntry,
)
def get_manifest(ctx):
return PluginManifest(
name="pong",
pretty_name="Pong",
source_files=[
SourceFile("PongActivity.h", "src/activities"),
SourceFile("PongActivity.cpp", "src/activities"),
],
includes=[
Include("activities/PongActivity.h", "settings_activity_cpp"),
],
setting_actions=[
SettingActionEnumValue("PongGame"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Pong Game",
kind="action",
action_name="PongGame",
action_value_text="Launch",
activity_launch_expr="std::make_unique<PongActivity>(renderer, mappedInput)",
),
],
)
-388
View File
@@ -1,388 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import glob
import shutil
def read_file(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
def write_file(path, content):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def find_first(filename, repo_dir):
results = glob.glob(os.path.join(repo_dir, "**", filename), recursive=True)
if not results:
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0]
def copy_plugin_sources(plugin_dir, repo_dir):
dest = os.path.join(repo_dir, "src", "activities", "settings")
os.makedirs(dest, exist_ok=True)
skip = {"SmallerFontsSettingsPage.h", "SmallerFontsSettingsPage.cpp"}
for fname in os.listdir(plugin_dir):
if fname.endswith((".h", ".cpp", ".hpp")) and fname not in skip:
src = os.path.join(plugin_dir, fname)
dst = os.path.join(dest, fname)
shutil.copy2(src, dst)
print(f" ✓ {fname}")
def patch_cross_point_settings_h(repo_dir):
path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path)
if "smallerFontsMode" in content:
print(" CrossPointSettings.h already patched, skipping.")
return
for include_anchor in [
'#pragma once\n#include <HalStorage.h>',
'#pragma once\n\n#include <HalStorage.h>',
'#pragma once',
]:
if include_anchor in content and '"SmallerFontsPlugin.h"' not in content:
content = content.replace(
include_anchor,
include_anchor + '\n#include "activities/settings/SmallerFontsPlugin.h"',
1
)
break
for member_anchor in [
' uint8_t imageRendering = IMAGES_DISPLAY;',
'\tuint8_t imageRendering = IMAGES_DISPLAY;',
'uint8_t imageRendering = IMAGES_DISPLAY;',
]:
if member_anchor in content:
content = content.replace(
member_anchor,
member_anchor + '\n uint8_t smallerFontsMode = 0;',
1
)
break
if "smallerFontsMode" not in content:
sys.exit("ERROR: Could not add smallerFontsMode to CrossPointSettings.h — anchor not found")
write_file(path, content)
print(" CrossPointSettings.h patched.")
def patch_cross_point_settings_cpp(repo_dir):
path = find_first("CrossPointSettings.cpp", repo_dir)
content = read_file(path)
if "resolveReaderFontId" in content:
print(" CrossPointSettings.cpp already patched, skipping.")
return
content = content.replace(
'int CrossPointSettings::getReaderFontId() const {',
'int CrossPointSettings::getReaderFontId() const {\n auto resolve = [this](int baseId) {\n return SmallerFontsPlugin::resolveReaderFontId(baseId, static_cast<SmallerFontsMode>(smallerFontsMode));\n };\n'
)
for name in ["NOTOSERIF_12", "NOTOSERIF_14", "NOTOSERIF_16", "NOTOSERIF_18",
"NOTOSANS_12", "NOTOSANS_14", "NOTOSANS_16", "NOTOSANS_18",
"OPENDYSLEXIC_8", "OPENDYSLEXIC_10", "OPENDYSLEXIC_12", "OPENDYSLEXIC_14",
"BOOKERLY_12"]:
content = content.replace(f'return {name}_FONT_ID;', f'return resolve({name}_FONT_ID);')
write_file(path, content)
print(" CrossPointSettings.cpp patched.")
def patch_settings_list_h(repo_dir):
path = find_first("SettingsList.h", repo_dir)
content = read_file(path)
if "smallerFontsMode" in content:
print(" SettingsList.h already patched, skipping.")
return
entry = (
' SettingInfo::Enum(StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"),\n'
)
content = content.replace(
' };\n return list;\n}\n',
entry + ' };\n return list;\n}\n'
)
write_file(path, content)
print(" SettingsList.h patched.")
def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path)
already_has_plugins_tab = "categoryCount = 5" in content
already_has_plugins_vec = "pluginsSettings" in content
if already_has_plugins_tab and already_has_plugins_vec:
print(" SettingsActivity.h already patched, skipping.")
return
if '"SmallerFontsPlugin.h"' not in content:
content = content.replace(
'#include <I18n.h>',
'#include <I18n.h>\n#include "SmallerFontsPlugin.h"'
)
if not already_has_plugins_tab:
content = content.replace(
'static constexpr int categoryCount = 4;',
'static constexpr int categoryCount = 5;'
)
if not already_has_plugins_vec:
content = content.replace(
' std::vector<SettingInfo> systemSettings;',
' std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> pluginsSettings;'
)
write_file(path, content)
print(" SettingsActivity.h patched.")
def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if "SmallerFontsPlugin::modeName" in content and "smallerFontsMode" in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
if "STR_NONE_OPT" not in content:
content = content.replace(
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};',
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n'
' StrId::STR_NONE_OPT};'
)
if "pluginsSettings.clear();" not in content:
content = content.replace(
' systemSettings.clear();',
' systemSettings.clear();\n pluginsSettings.clear();'
)
plugins_block = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n'
' ));\n'
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n'
' ));\n'
)
sf_only_block = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n'
' ));\n'
)
broken_old_block = (
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT,\n'
' &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n'
' "smallerFontsMode"\n'
' ));\n'
)
if broken_old_block in content:
content = content.replace(broken_old_block, sf_only_block)
elif '"darkModeState"' in content and '"smallerFontsMode"' not in content:
content = content.replace(
' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n'
' ));\n',
plugins_block
)
elif '"smallerFontsMode"' not in content:
content = content.replace(
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n',
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n'
+ sf_only_block
)
if 'case 4:\n currentSettings = &pluginsSettings;' not in content:
content = content.replace(
' case 3:\n currentSettings = &systemSettings;\n break;',
' case 3:\n currentSettings = &systemSettings;\n break;\n'
' case 4:\n currentSettings = &pluginsSettings;\n break;'
)
if '"Plugins"' not in content:
content = content.replace(
' std::vector<TabInfo> tabs;\n'
' tabs.reserve(categoryCount);\n'
' for (int i = 0; i < categoryCount; i++) {\n'
' tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});\n'
' }',
' std::vector<TabInfo> tabs;\n'
' tabs.reserve(categoryCount);\n'
' for (int i = 0; i < categoryCount; i++) {\n'
' const char* tabLabel = (i == 4) ? "Plugins" : I18N.get(categoryNames[i]);\n'
' tabs.push_back({tabLabel, selectedCategoryIndex == i});\n'
' }'
)
if 'nextCatLabel' not in content:
content = content.replace(
' const auto confirmLabel = (selectedSettingIndex == 0)\n'
' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n'
' : tr(STR_TOGGLE);',
' const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n'
' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n'
' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);'
)
if 'selectedCategoryIndex == 4' not in content:
content = content.replace(
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); },',
' [&settings, this](int index) -> std::string {\n'
' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n'
' if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n'
' if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n'
' }\n'
' return std::string(I18N.get(settings[index].nameId));\n'
' },'
)
if "SmallerFontsPlugin::modeName" not in content:
if "DarkModePlugin::stateName" in content:
content = content.replace(
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }',
' if (setting.key && std::string(setting.key) == "darkModeState") {\n'
' valueText = DarkModePlugin::stateName(static_cast<DarkModeState>(value));\n'
' } else if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }'
)
else:
content = content.replace(
' valueText = I18N.get(setting.enumValues[value]);',
' if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n'
' valueText = SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value));\n'
' } else {\n'
' valueText = I18N.get(setting.enumValues[value]);\n'
' }'
)
write_file(path, content)
print(" SettingsActivity.cpp patched.")
def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path)
if '"Dark Mode"' in content or '"Smaller Fonts"' in content:
print(" CrossPointWebServer.cpp already patched, skipping.")
return
name_overrides = (
'\n'
' if (s.key) {\n'
' if (strcmp(s.key, "darkModeState") == 0) {\n'
' doc["name"] = "Dark Mode";\n'
' doc["category"] = "Plugins";\n'
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' doc["name"] = "Smaller Fonts";\n'
' doc["category"] = "Plugins";\n'
' }\n'
' }\n'
)
content = content.replace(
' doc["category"] = I18N.get(s.category);\n'
'\n'
' switch (s.type) {',
' doc["category"] = I18N.get(s.category);\n'
+ name_overrides +
'\n'
' switch (s.type) {'
)
enum_overrides = (
' if (s.key) {\n'
' if (strcmp(s.key, "darkModeState") == 0) {\n'
' JsonArray opts = doc["options"].to<JsonArray>();\n'
' opts.add("Disabled");\n'
' opts.add("Enabled");\n'
' } else if (strcmp(s.key, "smallerFontsMode") == 0) {\n'
' JsonArray opts = doc["options"].to<JsonArray>();\n'
' opts.add("Disabled");\n'
' opts.add("Smaller");\n'
' opts.add("Smallest");\n'
' }\n'
' }\n'
)
content = content.replace(
' for (const auto& opt : s.enumValues) {\n'
' options.add(I18N.get(opt));\n'
' }\n'
' break;\n',
' for (const auto& opt : s.enumValues) {\n'
' options.add(I18N.get(opt));\n'
' }\n'
+ enum_overrides +
' break;\n'
)
write_file(path, content)
print(" CrossPointWebServer.cpp patched.")
def patch(repo_dir: str):
plugin_dir = os.path.dirname(os.path.abspath(__file__))
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching CrossPointSettings.h...")
patch_cross_point_settings_h(repo_dir)
print(" Patching CrossPointSettings.cpp...")
patch_cross_point_settings_cpp(repo_dir)
print(" Patching SettingsList.h...")
patch_settings_list_h(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
print(" Patching CrossPointWebServer.cpp (web UI)...")
patch_web_server(repo_dir)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")
patch(sys.argv[1])
+86
View File
@@ -0,0 +1,86 @@
import sys
import os
import re
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from framework.manifest import (
PluginManifest, SourceFile, Include, SettingsField, PluginsTabEntry,
)
from framework.engine import find_first, read_file, write_file, PatchError
# NOTE: the plugin's own SmallerFontsPlugin.h/.cpp currently implement only
# two real states (Off / Enabled - a single font-size step down), even
# though the settings list historically reserved a 3rd ("Smallest") slot and
# the README describes three tiers. We match what the code actually does
# (2 states) rather than exposing a 3rd option that behaves identically to
# the 2nd - see SmallerFontsPlugin.cpp: resolveReaderFontId() only special-
# cases MODE_OFF vs "anything else". Worth a real 3-tier implementation as a
# follow-up, but that's a feature gap, not part of the install/composability
# fix this framework addresses.
def _wrap_font_resolution(ctx):
"""Wrap every currently-present `return X_FONT_ID;` line in
CrossPointSettings::getReaderFontId() with SmallerFontsPlugin's resolver.
Regex-driven (not a hardcoded font name list) so it picks up whatever
font plugins (e.g. bookerly) already added earlier in the same run -
this plugin has phase=1 specifically so it runs after any font-provider
plugin's phase=0 step."""
path = find_first("CrossPointSettings.cpp", ctx.repo_dir)
content = read_file(path)
if "SmallerFontsPlugin::resolveReaderFontId" in content:
return
func_pattern = re.compile(r"(int CrossPointSettings::getReaderFontId\(\) const \{)")
m = func_pattern.search(content)
if not m:
raise PatchError("CrossPointSettings.cpp: could not find getReaderFontId()")
resolver = (
"\n auto resolve = [this](int baseId) {\n"
" return SmallerFontsPlugin::resolveReaderFontId(baseId, static_cast<SmallerFontsMode>(smallerFontsMode));\n"
" };"
)
content = content[:m.end()] + resolver + content[m.end():]
# Only wrap `return X_FONT_ID;` lines that occur after the point we just
# inserted the resolver (i.e. inside this function), up to its closing brace.
start = content.index(resolver) + len(resolver)
# crude but reliable: this function is the only remaining thing in the
# file after this point that returns *_FONT_ID literals directly.
def _wrap(match):
return f"return resolve({match.group(1)});"
content = content[:start] + re.sub(r"return (\w+_FONT_ID);", _wrap, content[start:])
write_file(path, content)
print(" CrossPointSettings.cpp: wrapped font-id returns with SmallerFontsPlugin::resolveReaderFontId.")
def get_manifest(ctx):
return PluginManifest(
name="smallerfonts",
pretty_name="Smaller Fonts",
phase=1, # after bookerly (phase 0), so its font-id return line exists to be wrapped
source_files=[
SourceFile("SmallerFontsPlugin.h", "src/activities/settings"),
SourceFile("SmallerFontsPlugin.cpp", "src/activities/settings"),
],
includes=[
Include("activities/settings/SmallerFontsPlugin.h", "cross_point_settings_h"),
],
settings_fields=[
SettingsField("uint8_t smallerFontsMode = 0;"),
],
plugins_tab_entries=[
PluginsTabEntry(
label="Smaller Fonts",
kind="enum",
key="smallerFontsMode",
option_labels=["Disabled", "Enabled"],
value_text_expr="SmallerFontsPlugin::modeName(static_cast<SmallerFontsMode>(value))",
),
],
post_patch=_wrap_font_resolution,
)