feat(githubsync): add GitHub Sync plugin to sync epubs from a private GitHub repo on boot

This commit is contained in:
justin committed 2026-04-23 15:02:00 -07:00
1 parent e4f411dde5
commit 6abf076a3e
9 files changed
+963 -655

No files matched your search

+456 -311
View File
@@ -4,6 +4,19 @@ 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):
@@ -21,100 +34,384 @@ def find_first(filename, 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):
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}")
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_cross_point_settings_h(repo_dir, url="", pat=""):
path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path)
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.")
if "githubSyncUrl" in content:
print(" CrossPointSettings.h already patched, skipping.")
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
safe_url = url.replace('\\', '\\\\').replace('"', '\\"')
safe_pat = pat.replace('\\', '\\\\').replace('"', '\\"')
content = read_file(main_path)
content = content.replace(
' uint8_t imageRendering = IMAGES_DISPLAY;',
f' uint8_t imageRendering = IMAGES_DISPLAY;\n'
f' char githubSyncUrl[512] = "{safe_url}";\n'
f' char githubSyncPat[256] = "{safe_pat}";'
)
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 "githubSyncUrl" in content:
print(" SettingsList.h already patched, skipping.")
if '#include "GitHubSync.h"' in content:
print(" main.cpp already patched, skipping.")
return
content = content.replace(
' };\n return list;\n}\n',
' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.githubSyncUrl,\n'
' sizeof(SETTINGS.githubSyncUrl), "githubSyncUrl"),\n'
' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.githubSyncPat,\n'
' sizeof(SETTINGS.githubSyncPat), "githubSyncPat")\n'
' .withObfuscated(),\n'
' };\n return list;\n}\n'
'#include "CrossPointSettings.h"',
'#include "GitHubSync.h"\n#include "CrossPointSettings.h"',
1
)
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 "GitHubSync" in content:
print(" SettingAction enum already patched, skipping.")
return
content = content.replace(
' Language,\n',
' GitHubSync,\n Language,\n'
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'
)
write_file(path, content)
print(" SettingAction::GitHubSync added.")
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)
already_has_tab = "categoryCount = 5" in content
already_has_vec = "pluginsSettings" in content
if already_has_tab and already_has_vec:
if "GitHubSync," in content:
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;'
)
content = content.replace(
" CheckForUpdates,",
" CheckForUpdates,\n GitHubSync,",
1
)
write_file(path, content)
print(" SettingsActivity.h patched.")
@@ -124,260 +421,118 @@ def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if "GitHubSyncPlugin" in content:
if '#include "GitHubSyncSettingsActivity.h"' in content and "SettingAction::GitHubSync" in content:
print(" SettingsActivity.cpp already patched, skipping.")
return
content = content.replace(
'#include "SettingsList.h"',
'#include "SettingsList.h"\n#include "GitHubSyncPlugin.h"\n#include "GitHubSyncActivity.h"'
)
if 'StrId::STR_NONE_OPT};' not in content and 'STR_NONE_OPT' not in content.split('categoryNames')[1].split(';')[0]:
if '#include "GitHubSyncSettingsActivity.h"' 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};'
'#include "SettingsActivity.h"',
'#include "SettingsActivity.h"\n#include "GitHubSyncSettingsActivity.h"',
1
)
if "pluginsSettings.clear();" not in content:
if "SettingAction::GitHubSync" not in content:
content = content.replace(
' systemSettings.clear();',
' systemSettings.clear();\n pluginsSettings.clear();'
"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
)
github_push = (
' pluginsSettings.push_back(SettingInfo::Action(\n'
' StrId::STR_NONE_OPT, SettingAction::GitHubSync\n'
' ));\n'
)
hardcover_push = (
' pluginsSettings.push_back(SettingInfo::Action(\n'
' StrId::STR_NONE_OPT, SettingAction::HardcoverSync\n'
' ));\n'
)
if 'SettingAction::GitHubSync' 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 + github_push + hardcover_push)
break
if 'case 4:\n currentSettings = &pluginsSettings;' not in content:
if "case SettingAction::GitHubSync:" 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;'
"case SettingAction::CheckForUpdates:",
"case SettingAction::CheckForUpdates:\n"
" case SettingAction::GitHubSync:\n"
" startActivityForResult(std::make_unique<GitHubSyncSettingsActivity>(renderer, mappedInput), resultHandler);\n"
" break;",
1
)
if '"Plugins"' not in content:
if '"GitHub Sync"' 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'
' }'
'[&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 'nextCatLabel' not in content:
if '"Installed"' 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);'
'} 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
)
github_label_line = (
' if (s.type == SettingType::ACTION &&\n'
' s.action == SettingAction::GitHubSync) return "GitHub Sync";\n'
)
if 'GitHub Sync' not in content:
if 'selectedCategoryIndex == 4' in content:
for first_key_line in [
' if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n',
' if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n',
' if (s.type == SettingType::ACTION && s.action == SettingAction::BookerlyInstalled) return "Bookerly Font";\n',
' return std::string(I18N.get(settings[index].nameId));\n',
]:
if first_key_line in content:
content = content.replace(
first_key_line,
github_label_line + first_key_line
)
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'
+ github_label_line +
' 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'
' }, nullptr, nullptr,'
)
content = content.replace(old_lambda, new_lambda)
if '"No WiFi"' not in content:
elif "GitHubSync" not in content.split('valueText = "Installed"')[0].split('BookerlyInstalled')[-1]:
content = content.replace(
' if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {',
' if (setting.type == SettingType::ACTION &&\n'
' setting.action == SettingAction::GitHubSync) {\n'
' if (WiFi.status() != WL_CONNECTED) {\n'
' valueText = "No WiFi";\n'
' } else {\n'
' valueText = "Sync";\n'
' }\n'
' } else if (setting.type == SettingType::ACTION &&\n'
' setting.action == SettingAction::HardcoverSync) {\n'
'} 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::TOGGLE && setting.valuePtr != nullptr) {'
)
if 'case SettingAction::GitHubSync:' not in content:
content = content.replace(
' case SettingAction::Language:\n',
' case SettingAction::GitHubSync:\n'
' startActivityForResult(std::make_unique<GitHubSyncActivity>(renderer, mappedInput), resultHandler);\n'
' break;\n'
' case SettingAction::Language:\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_wifi_include(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path)
if '#include <WiFi.h>' in content:
print(" WiFi.h already included in SettingsActivity.cpp, skipping.")
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
content = content.replace(
'#include "SettingsList.h"',
'#include "SettingsList.h"\n#include <WiFi.h>'
)
write_file(path, content)
print(" WiFi.h include added to SettingsActivity.cpp.")
def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path)
if '"githubSyncUrl"' in content or '"GitHub Repo"' in content:
print(" CrossPointWebServer.cpp already patched, skipping.")
yaml_files = glob.glob(os.path.join(yaml_dir, "*.yaml"))
if not yaml_files:
print(" ! No translation YAML files found.")
return
github_block = (
' if (strcmp(s.key, "githubSyncUrl") == 0) {\n'
' doc["name"] = "GitHub Repo";\n'
' doc["category"] = "Plugins";\n'
' }\n'
' if (strcmp(s.key, "githubSyncPat") == 0) {\n'
' doc["name"] = "GitHub PAT (Optional)";\n'
' doc["category"] = "Plugins";\n'
' }\n'
)
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.")
if 'if (s.key) {' in content and 'darkModeState' in content:
content = content.replace(
' if (strcmp(s.key, "darkModeState") == 0) {\n',
github_block +
' if (strcmp(s.key, "darkModeState") == 0) {\n'
)
elif 'if (s.key) {' in content and 'hardcoverApiToken' in content:
content = content.replace(
' if (strcmp(s.key, "hardcoverApiToken") == 0) {\n',
github_block +
' if (strcmp(s.key, "hardcoverApiToken") == 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'
+ github_block +
' }\n'
'\n'
' switch (s.type) {'
)
write_file(path, content)
print(" CrossPointWebServer.cpp patched.")
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__))
cache_file = os.path.expanduser("~/.githubsync")
url = ""
pat = ""
if os.path.exists(cache_file):
with open(cache_file) as f:
for line in f:
if line.startswith("url="):
url = line[4:].strip()
elif line.startswith("pat="):
pat = line[4:].strip()
print(f" Using GitHub config from ~/.githubsync (url: {url}).")
else:
print(" GitHub repository URL is required.")
print(" Example: https://github.com/yourusername/yourrepo")
while not url:
url = input(" GitHub URL: ").strip()
if not url:
print(" URL cannot be empty.")
pat = input(" GitHub PAT (Optional for Private repo): ").strip()
with open(cache_file, "w") as f:
f.write(f"url={url}\n")
f.write(f"pat={pat}\n")
print(f" Config saved to ~/.githubsync for future installs.")
print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching CrossPointSettings.h...")
patch_cross_point_settings_h(repo_dir, url, pat)
print(" Patching platformio.ini...")
patch_platformio_ini(repo_dir)
print(" Patching SettingsList.h...")
patch_settings_list_h(repo_dir)
print(" Patching SettingAction enum...")
patch_setting_action_enum(repo_dir)
print(" Patching main.cpp...")
patch_main_cpp(repo_dir)
print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir)
@@ -385,32 +540,22 @@ def patch(repo_dir: str, yes_all: bool = False):
print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir)
print(" Adding WiFi.h include...")
patch_wifi_include(repo_dir)
print(" Patching translation files...")
patch_translation_files(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: 'GitHub Sync'", "GitHub Sync" in cpp_content),
("switch case: GitHubSync", "case SettingAction::GitHubSync:" in cpp_content),
("GitHubSyncActivity launched", "GitHubSyncActivity" in cpp_content),
("pluginsSettings.clear()", "pluginsSettings.clear();" in cpp_content),
("case 4 -> pluginsSettings", "case 4:" in cpp_content),
("No WiFi button state", "No WiFi" 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 GitHub Sync patch checks failed.")
print(" GitHub Sync plugin patch verified OK.")
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__":