feat(hardcover): auto-connect to WiFi before syncing

This commit is contained in:
Justin Oros
2026-04-27 13:25:30 -07:00
parent b7223f4375
commit 850e674706
3 changed files with 75 additions and 118 deletions
+35 -7
View File
@@ -2,21 +2,46 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <I18n.h> #include <I18n.h>
#include <WiFi.h>
#include "HardcoverPlugin.h" #include "HardcoverPlugin.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
void HardcoverSyncActivity::onEnter() { void HardcoverSyncActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
state = SYNCING;
result = HardcoverPlugin::SyncResult::OK; result = HardcoverPlugin::SyncResult::OK;
requestUpdate(); startWifiThenSync();
} }
void HardcoverSyncActivity::onExit() { Activity::onExit(); } void HardcoverSyncActivity::onExit() { Activity::onExit(); }
void HardcoverSyncActivity::startWifiThenSync() {
if (WiFi.status() == WL_CONNECTED) {
state = SYNCING;
requestUpdate();
return;
}
state = CONNECTING;
requestUpdate();
startActivityForResult(
std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
[this](const ActivityResult&) {
if (WiFi.status() != WL_CONNECTED) {
result = HardcoverPlugin::SyncResult::NO_WIFI;
state = DONE;
requestUpdate();
return;
}
state = SYNCING;
requestUpdate();
});
}
void HardcoverSyncActivity::render(RenderLock&&) { void HardcoverSyncActivity::render(RenderLock&&) {
const auto& metrics = UITheme::getInstance().getMetrics(); const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
@@ -25,13 +50,18 @@ void HardcoverSyncActivity::render(RenderLock&&) {
renderer.clearScreen(); renderer.clearScreen();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Hardcover"); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Hardcover");
if (state == CONNECTING) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Connecting to WiFi...");
renderer.displayBuffer();
return;
}
if (state == SYNCING) { if (state == SYNCING) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Syncing..."); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Syncing...");
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
// DONE state - show result
const char* line1 = nullptr; const char* line1 = nullptr;
const char* line2 = nullptr; const char* line2 = nullptr;
@@ -60,9 +90,8 @@ void HardcoverSyncActivity::render(RenderLock&&) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - (line2 ? 15 : 0), line1, true, renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - (line2 ? 15 : 0), line1, true,
result == HardcoverPlugin::SyncResult::OK ? EpdFontFamily::BOLD result == HardcoverPlugin::SyncResult::OK ? EpdFontFamily::BOLD
: EpdFontFamily::REGULAR); : EpdFontFamily::REGULAR);
if (line2) { if (line2)
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 15, line2); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 15, line2);
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
@@ -86,9 +115,8 @@ void HardcoverSyncActivity::loop() {
} }
if (state == DONE) { if (state == DONE) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back))
finish(); finish();
}
return; return;
} }
} }
+3 -2
View File
@@ -15,8 +15,9 @@ class HardcoverSyncActivity final : public Activity {
void render(RenderLock&&) override; void render(RenderLock&&) override;
private: private:
enum State { SYNCING, DONE }; enum State { CONNECTING, SYNCING, DONE };
State state = SYNCING; State state = CONNECTING;
HardcoverPlugin::SyncResult result = HardcoverPlugin::SyncResult::OK; HardcoverPlugin::SyncResult result = HardcoverPlugin::SyncResult::OK;
void doSync(); void doSync();
void startWifiThenSync();
}; };
+33 -105
View File
@@ -1,31 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""
Hardcover Progress plugin patch.
Adds a "Hardcover Progress" entry to the Plugins settings tab.
When the user presses Sync, the device:
1. Recursively scans the SD card for .epub files.
2. For each file whose progress.bin shows 1-99% read, parses the
epub OPF metadata to extract the ISBN-13.
3. POSTs a GraphQL mutation to https://api.hardcover.app/v1/graphql,
setting that book's status to "Currently Reading" with the current
page number.
The Hardcover API bearer token is stored in CrossPointSettings as a
char[256] field (hardcoverApiToken) and exposed in the web UI.
"""
import os import os
import sys import sys
import glob import glob
import shutil import shutil
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def read_file(path): def read_file(path):
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
return f.read() return f.read()
@@ -40,11 +19,6 @@ def find_first(filename, repo_dir):
sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}") sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}")
return results[0] return results[0]
# ---------------------------------------------------------------------------
# Copy plugin sources into the repo
# ---------------------------------------------------------------------------
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") dest = os.path.join(repo_dir, "src", "activities", "settings")
os.makedirs(dest, exist_ok=True) os.makedirs(dest, exist_ok=True)
@@ -55,11 +29,6 @@ def copy_plugin_sources(plugin_dir, repo_dir):
shutil.copy2(src, dst) shutil.copy2(src, dst)
print(f" \u2713 {fname}") print(f" \u2713 {fname}")
# ---------------------------------------------------------------------------
# CrossPointSettings.h - add hardcoverApiToken char array with default token
# ---------------------------------------------------------------------------
def patch_cross_point_settings_h(repo_dir, token=""): def patch_cross_point_settings_h(repo_dir, token=""):
path = find_first("CrossPointSettings.h", repo_dir) path = find_first("CrossPointSettings.h", repo_dir)
content = read_file(path) content = read_file(path)
@@ -68,8 +37,6 @@ def patch_cross_point_settings_h(repo_dir, token=""):
print(" CrossPointSettings.h already patched, skipping.") print(" CrossPointSettings.h already patched, skipping.")
return return
# Escape token for use in a C string literal (tokens are JWTs, no special chars needed
# beyond basic safety)
safe_token = token.replace('\\', '\\\\').replace('"', '\\"') safe_token = token.replace('\\', '\\\\').replace('"', '\\"')
content = content.replace( content = content.replace(
@@ -80,11 +47,6 @@ def patch_cross_point_settings_h(repo_dir, token=""):
write_file(path, content) write_file(path, content)
print(f" CrossPointSettings.h patched{' (token embedded)' if token else ' (no token)'}.") print(f" CrossPointSettings.h patched{' (token embedded)' if token else ' (no token)'}.")
# ---------------------------------------------------------------------------
# SettingsList.h - add hardcoverApiToken String for web UI exposure
# ---------------------------------------------------------------------------
def patch_settings_list_h(repo_dir): def patch_settings_list_h(repo_dir):
path = find_first("SettingsList.h", repo_dir) path = find_first("SettingsList.h", repo_dir)
content = read_file(path) content = read_file(path)
@@ -104,12 +66,6 @@ def patch_settings_list_h(repo_dir):
write_file(path, content) write_file(path, content)
print(" SettingsList.h patched.") print(" SettingsList.h patched.")
# ---------------------------------------------------------------------------
# SettingAction enum - add HardcoverSync value
# The enum lives in SettingsActivity.h.
# ---------------------------------------------------------------------------
def patch_setting_action_enum(repo_dir): def patch_setting_action_enum(repo_dir):
path = find_first("SettingsActivity.h", repo_dir) path = find_first("SettingsActivity.h", repo_dir)
content = read_file(path) content = read_file(path)
@@ -135,12 +91,6 @@ def patch_setting_action_enum(repo_dir):
write_file(path, content) write_file(path, content)
print(" SettingAction::HardcoverSync added.") print(" SettingAction::HardcoverSync added.")
# ---------------------------------------------------------------------------
# SettingsActivity.h - add pluginsSettings vector (if not already done by
# darkmode/smallerfonts) and categoryCount = 5
# ---------------------------------------------------------------------------
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)
@@ -167,11 +117,6 @@ def patch_settings_h(repo_dir):
write_file(path, content) write_file(path, content)
print(" SettingsActivity.h patched.") print(" SettingsActivity.h patched.")
# ---------------------------------------------------------------------------
# SettingsActivity.cpp - wire up Plugins tab + Hardcover action
# ---------------------------------------------------------------------------
def patch_settings_cpp(repo_dir): 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)
@@ -180,13 +125,11 @@ def patch_settings_cpp(repo_dir):
print(" SettingsActivity.cpp already patched, skipping.") print(" SettingsActivity.cpp already patched, skipping.")
return return
# ---- include HardcoverPlugin.h and HardcoverSyncActivity.h ------------
content = content.replace( content = content.replace(
'#include "SettingsList.h"', '#include "SettingsList.h"',
'#include "SettingsList.h"\n#include "HardcoverPlugin.h"\n#include "HardcoverSyncActivity.h"' '#include "SettingsList.h"\n#include "HardcoverPlugin.h"\n#include "HardcoverSyncActivity.h"'
) )
# ---- categoryNames: add STR_NONE_OPT for Plugins tab (if not done yet) -
if 'StrId::STR_NONE_OPT};' not in content and 'STR_NONE_OPT' not in content.split('categoryNames')[1].split(';')[0]: if 'StrId::STR_NONE_OPT};' not in content and 'STR_NONE_OPT' not in content.split('categoryNames')[1].split(';')[0]:
content = content.replace( content = content.replace(
'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n' 'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n'
@@ -196,16 +139,12 @@ def patch_settings_cpp(repo_dir):
' StrId::STR_NONE_OPT};' ' StrId::STR_NONE_OPT};'
) )
# ---- pluginsSettings.clear() -------------------------------------------
if "pluginsSettings.clear();" not in content: if "pluginsSettings.clear();" not in content:
content = content.replace( content = content.replace(
' systemSettings.clear();', ' systemSettings.clear();',
' systemSettings.clear();\n pluginsSettings.clear();' ' systemSettings.clear();\n pluginsSettings.clear();'
) )
# ---- push Hardcover action into pluginsSettings -------------------------
# Darkmode pushes darkModeState and smallerFontsMode here. We append after.
# Handle three cases: both other plugins present, only darkmode, or neither.
hardcover_push = ( hardcover_push = (
' pluginsSettings.push_back(SettingInfo::Action(\n' ' pluginsSettings.push_back(SettingInfo::Action(\n'
' StrId::STR_NONE_OPT, SettingAction::HardcoverSync\n' ' StrId::STR_NONE_OPT, SettingAction::HardcoverSync\n'
@@ -214,17 +153,17 @@ def patch_settings_cpp(repo_dir):
if 'SettingAction::HardcoverSync' not in content: if 'SettingAction::HardcoverSync' not in content:
anchors = [ anchors = [
# After smallerFontsMode push (both other plugins installed)
' pluginsSettings.push_back(SettingInfo::Enum(\n' ' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n' ' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n' ' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n'
' ));\n', ' ));\n',
# After darkModeState push (only darkmode installed)
' pluginsSettings.push_back(SettingInfo::Enum(\n' ' pluginsSettings.push_back(SettingInfo::Enum(\n'
' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n' ' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n'
' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n' ' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n'
' ));\n', ' ));\n',
# No other plugins: append after CustomiseStatusBar push
' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n', ' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n',
] ]
for anchor in anchors: for anchor in anchors:
@@ -232,7 +171,6 @@ def patch_settings_cpp(repo_dir):
content = content.replace(anchor, anchor + hardcover_push) content = content.replace(anchor, anchor + hardcover_push)
break break
# ---- case 4 -> pluginsSettings (if not done yet) -----------------------
if 'case 4:\n currentSettings = &pluginsSettings;' not in content: if 'case 4:\n currentSettings = &pluginsSettings;' not in content:
content = content.replace( content = content.replace(
' case 3:\n currentSettings = &systemSettings;\n break;', ' case 3:\n currentSettings = &systemSettings;\n break;',
@@ -240,7 +178,6 @@ def patch_settings_cpp(repo_dir):
' case 4:\n currentSettings = &pluginsSettings;\n break;' ' case 4:\n currentSettings = &pluginsSettings;\n break;'
) )
# ---- "Plugins" tab label (if not done yet) -----------------------------
if '"Plugins"' not in content: if '"Plugins"' not in content:
content = content.replace( content = content.replace(
' std::vector<TabInfo> tabs;\n' ' std::vector<TabInfo> tabs;\n'
@@ -256,7 +193,6 @@ def patch_settings_cpp(repo_dir):
' }' ' }'
) )
# ---- nextCatLabel fix (if not done yet) --------------------------------
if 'nextCatLabel' not in content: if 'nextCatLabel' not in content:
content = content.replace( content = content.replace(
' const auto confirmLabel = (selectedSettingIndex == 0)\n' ' const auto confirmLabel = (selectedSettingIndex == 0)\n'
@@ -267,10 +203,6 @@ def patch_settings_cpp(repo_dir):
' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);' ' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);'
) )
# ---- Row label lambda --------------------------------------------------
# ACTION rows never have a key, so we must match on s.action, not s.key.
# The darkmode patch already converted the lambda to the multi-line form
# and added key-based checks. We extend it with our action-based check.
hardcover_label_line = ( hardcover_label_line = (
' if (s.type == SettingType::ACTION &&\n' ' if (s.type == SettingType::ACTION &&\n'
' s.action == SettingAction::HardcoverSync) return "Hardcover Progress";\n' ' s.action == SettingAction::HardcoverSync) return "Hardcover Progress";\n'
@@ -278,7 +210,7 @@ def patch_settings_cpp(repo_dir):
if 'Hardcover Progress' not in content: if 'Hardcover Progress' not in content:
if 'selectedCategoryIndex == 4' in content: if 'selectedCategoryIndex == 4' in content:
# Lambda already patched by another plugin - insert before the closing return.
closing = ( closing = (
' }\n' ' }\n'
' return std::string(I18N.get(settings[index].nameId));\n' ' return std::string(I18N.get(settings[index].nameId));\n'
@@ -309,8 +241,7 @@ def patch_settings_cpp(repo_dir):
content = content.replace(cl, hardcover_label_line + cl, 1) content = content.replace(cl, hardcover_label_line + cl, 1)
break break
else: else:
# No other plugin has patched the lambda yet - create it from scratch.
# Exact line from real unpatched file (6 leading spaces):
old_lambda = ( old_lambda = (
' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,' ' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,'
) )
@@ -325,39 +256,47 @@ def patch_settings_cpp(repo_dir):
) )
content = content.replace(old_lambda, new_lambda) content = content.replace(old_lambda, new_lambda)
# ---- Value text: "Sync" for the HardcoverSync action row --------------- hardcover_value_block = (
if 'SettingAction::HardcoverSync) {\n valueText = "Sync"' not in content:
content = content.replace(
' if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {',
' if (setting.type == SettingType::ACTION &&\n' ' if (setting.type == SettingType::ACTION &&\n'
' setting.action == SettingAction::HardcoverSync) {\n' ' setting.action == SettingAction::HardcoverSync) {\n'
' valueText = "Sync";\n' ' valueText = "Sync";\n'
' } else if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {' ' } else '
) )
if 'SettingAction::HardcoverSync) {\n valueText = "Sync"' not in content:
for value_anchor in [
' if (setting.type == SettingType::ACTION &&\n'
' setting.action == SettingAction::GoodreadsSync) {\n'
' valueText = "Sync";\n'
' } else if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {',
' if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {',
]:
if value_anchor in content:
content = content.replace(value_anchor, hardcover_value_block + value_anchor)
break
# ---- HardcoverSync case in toggleCurrentSetting switch ----------------- hardcover_case = (
content = content.replace( ' case SettingAction::HardcoverSync:\n'
' case SettingAction::Language:\n' ' startActivityForResult(std::make_unique<HardcoverSyncActivity>(renderer, mappedInput), resultHandler);\n'
' startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);\n' ' break;\n'
)
if 'case SettingAction::HardcoverSync:' not in content:
for anchor in [
' case SettingAction::GoodreadsSync:\n'
' startActivityForResult(std::make_unique<GoodreadsSyncActivity>(renderer, mappedInput), resultHandler);\n'
' break;\n' ' break;\n'
' case SettingAction::None:', ' case SettingAction::None:',
' case SettingAction::Language:\n' ' case SettingAction::Language:\n'
' startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);\n' ' startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);\n'
' break;\n' ' break;\n'
' case SettingAction::HardcoverSync:\n' ' case SettingAction::None:',
' startActivityForResult(std::make_unique<HardcoverSyncActivity>(renderer, mappedInput), resultHandler);\n' ]:
' break;\n' if anchor in content:
' case SettingAction::None:' content = content.replace(anchor, hardcover_case + anchor)
) break
write_file(path, content) write_file(path, content)
print(" SettingsActivity.cpp patched.") print(" SettingsActivity.cpp patched.")
# ---------------------------------------------------------------------------
# CrossPointWebServer.cpp - expose hardcoverApiToken + Hardcover label
# ---------------------------------------------------------------------------
def patch_web_server(repo_dir): def patch_web_server(repo_dir):
path = find_first("CrossPointWebServer.cpp", repo_dir) path = find_first("CrossPointWebServer.cpp", repo_dir)
content = read_file(path) content = read_file(path)
@@ -373,7 +312,6 @@ def patch_web_server(repo_dir):
' }\n' ' }\n'
) )
# Try to insert inside the existing s.key block added by darkmode patch
if 'if (s.key) {' in content and 'darkModeState' in content: if 'if (s.key) {' in content and 'darkModeState' in content:
content = content.replace( content = content.replace(
' if (strcmp(s.key, "darkModeState") == 0) {\n', ' if (strcmp(s.key, "darkModeState") == 0) {\n',
@@ -381,7 +319,7 @@ def patch_web_server(repo_dir):
' if (strcmp(s.key, "darkModeState") == 0) {\n' ' if (strcmp(s.key, "darkModeState") == 0) {\n'
) )
else: else:
# No darkmode patch present - add our own block
content = content.replace( content = content.replace(
' doc["category"] = I18N.get(s.category);\n' ' doc["category"] = I18N.get(s.category);\n'
'\n' '\n'
@@ -398,17 +336,9 @@ def patch_web_server(repo_dir):
write_file(path, content) write_file(path, content)
print(" CrossPointWebServer.cpp patched.") print(" CrossPointWebServer.cpp patched.")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
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__))
# ---- Resolve API token from ~/.hardcover or prompt ---------------------
# The token prompt is always shown even with --yes, since it is required
# input, not an optional confirmation.
hardcover_file = os.path.expanduser("~/.hardcover") hardcover_file = os.path.expanduser("~/.hardcover")
token = "" token = ""
@@ -459,7 +389,6 @@ def patch(repo_dir: str, yes_all: bool = False):
print(" Patching CrossPointWebServer.cpp (web UI)...") print(" Patching CrossPointWebServer.cpp (web UI)...")
patch_web_server(repo_dir) patch_web_server(repo_dir)
# ---- Verification ------------------------------------------------------
import glob as _glob import glob as _glob
cpp_results = _glob.glob(os.path.join(repo_dir, "**", "SettingsActivity.cpp"), recursive=True) cpp_results = _glob.glob(os.path.join(repo_dir, "**", "SettingsActivity.cpp"), recursive=True)
if cpp_results: if cpp_results:
@@ -480,7 +409,6 @@ def patch(repo_dir: str, yes_all: bool = False):
sys.exit("ERROR: One or more Hardcover patch checks failed.") sys.exit("ERROR: One or more Hardcover patch checks failed.")
print(" Hardcover plugin patch verified OK.") print(" Hardcover plugin patch verified OK.")
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) < 2: if len(sys.argv) < 2:
sys.exit("Usage: python patch.py <path-to-crosspoint-repo>") sys.exit("Usage: python patch.py <path-to-crosspoint-repo>")