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

This commit is contained in:
Justin Oros
2026-04-23 15:02:00 -07:00
parent e4f411dde5
commit 6abf076a3e
9 changed files with 963 additions and 655 deletions
+232
View File
@@ -0,0 +1,232 @@
#include "GitHubSync.h"
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <WiFi.h>
#include <Logging.h>
#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<JsonArray>();
int downloaded = 0;
for (JsonObject file : files) {
std::string type = file["type"].as<const char*>();
std::string name = file["name"].as<const char*>();
std::string sha = file["sha"].as<const char*>();
std::string downloadUrl = file["download_url"].as<const char*>();
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;
}
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <Arduino.h>
#include <functional>
#include <string>
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<void(const std::string &status)>;
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);
};
-84
View File
@@ -1,84 +0,0 @@
#include "GitHubSyncActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#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;
}
}
-22
View File
@@ -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();
};
-223
View File
@@ -1,223 +0,0 @@
#include "GitHubSyncPlugin.h"
#include <HTTPClient.h>
#include <HalStorage.h>
#include <Logging.h>
#include <WiFi.h>
#include <ArduinoJson.h>
#include <string>
#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<const uint8_t*>(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<JsonArray>();
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;
}
}
-15
View File
@@ -1,15 +0,0 @@
#pragma once
#include <Arduino.h>
namespace GitHubSyncPlugin {
enum class SyncResult {
OK,
NO_WIFI,
GIT_ERROR,
};
SyncResult sync();
}
@@ -0,0 +1,207 @@
#include "GitHubSyncSettingsActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <WiFi.h>
#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<WifiSelectionActivity>(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<KeyboardEntryActivity>(renderer, mappedInput, "GitHub Username",
cfg.username, 64, InputType::Text),
[](const ActivityResult &result) {
if (!result.isCancelled) {
GitHubSyncConfig c;
GitHubSync::loadConfig(c);
c.username = std::get<KeyboardResult>(result.data).text;
GitHubSync::saveConfig(c);
}
});
} else if (selectedIndex == 3) {
startActivityForResult(
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, "Personal Access Token",
"", 128, InputType::Text),
[](const ActivityResult &result) {
if (!result.isCancelled) {
const auto &text = std::get<KeyboardResult>(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<KeyboardEntryActivity>(renderer, mappedInput, "Repo Name",
cfg.repo, 64, InputType::Text),
[](const ActivityResult &result) {
if (!result.isCancelled) {
GitHubSyncConfig c;
GitHubSync::loadConfig(c);
c.repo = std::get<KeyboardResult>(result.data).text;
GitHubSync::saveConfig(c);
}
});
} else if (selectedIndex == 5) {
startActivityForResult(
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, "Branch",
cfg.branch, 32, InputType::Text),
[](const ActivityResult &result) {
if (!result.isCancelled) {
GitHubSyncConfig c;
GitHubSync::loadConfig(c);
c.branch = std::get<KeyboardResult>(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();
}
@@ -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;
};
+451 -306
View File
@@ -4,6 +4,19 @@ import os
import sys import sys
import glob import glob
import shutil 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): def read_file(path):
@@ -21,99 +34,383 @@ def find_first(filename, repo_dir):
return results[0] 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): def copy_plugin_sources(plugin_dir, repo_dir):
dest = os.path.join(repo_dir, "src", "activities", "settings") copies = [
os.makedirs(dest, exist_ok=True) (os.path.join(plugin_dir, "GitHubSync.h"),
for fname in os.listdir(plugin_dir): os.path.join(repo_dir, "include", "GitHubSync.h")),
if fname.endswith((".h", ".cpp", ".hpp")): (os.path.join(plugin_dir, "GitHubSyncSettingsActivity.h"),
src = os.path.join(plugin_dir, fname) os.path.join(repo_dir, "include", "GitHubSyncSettingsActivity.h")),
dst = os.path.join(dest, fname) (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) shutil.copy2(src, dst)
print(f" \u2713 {fname}") print(f" ✓ {os.path.basename(dst)}")
def patch_cross_point_settings_h(repo_dir, url="", pat=""): def patch_platformio_ini(repo_dir):
path = find_first("CrossPointSettings.h", repo_dir) ini = os.path.join(repo_dir, "platformio.ini")
content = read_file(path) 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 return
safe_url = url.replace('\\', '\\\\').replace('"', '\\"') content = read_file(main_path)
safe_pat = pat.replace('\\', '\\\\').replace('"', '\\"')
content = content.replace( if '#include "GitHubSync.h"' in content:
' uint8_t imageRendering = IMAGES_DISPLAY;', print(" main.cpp already patched, skipping.")
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.")
return return
content = content.replace( content = content.replace(
' };\n return list;\n}\n', '#include "CrossPointSettings.h"',
' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.githubSyncUrl,\n' '#include "GitHubSync.h"\n#include "CrossPointSettings.h"',
' sizeof(SETTINGS.githubSyncUrl), "githubSyncUrl"),\n' 1
' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.githubSyncPat,\n'
' sizeof(SETTINGS.githubSyncPat), "githubSyncPat")\n'
' .withObfuscated(),\n'
' };\n return list;\n}\n'
) )
write_file(path, content) sync_call = (
print(" SettingsList.h patched.") '\n if (GitHubSync::isConfigured()) {\n'
' GitHubSyncResult result = GitHubSync::sync();\n'
' if (result != GitHubSyncResult::OK) {\n'
def patch_setting_action_enum(repo_dir): ' LOG_ERR("SYNC", "%s", GitHubSync::resultMessage(result));\n'
path = find_first("SettingsActivity.h", repo_dir) ' }\n'
content = read_file(path) ' }\n'
)
if "GitHubSync" in content:
print(" SettingAction enum already patched, skipping.")
return
if "GitHubSync::isConfigured" not in content:
content = content.replace( content = content.replace(
' Language,\n', "activityManager.goToBoot();",
' GitHubSync,\n Language,\n' "activityManager.goToBoot();" + sync_call,
1
) )
write_file(path, content) write_file(main_path, content)
print(" SettingAction::GitHubSync added.") print(" main.cpp patched.")
def patch_settings_h(repo_dir): def patch_settings_h(repo_dir):
path = find_first("SettingsActivity.h", repo_dir) path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path) content = read_file(path)
already_has_tab = "categoryCount = 5" in content if "GitHubSync," in content:
already_has_vec = "pluginsSettings" in content
if already_has_tab and already_has_vec:
print(" SettingsActivity.h already patched, skipping.") print(" SettingsActivity.h already patched, skipping.")
return return
if not already_has_tab:
content = content.replace( content = content.replace(
'static constexpr int categoryCount = 4;', " CheckForUpdates,",
'static constexpr int categoryCount = 5;' " CheckForUpdates,\n GitHubSync,",
) 1
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) write_file(path, content)
@@ -124,260 +421,118 @@ def patch_settings_cpp(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir) path = find_first("SettingsActivity.cpp", repo_dir)
content = read_file(path) 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.") print(" SettingsActivity.cpp already patched, skipping.")
return return
if '#include "GitHubSyncSettingsActivity.h"' not in content:
content = content.replace( content = content.replace(
'#include "SettingsList.h"', '#include "SettingsActivity.h"',
'#include "SettingsList.h"\n#include "GitHubSyncPlugin.h"\n#include "GitHubSyncActivity.h"' '#include "SettingsActivity.h"\n#include "GitHubSyncSettingsActivity.h"',
1
) )
if 'StrId::STR_NONE_OPT};' not in content and 'STR_NONE_OPT' not in content.split('categoryNames')[1].split(';')[0]: if "SettingAction::GitHubSync" not in content:
content = content.replace( content = content.replace(
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n' "SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));",
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};', "SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));\n"
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n' " pluginsSettings.push_back(SettingInfo::Action(StrId::STR_NONE_OPT, SettingAction::GitHubSync));",
' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n' 1
' StrId::STR_NONE_OPT};'
) )
if "pluginsSettings.clear();" not in content: if "case SettingAction::GitHubSync:" not in content:
content = content.replace( content = content.replace(
' systemSettings.clear();', "case SettingAction::CheckForUpdates:",
' systemSettings.clear();\n pluginsSettings.clear();' "case SettingAction::CheckForUpdates:\n"
" case SettingAction::GitHubSync:\n"
" startActivityForResult(std::make_unique<GitHubSyncSettingsActivity>(renderer, mappedInput), resultHandler);\n"
" break;",
1
) )
github_push = ( if '"GitHub Sync"' not in content:
' 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:
content = content.replace( content = content.replace(
' case 3:\n currentSettings = &systemSettings;\n break;', '[&settings, this](int index) -> std::string {\n',
' case 3:\n currentSettings = &systemSettings;\n break;\n' '[&settings, this](int index) -> std::string {\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);'
)
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' ' if (selectedCategoryIndex == 4) {\n'
' const auto& s = settings[index];\n' ' const auto& s = settings[index];\n'
+ github_label_line + ' if (s.type == SettingType::ACTION && s.action == SettingAction::GitHubSync) return "GitHub Sync";\n'
' if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n' ' }\n',
' if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n' 1
' }\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:
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'
' valueText = "Sync";\n'
' } else if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {'
) )
if 'case SettingAction::GitHubSync:' not in content: if '"Installed"' not in content:
content = content.replace( content = content.replace(
' case SettingAction::Language:\n', '} else if (setting.type == SettingType::ACTION && setting.action == SettingAction::BookerlyInstalled) {\n'
' case SettingAction::GitHubSync:\n' ' valueText = "Installed";\n',
' startActivityForResult(std::make_unique<GitHubSyncActivity>(renderer, mappedInput), resultHandler);\n' '} else if (setting.type == SettingType::ACTION && setting.action == SettingAction::BookerlyInstalled) {\n'
' break;\n' ' valueText = "Installed";\n'
' case SettingAction::Language:\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) write_file(path, content)
print(" SettingsActivity.cpp patched.") print(" SettingsActivity.cpp patched.")
def patch_wifi_include(repo_dir): def patch_translation_files(repo_dir):
path = find_first("SettingsActivity.cpp", repo_dir) yaml_dir = os.path.join(repo_dir, "lib", "I18n", "translations")
content = read_file(path) if not os.path.isdir(yaml_dir):
print(" ! Could not find translation YAML files — add STR_GITHUB_SYNC manually.")
if '#include <WiFi.h>' in content:
print(" WiFi.h already included in SettingsActivity.cpp, skipping.")
return return
content = content.replace( yaml_files = glob.glob(os.path.join(yaml_dir, "*.yaml"))
'#include "SettingsList.h"', if not yaml_files:
'#include "SettingsList.h"\n#include <WiFi.h>' print(" ! No translation YAML files found.")
)
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.")
return return
github_block = ( patched = 0
' if (strcmp(s.key, "githubSyncUrl") == 0) {\n' for yf in yaml_files:
' doc["name"] = "GitHub Repo";\n' content = read_file(yf)
' doc["category"] = "Plugins";\n' if "STR_GITHUB_SYNC" in content:
' }\n' patched += 1
' if (strcmp(s.key, "githubSyncPat") == 0) {\n' continue
' doc["name"] = "GitHub PAT (Optional)";\n' lines = content.splitlines(keepends=True)
' doc["category"] = "Plugins";\n' new_lines = []
' }\n' inserted = False
) for line in lines:
new_lines.append(line)
if 'if (s.key) {' in content and 'darkModeState' in content: if line.startswith("STR_CHECK_UPDATES:"):
content = content.replace( new_lines.append('STR_GITHUB_SYNC: "GitHub Sync"\n')
' if (strcmp(s.key, "darkModeState") == 0) {\n', inserted = True
github_block + if inserted:
' if (strcmp(s.key, "darkModeState") == 0) {\n' write_file(yf, "".join(new_lines))
) patched += 1
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: else:
content = content.replace( print(f" ! STR_CHECK_UPDATES not found in {os.path.basename(yf)} — add STR_GITHUB_SYNC manually.")
' 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(f" Translation files patched ({patched} files).")
print(" CrossPointWebServer.cpp patched.")
def patch(repo_dir: str, yes_all: bool = False): def patch(repo_dir: str, yes_all: bool = False):
plugin_dir = os.path.dirname(os.path.abspath(__file__)) 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...") print(" Copying plugin sources...")
copy_plugin_sources(plugin_dir, repo_dir) copy_plugin_sources(plugin_dir, repo_dir)
print(" Patching CrossPointSettings.h...") print(" Patching platformio.ini...")
patch_cross_point_settings_h(repo_dir, url, pat) patch_platformio_ini(repo_dir)
print(" Patching SettingsList.h...") print(" Patching main.cpp...")
patch_settings_list_h(repo_dir) patch_main_cpp(repo_dir)
print(" Patching SettingAction enum...")
patch_setting_action_enum(repo_dir)
print(" Patching SettingsActivity.h...") print(" Patching SettingsActivity.h...")
patch_settings_h(repo_dir) patch_settings_h(repo_dir)
@@ -385,32 +540,22 @@ def patch(repo_dir: str, yes_all: bool = False):
print(" Patching SettingsActivity.cpp...") print(" Patching SettingsActivity.cpp...")
patch_settings_cpp(repo_dir) patch_settings_cpp(repo_dir)
print(" Adding WiFi.h include...") print(" Patching translation files...")
patch_wifi_include(repo_dir) patch_translation_files(repo_dir)
print(" Patching CrossPointWebServer.cpp (web UI)...") if not yes_all:
patch_web_server(repo_dir) print("\n GitHub Sync credentials (optional — can also be set on-device).")
answer = input(" Configure GitHub credentials now? [Y/n]: ").strip().lower()
import glob as _glob if answer in ("", "y", "yes"):
cpp_results = _glob.glob(os.path.join(repo_dir, "**", "SettingsActivity.cpp"), recursive=True) cfg = prompt_github_config()
if cpp_results: if cfg:
cpp_content = open(cpp_results[0]).read() nvs_bin = write_nvs_partition(cfg)
checks = [ if nvs_bin:
("label lambda: 'GitHub Sync'", "GitHub Sync" in cpp_content), print(f"\n NVS partition ready: {nvs_bin}")
("switch case: GitHubSync", "case SettingAction::GitHubSync:" in cpp_content), print(" Flash it after firmware upload with:")
("GitHubSyncActivity launched", "GitHubSyncActivity" in cpp_content), print(f" esptool.py --chip esp32c3 write_flash 0x9000 {nvs_bin}")
("pluginsSettings.clear()", "pluginsSettings.clear();" in cpp_content), else:
("case 4 -> pluginsSettings", "case 4:" in cpp_content), print(" Skipping credential prompt (--yes mode). Configure on-device via Settings -> GitHub Sync.")
("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 __name__ == "__main__": if __name__ == "__main__":