From 6abf076a3e1d201b7dbcd96fb76727c1c46637f3 Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Thu, 23 Apr 2026 15:02:00 -0700 Subject: [PATCH] feat(githubsync): add GitHub Sync plugin to sync epubs from a private GitHub repo on boot --- plugins/githubsync/GitHubSync.cpp | 232 ++++++ plugins/githubsync/GitHubSync.h | 41 + plugins/githubsync/GitHubSyncActivity.cpp | 84 -- plugins/githubsync/GitHubSyncActivity.h | 22 - plugins/githubsync/GitHubSyncPlugin.cpp | 223 ----- plugins/githubsync/GitHubSyncPlugin.h | 15 - .../githubsync/GitHubSyncSettingsActivity.cpp | 207 +++++ .../githubsync/GitHubSyncSettingsActivity.h | 27 + plugins/githubsync/patch.py | 767 +++++++++++------- 9 files changed, 963 insertions(+), 655 deletions(-) create mode 100644 plugins/githubsync/GitHubSync.cpp create mode 100644 plugins/githubsync/GitHubSync.h delete mode 100644 plugins/githubsync/GitHubSyncActivity.cpp delete mode 100644 plugins/githubsync/GitHubSyncActivity.h delete mode 100644 plugins/githubsync/GitHubSyncPlugin.cpp delete mode 100644 plugins/githubsync/GitHubSyncPlugin.h create mode 100644 plugins/githubsync/GitHubSyncSettingsActivity.cpp create mode 100644 plugins/githubsync/GitHubSyncSettingsActivity.h diff --git a/plugins/githubsync/GitHubSync.cpp b/plugins/githubsync/GitHubSync.cpp new file mode 100644 index 0000000..e15c1f2 --- /dev/null +++ b/plugins/githubsync/GitHubSync.cpp @@ -0,0 +1,232 @@ +#include "GitHubSync.h" +#include +#include +#include +#include +#include +#include "SDCardManager.h" + +#define GH_PREFS_NS "github_sync" +#define GH_KEY_USER "username" +#define GH_KEY_PAT "pat" +#define GH_KEY_REPO "repo" +#define GH_KEY_BRANCH "branch" +#define GH_SHA_DIR "/.crosspoint/github_sha/" +#define GH_BOOKS_DIR "/" +#define GH_SLEEP_BMP "sleep.bmp" +#define GH_SLEEP_PATH "/sleep.bmp" +#define GH_API_BASE "https://api.github.com" + +bool GitHubSync::loadConfig(GitHubSyncConfig &cfg) { + Preferences prefs; + prefs.begin(GH_PREFS_NS, true); + cfg.username = prefs.getString(GH_KEY_USER, "").c_str(); + cfg.pat = prefs.getString(GH_KEY_PAT, "").c_str(); + cfg.repo = prefs.getString(GH_KEY_REPO, "xteink").c_str(); + cfg.branch = prefs.getString(GH_KEY_BRANCH, "main").c_str(); + prefs.end(); + return !cfg.username.empty() && !cfg.pat.empty(); +} + +void GitHubSync::saveConfig(const GitHubSyncConfig &cfg) { + Preferences prefs; + prefs.begin(GH_PREFS_NS, false); + prefs.putString(GH_KEY_USER, cfg.username.c_str()); + prefs.putString(GH_KEY_PAT, cfg.pat.c_str()); + prefs.putString(GH_KEY_REPO, cfg.repo.c_str()); + prefs.putString(GH_KEY_BRANCH, cfg.branch.c_str()); + prefs.end(); +} + +bool GitHubSync::isConfigured() { + GitHubSyncConfig cfg; + return loadConfig(cfg); +} + +const char* GitHubSync::resultMessage(GitHubSyncResult r) { + switch (r) { + case GitHubSyncResult::OK: return "Sync complete"; + case GitHubSyncResult::NOT_CONFIGURED: return "Not configured"; + case GitHubSyncResult::NO_WIFI: return "No WiFi"; + case GitHubSyncResult::AUTH_ERROR: return "Auth failed (check PAT)"; + case GitHubSyncResult::REPO_NOT_FOUND: return "Repo not found"; + case GitHubSyncResult::API_ERROR: return "API error"; + case GitHubSyncResult::SD_ERROR: return "SD card error"; + case GitHubSyncResult::PARSE_ERROR: return "Bad API response"; + default: return "Unknown error"; + } +} + +std::string GitHubSync::shaFilePath(const std::string &filename) { + std::string safe = filename; + for (char &c : safe) if (c == '/') c = '_'; + return std::string(GH_SHA_DIR) + safe + ".sha"; +} + +std::string GitHubSync::loadLocalSha(const std::string &filename) { + std::string path = shaFilePath(filename); + auto &sd = SDCardManager::getInstance(); + FsFile f; + if (!sd.openFileForRead("SYNC", path, f)) return ""; + char buf[41] = {}; + f.read(buf, sizeof(buf) - 1); + f.close(); + std::string sha(buf); + while (!sha.empty() && (sha.back() == '\n' || sha.back() == '\r' || sha.back() == ' ')) + sha.pop_back(); + return sha; +} + +void GitHubSync::saveLocalSha(const std::string &filename, const std::string &sha) { + auto &sd = SDCardManager::getInstance(); + sd.mkdir(GH_SHA_DIR); + std::string path = shaFilePath(filename); + FsFile f; + if (!sd.openFileForWrite("SYNC", path, f)) return; + f.print(sha.c_str()); + f.close(); +} + +bool GitHubSync::fetchFileList(const GitHubSyncConfig &cfg, std::string &outJson, GitHubSyncResult &err) { + std::string url = std::string(GH_API_BASE) + "/repos/" + cfg.username + "/" + + cfg.repo + "/contents/?ref=" + cfg.branch; + + HTTPClient http; + http.begin(url.c_str()); + http.addHeader("Authorization", ("token " + cfg.pat).c_str()); + http.addHeader("Accept", "application/vnd.github.v3+json"); + http.addHeader("User-Agent", "CrossPoint-X4"); + + int code = http.GET(); + LOG_INF("SYNC", "fetchFileList HTTP code: %d", code); + if (code == 401 || code == 403) { http.end(); err = GitHubSyncResult::AUTH_ERROR; return false; } + if (code == 404) { http.end(); err = GitHubSyncResult::REPO_NOT_FOUND; return false; } + if (code != 200) { http.end(); err = GitHubSyncResult::API_ERROR; return false; } + + outJson = http.getString().c_str(); + http.end(); + return true; +} + +bool GitHubSync::downloadFile(const GitHubSyncConfig &cfg, const std::string &downloadUrl, const std::string &name, const std::string &sha, GitHubSyncResult &err, GitHubSyncProgressCallback onProgress) { + HTTPClient http; + http.begin(downloadUrl.c_str()); + http.addHeader("Authorization", ("token " + cfg.pat).c_str()); + http.addHeader("User-Agent", "CrossPoint-X4"); + + int code = http.GET(); + LOG_INF("SYNC", "downloadFile '%s' HTTP code: %d", name.c_str(), code); + if (code == 401 || code == 403) { http.end(); err = GitHubSyncResult::AUTH_ERROR; return false; } + if (code != 200) { http.end(); err = GitHubSyncResult::API_ERROR; return false; } + + std::string destPath = (name == GH_SLEEP_BMP) ? GH_SLEEP_PATH : std::string(GH_BOOKS_DIR) + name; + LOG_INF("SYNC", "Writing to SD path: '%s'", destPath.c_str()); + + auto &sd = SDCardManager::getInstance(); + LOG_INF("SYNC", "SDCardManager ready: %s", sd.ready() ? "YES" : "NO"); + + if (onProgress) onProgress("Writing..."); + + FsFile f; + if (!sd.openFileForWrite("SYNC", destPath, f)) { + http.end(); + err = GitHubSyncResult::SD_ERROR; + return false; + } + LOG_INF("SYNC", "File opened for write OK"); + + WiFiClient *stream = http.getStreamPtr(); + uint8_t buf[512]; + int total = http.getSize(); + int remaining = total; + unsigned long lastData = millis(); + const unsigned long timeoutMs = 30000; + + while (http.connected() && (remaining > 0 || total == -1)) { + size_t available = stream->available(); + if (available) { + size_t read = stream->readBytes(buf, min(available, sizeof(buf))); + f.write(buf, read); + if (remaining > 0) remaining -= (int)read; + lastData = millis(); + } else { + if (millis() - lastData > timeoutMs) { + LOG_INF("SYNC", "Download timed out after 30s"); + f.close(); + http.end(); + err = GitHubSyncResult::API_ERROR; + return false; + } + delay(10); + } + } + + f.close(); + http.end(); + saveLocalSha(name, sha); + if (onProgress) onProgress("Saved"); + return true; +} + +GitHubSyncResult GitHubSync::sync(GitHubSyncProgressCallback onProgress) { + GitHubSyncConfig cfg; + if (!loadConfig(cfg)) return GitHubSyncResult::NOT_CONFIGURED; + if (WiFi.status() != WL_CONNECTED) return GitHubSyncResult::NO_WIFI; + + if (onProgress) onProgress("Checking..."); + + std::string jsonStr; + GitHubSyncResult err = GitHubSyncResult::OK; + if (!fetchFileList(cfg, jsonStr, err)) return err; + + JsonDocument doc; + DeserializationError jsonErr = deserializeJson(doc, jsonStr); + if (jsonErr) return GitHubSyncResult::PARSE_ERROR; + + JsonArray files = doc.as(); + int downloaded = 0; + for (JsonObject file : files) { + std::string type = file["type"].as(); + std::string name = file["name"].as(); + std::string sha = file["sha"].as(); + std::string downloadUrl = file["download_url"].as(); + + if (type != "file") continue; + + bool isEpub = name.size() > 5 && (name.substr(name.size()-5) == ".epub" || name.substr(name.size()-5) == ".EPUB"); + bool isSleep = (name == GH_SLEEP_BMP); + + if (!isEpub && !isSleep) continue; + if (downloadUrl.empty()) continue; + + std::string localSha = loadLocalSha(name); + std::string destPath = (name == GH_SLEEP_BMP) ? GH_SLEEP_PATH : std::string(GH_BOOKS_DIR) + name; + bool fileExists = SDCardManager::getInstance().exists(destPath.c_str()); + + if (localSha == sha && fileExists) { + if (onProgress) onProgress("Up to date"); + continue; + } + + if (fileExists && localSha.empty()) { + LOG_INF("SYNC", "Adopting remote SHA for existing file '%s'", name.c_str()); + saveLocalSha(name, sha); + if (onProgress) onProgress("Up to date"); + continue; + } + + if (onProgress) onProgress("Downloading..."); + LOG_INF("SYNC", "Downloading '%s'", name.c_str()); + if (!downloadFile(cfg, downloadUrl, name, sha, err, onProgress)) return err; + downloaded++; + } + + if (onProgress) { + if (downloaded == 0) + onProgress("Up to date"); + else + onProgress("Synchronized"); + } + + return GitHubSyncResult::OK; +} diff --git a/plugins/githubsync/GitHubSync.h b/plugins/githubsync/GitHubSync.h new file mode 100644 index 0000000..bab22ca --- /dev/null +++ b/plugins/githubsync/GitHubSync.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +struct GitHubSyncConfig { + std::string username; + std::string pat; + std::string repo; + std::string branch; +}; + +enum class GitHubSyncResult { + OK, + NOT_CONFIGURED, + NO_WIFI, + AUTH_ERROR, + REPO_NOT_FOUND, + API_ERROR, + SD_ERROR, + PARSE_ERROR +}; + +using GitHubSyncProgressCallback = std::function; + +class GitHubSync { +public: + static bool loadConfig(GitHubSyncConfig &cfg); + static void saveConfig(const GitHubSyncConfig &cfg); + static bool isConfigured(); + static GitHubSyncResult sync(GitHubSyncProgressCallback onProgress = nullptr); + static const char* resultMessage(GitHubSyncResult r); + +private: + static bool fetchFileList(const GitHubSyncConfig &cfg, std::string &outJson, GitHubSyncResult &err); + static bool downloadFile(const GitHubSyncConfig &cfg, const std::string &downloadUrl, const std::string &name, const std::string &sha, GitHubSyncResult &err, GitHubSyncProgressCallback onProgress); + static std::string loadLocalSha(const std::string &filename); + static void saveLocalSha(const std::string &filename, const std::string &sha); + static std::string shaFilePath(const std::string &filename); +}; diff --git a/plugins/githubsync/GitHubSyncActivity.cpp b/plugins/githubsync/GitHubSyncActivity.cpp deleted file mode 100644 index cd9c747..0000000 --- a/plugins/githubsync/GitHubSyncActivity.cpp +++ /dev/null @@ -1,84 +0,0 @@ -#include "GitHubSyncActivity.h" - -#include -#include - -#include "GitHubSyncPlugin.h" -#include "MappedInputManager.h" -#include "components/UITheme.h" -#include "fontIds.h" - -void GitHubSyncActivity::onEnter() { - Activity::onEnter(); - state = SYNCING; - result = GitHubSyncPlugin::SyncResult::OK; - requestUpdate(); -} - -void GitHubSyncActivity::onExit() { Activity::onExit(); } - -void GitHubSyncActivity::render(RenderLock&&) { - const auto& metrics = UITheme::getInstance().getMetrics(); - const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); - - renderer.clearScreen(); - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "GitHub Sync"); - - if (state == SYNCING) { - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Syncing..."); - renderer.displayBuffer(); - return; - } - - const char* line1 = nullptr; - const char* line2 = nullptr; - - switch (result) { - case GitHubSyncPlugin::SyncResult::OK: - line1 = "Sync complete."; - break; - case GitHubSyncPlugin::SyncResult::NO_WIFI: - line1 = "Not connected to WiFi."; - line2 = "Connect to WiFi and try again."; - break; - case GitHubSyncPlugin::SyncResult::GIT_ERROR: - line1 = "Sync failed."; - line2 = "Check GitHub URL and PAT in Settings > Plugins."; - break; - } - - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - (line2 ? 15 : 0), line1, true, - result == GitHubSyncPlugin::SyncResult::OK ? EpdFontFamily::BOLD - : EpdFontFamily::REGULAR); - if (line2) - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 15, line2); - - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(); -} - -void GitHubSyncActivity::doSync() { - result = GitHubSyncPlugin::sync(); - state = DONE; - requestUpdate(); -} - -void GitHubSyncActivity::loop() { - if (state == SYNCING) { - { - RenderLock lock(*this); - } - requestUpdateAndWait(); - doSync(); - return; - } - - if (state == DONE) { - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - finish(); - } - return; - } -} diff --git a/plugins/githubsync/GitHubSyncActivity.h b/plugins/githubsync/GitHubSyncActivity.h deleted file mode 100644 index b132595..0000000 --- a/plugins/githubsync/GitHubSyncActivity.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "GitHubSyncPlugin.h" -#include "activities/Activity.h" - -class GitHubSyncActivity final : public Activity { - public: - explicit GitHubSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) - : Activity("GitHubSync", renderer, mappedInput) {} - - void onEnter() override; - void onExit() override; - void loop() override; - bool skipLoopDelay() override { return true; } - void render(RenderLock&&) override; - - private: - enum State { SYNCING, DONE }; - State state = SYNCING; - GitHubSyncPlugin::SyncResult result = GitHubSyncPlugin::SyncResult::OK; - void doSync(); -}; diff --git a/plugins/githubsync/GitHubSyncPlugin.cpp b/plugins/githubsync/GitHubSyncPlugin.cpp deleted file mode 100644 index 0323f1e..0000000 --- a/plugins/githubsync/GitHubSyncPlugin.cpp +++ /dev/null @@ -1,223 +0,0 @@ -#include "GitHubSyncPlugin.h" - -#include -#include -#include -#include -#include - -#include - -#include "CrossPointSettings.h" - -namespace { - -constexpr char GH_API_BASE[] = "https://api.github.com"; -constexpr char GH_SHA_DIR[] = "/.crosspoint/github_sha/"; -constexpr char GH_SLEEP_BMP[] = "sleep.bmp"; - -struct RepoInfo { - std::string owner; - std::string repo; - std::string branch; -}; - -RepoInfo parseRepoUrl(const std::string& url) { - RepoInfo info; - std::string u = url; - - for (const char* prefix : {"https://github.com/", "http://github.com/", "github.com/"}) { - if (u.rfind(prefix, 0) == 0) { - u = u.substr(strlen(prefix)); - break; - } - } - - if (!u.empty() && u.back() == '/') u.pop_back(); - if (u.size() > 4 && u.substr(u.size() - 4) == ".git") - u = u.substr(0, u.size() - 4); - - auto slash = u.find('/'); - if (slash == std::string::npos) return info; - info.owner = u.substr(0, slash); - info.repo = u.substr(slash + 1); - info.branch = "main"; - return info; -} - -std::string shaFilePath(const std::string& filename) { - std::string safe = filename; - for (char& c : safe) if (c == '/') c = '_'; - return std::string(GH_SHA_DIR) + safe + ".sha"; -} - -std::string loadLocalSha(const std::string& filename) { - FsFile f; - if (!Storage.openFileForRead("GHS", shaFilePath(filename).c_str(), f)) return ""; - char buf[41] = {}; - f.read(buf, sizeof(buf) - 1); - f.close(); - std::string sha(buf); - while (!sha.empty() && (sha.back() == '\n' || sha.back() == '\r' || sha.back() == ' ')) - sha.pop_back(); - return sha; -} - -void saveLocalSha(const std::string& filename, const std::string& sha) { - Storage.mkdir(GH_SHA_DIR); - FsFile f; - if (!Storage.openFileForWrite("GHS", shaFilePath(filename).c_str(), f)) return; - f.write(reinterpret_cast(sha.c_str()), sha.size()); - f.close(); -} - -bool downloadFile(const std::string& downloadUrl, const std::string& pat, - const std::string& destPath, const std::string& name, - const std::string& sha) { - HTTPClient http; - LOG_INF("GHS", "Download URL: %s", downloadUrl.c_str()); - http.begin(downloadUrl.c_str()); - http.setTimeout(30000); - http.addHeader("Authorization", ("token " + pat).c_str()); - http.addHeader("User-Agent", "CrossPoint-GitHubSync/1.0"); - - int code = http.GET(); - LOG_INF("GHS", "Download '%s' HTTP %d", name.c_str(), code); - if (code != 200) { - http.end(); - return false; - } - - FsFile f; - if (!Storage.openFileForWrite("GHS", destPath.c_str(), f)) { - LOG_ERR("GHS", "Cannot open %s for write", destPath.c_str()); - http.end(); - return false; - } - - WiFiClient* stream = http.getStreamPtr(); - uint8_t buf[512]; - int total = http.getSize(); - int remaining = total; - unsigned long lastData = millis(); - - while (http.connected() && (remaining > 0 || total == -1)) { - size_t avail = stream->available(); - if (avail) { - size_t toRead = avail < sizeof(buf) ? avail : sizeof(buf); - size_t read = stream->readBytes(buf, toRead); - f.write(buf, read); - if (remaining > 0) remaining -= (int)read; - lastData = millis(); - } else { - if (millis() - lastData > 30000) { - LOG_ERR("GHS", "Download timed out: %s", name.c_str()); - f.close(); - http.end(); - return false; - } - delay(10); - } - } - - f.close(); - http.end(); - saveLocalSha(name, sha); - return true; -} - -bool syncContents(const RepoInfo& info, const std::string& pat) { - std::string url = std::string(GH_API_BASE) + "/repos/" + info.owner + "/" + - info.repo + "/contents/?ref=" + info.branch; - - HTTPClient http; - http.begin(url.c_str()); - http.setTimeout(15000); - http.addHeader("Authorization", ("token " + pat).c_str()); - http.addHeader("Accept", "application/vnd.github.v3+json"); - http.addHeader("User-Agent", "CrossPoint-GitHubSync/1.0"); - - int code = http.GET(); - LOG_INF("GHS", "Contents API HTTP %d", code); - if (code != 200) { - http.end(); - return false; - } - - String body = http.getString(); - http.end(); - - JsonDocument doc; - if (deserializeJson(doc, body) != DeserializationError::Ok) { - LOG_ERR("GHS", "JSON parse error"); - return false; - } - - JsonArray files = doc.as(); - - for (JsonObject file : files) { - const char* type = file["type"] | ""; - const char* name = file["name"] | ""; - const char* sha = file["sha"] | ""; - const char* downloadUrl = file["download_url"] | ""; - - if (strcmp(type, "file") != 0) continue; - - std::string n(name); - bool isEpub = n.size() > 5 && (n.substr(n.size() - 5) == ".epub" || - n.substr(n.size() - 5) == ".EPUB"); - bool isSleep = (n == GH_SLEEP_BMP); - if (!isEpub && !isSleep) continue; - if (!downloadUrl || !*downloadUrl) continue; - - std::string destPath = isSleep ? "/sleep.bmp" : "/" + n; - std::string localSha = loadLocalSha(n); - - if (localSha == std::string(sha) && Storage.exists(destPath.c_str())) { - LOG_DBG("GHS", "Up to date: %s", name); - continue; - } - - if (Storage.exists(destPath.c_str())) { - LOG_DBG("GHS", "File exists, skipping: %s", name); - continue; - } - - LOG_INF("GHS", "Downloading: %s", name); - if (!downloadFile(std::string(downloadUrl), pat, destPath, n, std::string(sha))) - return false; - } - - return true; -} - -} - -namespace GitHubSyncPlugin { - -SyncResult sync() { - if (WiFi.status() != WL_CONNECTED) { - LOG_ERR("GHS", "No WiFi"); - return SyncResult::NO_WIFI; - } - - const std::string url(SETTINGS.githubSyncUrl); - if (url.empty()) { - LOG_ERR("GHS", "No GitHub URL configured"); - return SyncResult::GIT_ERROR; - } - - const std::string pat(SETTINGS.githubSyncPat); - RepoInfo info = parseRepoUrl(url); - - if (info.owner.empty() || info.repo.empty()) { - LOG_ERR("GHS", "Invalid GitHub URL: %s", url.c_str()); - return SyncResult::GIT_ERROR; - } - - if (!syncContents(info, pat)) return SyncResult::GIT_ERROR; - - return SyncResult::OK; -} - -} diff --git a/plugins/githubsync/GitHubSyncPlugin.h b/plugins/githubsync/GitHubSyncPlugin.h deleted file mode 100644 index 48d86d5..0000000 --- a/plugins/githubsync/GitHubSyncPlugin.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once - -#include - -namespace GitHubSyncPlugin { - - enum class SyncResult { - OK, - NO_WIFI, - GIT_ERROR, - }; - - SyncResult sync(); - -} diff --git a/plugins/githubsync/GitHubSyncSettingsActivity.cpp b/plugins/githubsync/GitHubSyncSettingsActivity.cpp new file mode 100644 index 0000000..7b09fd3 --- /dev/null +++ b/plugins/githubsync/GitHubSyncSettingsActivity.cpp @@ -0,0 +1,207 @@ +#include "GitHubSyncSettingsActivity.h" + +#include +#include +#include + +#include "MappedInputManager.h" +#include "activities/util/KeyboardEntryActivity.h" +#include "activities/network/WifiSelectionActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +constexpr int MENU_ITEMS = 7; +constexpr int SYNC_NOW_INDEX = 0; +constexpr int WIFI_STATUS_INDEX = 1; +constexpr int SAVE_INDEX = 6; +const char* menuNames[MENU_ITEMS] = { + "Sync Now", + "WiFi", + "Username", + "Token (PAT)", + "Repo", + "Branch", + "Save" +}; +} + +void GitHubSyncSettingsActivity::onEnter() { + Activity::onEnter(); + selectedIndex = 0; + requestUpdate(); +} + +void GitHubSyncSettingsActivity::onExit() { + Activity::onExit(); +} + +std::string GitHubSyncSettingsActivity::getMasked(const std::string &s) const { + return s.empty() ? "" : "••••••••"; +} + +void GitHubSyncSettingsActivity::doSync() { + GitHubSyncConfig cfg; + GitHubSync::loadConfig(cfg); + LOG_INF("SYNC", "user='%s' repo='%s' branch='%s' pat_len=%d", + cfg.username.c_str(), cfg.repo.c_str(), cfg.branch.c_str(), (int)cfg.pat.size()); + + auto progress = [this](const std::string &status) { + syncStatus = status; + requestUpdateAndWait(); + }; + + syncStatus = "Syncing..."; + requestUpdateAndWait(); + GitHubSyncResult result = GitHubSync::sync(progress); + if (result != GitHubSyncResult::OK) + syncStatus = GitHubSync::resultMessage(result); + LOG_INF("SYNC", "result: %s", syncStatus.c_str()); + requestUpdateAndWait(); +} + +void GitHubSyncSettingsActivity::handleSelection() { + GitHubSyncConfig cfg; + GitHubSync::loadConfig(cfg); + + if (selectedIndex == SYNC_NOW_INDEX) { + if (WiFi.status() != WL_CONNECTED) { + syncStatus = "Connecting..."; + requestUpdateAndWait(); + startActivityForResult( + std::make_unique(renderer, mappedInput), + [this](const ActivityResult &result) { + if (WiFi.status() != WL_CONNECTED) { + syncStatus = "No WiFi"; + requestUpdateAndWait(); + return; + } + syncStatus = "Connected"; + requestUpdateAndWait(); + doSync(); + }); + } else { + syncStatus = "Connected"; + requestUpdateAndWait(); + doSync(); + } + requestUpdate(); + } else if (selectedIndex == 2) { + startActivityForResult( + std::make_unique(renderer, mappedInput, "GitHub Username", + cfg.username, 64, InputType::Text), + [](const ActivityResult &result) { + if (!result.isCancelled) { + GitHubSyncConfig c; + GitHubSync::loadConfig(c); + c.username = std::get(result.data).text; + GitHubSync::saveConfig(c); + } + }); + } else if (selectedIndex == 3) { + startActivityForResult( + std::make_unique(renderer, mappedInput, "Personal Access Token", + "", 128, InputType::Text), + [](const ActivityResult &result) { + if (!result.isCancelled) { + const auto &text = std::get(result.data).text; + if (!text.empty()) { + GitHubSyncConfig c; + GitHubSync::loadConfig(c); + c.pat = text; + GitHubSync::saveConfig(c); + } + } + }); + } else if (selectedIndex == 4) { + startActivityForResult( + std::make_unique(renderer, mappedInput, "Repo Name", + cfg.repo, 64, InputType::Text), + [](const ActivityResult &result) { + if (!result.isCancelled) { + GitHubSyncConfig c; + GitHubSync::loadConfig(c); + c.repo = std::get(result.data).text; + GitHubSync::saveConfig(c); + } + }); + } else if (selectedIndex == 5) { + startActivityForResult( + std::make_unique(renderer, mappedInput, "Branch", + cfg.branch, 32, InputType::Text), + [](const ActivityResult &result) { + if (!result.isCancelled) { + GitHubSyncConfig c; + GitHubSync::loadConfig(c); + c.branch = std::get(result.data).text; + GitHubSync::saveConfig(c); + } + }); + } else if (selectedIndex == SAVE_INDEX) { + finish(); + } +} + +void GitHubSyncSettingsActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleSelection(); + return; + } + + buttonNavigator.onNext([this] { + selectedIndex = (selectedIndex + 1) % MENU_ITEMS; + if (selectedIndex == WIFI_STATUS_INDEX) + selectedIndex = (selectedIndex + 1) % MENU_ITEMS; + requestUpdate(); + }); + + buttonNavigator.onPrevious([this] { + selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS; + if (selectedIndex == WIFI_STATUS_INDEX) + selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS; + requestUpdate(); + }); +} + +void GitHubSyncSettingsActivity::render(RenderLock &&) { + renderer.clearScreen(); + + const auto &metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "GitHub Sync"); + + GitHubSyncConfig cfg; + GitHubSync::loadConfig(cfg); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, MENU_ITEMS, + selectedIndex, + [](int index) { return std::string(menuNames[index]); }, + nullptr, nullptr, + [this, &cfg](int index) -> std::string { + if (index == SYNC_NOW_INDEX) return syncStatus; + if (index == WIFI_STATUS_INDEX) + return WiFi.status() == WL_CONNECTED ? "Connected" : "Not connected"; + if (index == 2) return cfg.username.empty() ? "(not set)" : cfg.username; + if (index == 3) return cfg.pat.empty() ? "(not set)" : getMasked(cfg.pat); + if (index == 4) return cfg.repo.empty() ? "xteink" : cfg.repo; + if (index == 5) return cfg.branch.empty() ? "main" : cfg.branch; + return ""; + }, + true); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/plugins/githubsync/GitHubSyncSettingsActivity.h b/plugins/githubsync/GitHubSyncSettingsActivity.h new file mode 100644 index 0000000..fe59e15 --- /dev/null +++ b/plugins/githubsync/GitHubSyncSettingsActivity.h @@ -0,0 +1,27 @@ +#pragma once + +#include "activities/Activity.h" +#include "GitHubSync.h" +#include "util/ButtonNavigator.h" + +class GitHubSyncSettingsActivity final : public Activity { +public: + explicit GitHubSyncSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("GitHubSync", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + +private: + ButtonNavigator buttonNavigator; + int selectedIndex = 0; + std::string syncStatus; + + static constexpr int MENU_ITEMS = 7; + + void handleSelection(); + void doSync(); + std::string getMasked(const std::string& s) const; +}; diff --git a/plugins/githubsync/patch.py b/plugins/githubsync/patch.py index 9b510e0..f1809f4 100644 --- a/plugins/githubsync/patch.py +++ b/plugins/githubsync/patch.py @@ -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 systemSettings;', - ' std::vector systemSettings;\n std::vector 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(renderer, mappedInput), resultHandler);\n" + " break;", + 1 ) - if '"Plugins"' not in content: + if '"GitHub Sync"' not in content: content = content.replace( - ' std::vector 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 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(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 ' 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 ' - ) - 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__":