feat(githubsync): add GitHub Sync plugin to sync epubs from a private GitHub repo on boot
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
+450
-305
@@ -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'
|
|
||||||
' 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'
|
'[&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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user