diff --git a/docs/apps.md b/docs/apps.md new file mode 100644 index 00000000..4ef31f31 --- /dev/null +++ b/docs/apps.md @@ -0,0 +1,117 @@ +# Extensions (Apps) + +CrossPoint supports **extensions** implemented as standalone firmware images installed from the SD card. + +This is intended to keep non-core features (games/tools/experiments) out of the main reader firmware. + +## SD layout + +An extension is a firmware binary plus a small manifest: + +``` +/.crosspoint/apps// + app.bin # ESP32-C3 firmware image (starts with magic byte 0xE9) + app.json # manifest (name, version, ...) +``` + +CrossPoint discovers extensions by scanning `/.crosspoint/apps/*/app.json`. + +## How it boots (high level) + +```text +SD app.bin -> CrossPoint flashes to the other OTA slot -> reboot into extension +``` + +Note: CrossPoint OTA updates may overwrite the currently-installed extension slot (two-slot OTA). The extension remains on SD and can be reinstalled. + +## Installing an app (on device) + +1. Home → Apps +2. Select the app +3. Press Launch/Install + +CrossPoint will flash `app.bin` to the OTA partition and reboot. + +## Fast iteration: upload apps over WiFi (no SD card removal) + +Use the File Transfer feature: + +1. On device: Home → File Transfer +2. Connect to WiFi (STA) or create a hotspot (AP) +3. From your computer/phone browser, open the URL shown on the device +4. Open **Apps (Developer)** +5. Fill in: + - App ID (e.g. `chess-puzzles` or `org.example.myapp`) + - Name + - Version + - Optional: author, description, minFirmware +6. Upload your app binary (`app.bin`) +7. On device: Home → Apps → select app → Install + +Notes: +- This page is upload-only. Installing always happens on device. +- The Apps (Developer) page writes to `/.crosspoint/apps//` and generates `app.json`. + +## Building apps with the community SDK + +Recommended SDK: `https://github.com/open-x4-epaper/community-sdk` + +Typical setup (in your app repo): + +1. Add the SDK as a submodule: + ```bash + git submodule add https://github.com/open-x4-epaper/community-sdk.git open-x4-sdk + ``` +2. In `platformio.ini`, add SDK libs as `lib_deps` (symlink form), for example: + ```ini + lib_deps = + BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor + EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay + SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager + InputManager=symlink://open-x4-sdk/libs/hardware/InputManager + ``` +3. Build with PlatformIO: + ```bash + pio run + ``` +4. The firmware binary will usually be: + - `.pio/build//firmware.bin` + +For CrossPoint app uploads: +- Rename/copy your output to `app.bin`, then upload via the Apps (Developer) page. + +## Example: Hello World app + +This repo includes a minimal Hello World app that can be built as a standalone firmware image and installed via the Apps menu. + +Build: + +```bash +.venv/bin/pio run -e hello-world +``` + +Upload the output: + +- File: `.pio/build/hello-world/firmware.bin` +- Upload via: File Transfer → Apps (Developer) +- Suggested App ID: `hello-world` + +Then install on device: + +Home → Apps → Hello World → Install + +## Distribution (proposed) + +Apps should live in their own repositories and publish binaries via GitHub Releases. + +For safety/auditability, registry listings should reference a public source repository (e.g. GitHub URL) so maintainers and users can review the code that produced the release. + +Release assets: +- Required: `app.bin` +- Optional: `app.json` + +Registry location (maintainer choice): +1. Separate repo (recommended): `crosspoint-reader/app-registry` containing `apps.json` +2. Or keep `apps.json` in the main firmware repo + +The on-device store UI can be built later on top of this ecosystem. diff --git a/docs/webserver-endpoints.md b/docs/webserver-endpoints.md index 0abe9df4..05e0412c 100644 --- a/docs/webserver-endpoints.md +++ b/docs/webserver-endpoints.md @@ -7,9 +7,11 @@ This document describes all HTTP and WebSocket endpoints available on the CrossP - [HTTP Endpoints](#http-endpoints) - [GET `/` - Home Page](#get----home-page) - [GET `/files` - File Browser Page](#get-files---file-browser-page) + - [GET `/apps` - Apps (Developer) Page](#get-apps---apps-developer-page) - [GET `/api/status` - Device Status](#get-apistatus---device-status) - [GET `/api/files` - List Files](#get-apifiles---list-files) - [POST `/upload` - Upload File](#post-upload---upload-file) + - [POST `/upload-app` - Upload App (Developer)](#post-upload-app---upload-app-developer) - [POST `/mkdir` - Create Folder](#post-mkdir---create-folder) - [POST `/delete` - Delete File or Folder](#post-delete---delete-file-or-folder) - [WebSocket Endpoint](#websocket-endpoint) @@ -57,6 +59,22 @@ curl http://crosspoint.local/files --- +### GET `/apps` - Apps (Developer) Page + +Serves the Apps (Developer) HTML interface. + +This is a developer-oriented page for uploading app binaries to the SD card under `/.crosspoint/apps/`. +Installing/running apps still happens on the device: Home → Apps. + +**Request:** +```bash +curl http://crosspoint.local/apps +``` + +**Response:** HTML page (200 OK) + +--- + ### GET `/api/status` - Device Status Returns JSON with device status information. @@ -170,6 +188,51 @@ File uploaded successfully: mybook.epub --- +### POST `/upload-app` - Upload App (Developer) + +Uploads an app binary to the SD card under `/.crosspoint/apps//` and creates/updates `app.json`. + +**Important:** this endpoint does not install the app. Install on device via Home → Apps. + +**Request:** +```bash +curl -X POST \ + -F "file=@app.bin" \ + "http://crosspoint.local/upload-app?appId=chess-puzzles&name=Chess%20Puzzles&version=0.1.0" +``` + +**Query Parameters:** + +| Parameter | Required | Description | +|---------------|----------|-------------| +| `appId` | Yes | App identifier; allowed chars: `[A-Za-z0-9._-]`, max 64, no slashes | +| `name` | Yes | Display name | +| `version` | Yes | Version string | +| `author` | No | Author string | +| `description` | No | Short description | +| `minFirmware` | No | Minimum CrossPoint firmware version required | + +**Response (200 OK):** +``` +App uploaded. Install on device: Home -> Apps -> v +``` + +**Error Responses:** + +| Status | Body | Cause | +|--------|--------------------------------------------|-------| +| 400 | `Missing required fields: appId, name, version` | Missing query parameters | +| 400 | `Invalid appId` | appId failed validation | +| 500 | `SD card not ready` | SD not mounted/ready | +| 500 | `Failed to create app.bin.tmp` | File create failed (disk full / FS error) | +| 500 | `Invalid firmware image (bad magic byte)` | Uploaded file is not an ESP32 image | + +**Notes:** +- Upload uses a temp file (`app.bin.tmp`) and finalizes to `app.bin` on success. +- Partial uploads are cleaned up on abort/failure. + +--- + ### POST `/mkdir` - Create Folder Creates a new folder on the SD card. diff --git a/docs/webserver.md b/docs/webserver.md index d1d3bac5..ff3b5869 100644 --- a/docs/webserver.md +++ b/docs/webserver.md @@ -10,6 +10,7 @@ CrossPoint Reader includes a built-in web server that allows you to: - Browse and manage files on your device's SD card - Create folders to organize your ebooks - Delete files and folders +- (Developer) Upload app binaries for installation via the on-device Apps menu ## Prerequisites @@ -118,6 +119,7 @@ The home page displays: Navigation links: - **Home** - Returns to the status page +- **Apps (Developer)** - Upload app binaries to `/.crosspoint/apps//` (install on device) - **File Manager** - Access file management features diff --git a/platformio.ini b/platformio.ini index e8574470..9f0b5e08 100644 --- a/platformio.ini +++ b/platformio.ini @@ -51,12 +51,25 @@ lib_deps = [env:default] extends = base +src_filter = + +<*> + - build_flags = ${base.build_flags} -DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\" [env:gh_release] extends = base +src_filter = + +<*> + - build_flags = ${base.build_flags} -DCROSSPOINT_VERSION=\"${crosspoint.version}\" + +[env:hello-world] +extends = base +src_filter = + -<*> + + + + diff --git a/src/activities/apps/AppsActivity.cpp b/src/activities/apps/AppsActivity.cpp new file mode 100644 index 00000000..07c394c9 --- /dev/null +++ b/src/activities/apps/AppsActivity.cpp @@ -0,0 +1,201 @@ +#include "AppsActivity.h" + +#include +#include +#include +#include +#include +#include +#include + +AppsActivity::AppsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, ExitCallback exitCallback) + : Activity("Apps", renderer, mappedInput), + renderer_(renderer), + mappedInput_(mappedInput), + exitCallback_(exitCallback), + selectedIndex_(0), + needsUpdate_(true), + isFlashing_(false), + flashProgress_(0) {} + +void AppsActivity::onEnter() { + Activity::onEnter(); + scanApps(); + needsUpdate_ = true; +} + +void AppsActivity::onExit() { Activity::onExit(); } + +void AppsActivity::loop() { + if (isFlashing_) { + return; + } + + if (mappedInput_.wasPressed(MappedInputManager::Button::Up) || + mappedInput_.wasPressed(MappedInputManager::Button::Left)) { + if (selectedIndex_ > 0) { + selectedIndex_--; + needsUpdate_ = true; + } + } else if (mappedInput_.wasPressed(MappedInputManager::Button::Down) || + mappedInput_.wasPressed(MappedInputManager::Button::Right)) { + if (selectedIndex_ < static_cast(appList_.size()) - 1) { + selectedIndex_++; + needsUpdate_ = true; + } + } else if (mappedInput_.wasReleased(MappedInputManager::Button::Confirm)) { + if (!appList_.empty()) { + launchApp(); + } + } else if (mappedInput_.wasReleased(MappedInputManager::Button::Back)) { + if (exitCallback_) { + exitCallback_(); + } + } + + if (needsUpdate_) { + render(); + needsUpdate_ = false; + } +} + +void AppsActivity::scanApps() { + appList_.clear(); + selectedIndex_ = 0; + + CrossPoint::AppLoader loader; + appList_ = loader.scanApps(); + + Serial.printf("[%lu] [AppsActivity] Found %d apps\n", millis(), appList_.size()); +} + +void AppsActivity::launchApp() { + if (selectedIndex_ >= static_cast(appList_.size())) { + return; + } + + const auto& app = appList_[selectedIndex_]; + String binPath = app.path + "/app.bin"; + + Serial.printf("[%lu] [AppsActivity] Launching app: %s\n", millis(), app.manifest.name.c_str()); + + isFlashing_ = true; + flashProgress_ = 0; + needsUpdate_ = true; + renderProgress(); + + CrossPoint::AppLoader loader; + bool success = loader.flashApp(binPath, [this](size_t written, size_t total) { + const int nextProgress = (total > 0) ? static_cast((written * 100) / total) : 0; + if (nextProgress != flashProgress_) { + flashProgress_ = nextProgress; + needsUpdate_ = true; + renderProgress(); + } + }); + + if (!success) { + Serial.printf("[%lu] [AppsActivity] Flash failed\n", millis()); + isFlashing_ = false; + needsUpdate_ = true; + } +} + +void AppsActivity::render() { + renderer_.clearScreen(); + + const int pageWidth = renderer_.getScreenWidth(); + const int pageHeight = renderer_.getScreenHeight(); + + // Title + renderer_.drawCenteredText(UI_12_FONT_ID, 30, "Apps"); + + if (appList_.empty()) { + renderer_.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No apps found"); + renderer_.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 30, "Add apps to /.crosspoint/apps/"); + } else { + // List apps + const int startY = 70; + const int lineHeight = 35; + const int maxVisible = 10; + + int startIdx = 0; + if (selectedIndex_ >= maxVisible) { + startIdx = selectedIndex_ - maxVisible + 1; + } + + for (int i = 0; i < maxVisible && (startIdx + i) < static_cast(appList_.size()); i++) { + int idx = startIdx + i; + int y = startY + i * lineHeight; + + const auto& app = appList_[idx]; + char buf[128]; + snprintf(buf, sizeof(buf), "%s v%s", app.manifest.name.c_str(), app.manifest.version.c_str()); + + if (idx == selectedIndex_) { + // Highlight selected item + int textWidth = renderer_.getTextWidth(UI_12_FONT_ID, buf); + int x = (pageWidth - textWidth) / 2 - 10; + renderer_.fillRect(x, y - 5, textWidth + 20, lineHeight - 5); + // Draw white text on black highlight. + renderer_.drawText(UI_12_FONT_ID, x + 10, y, buf, false); + } else { + renderer_.drawCenteredText(UI_10_FONT_ID, y, buf); + } + } + + // Scroll indicator + if (appList_.size() > maxVisible) { + char scrollInfo[32]; + snprintf(scrollInfo, sizeof(scrollInfo), "%d/%d", selectedIndex_ + 1, static_cast(appList_.size())); + renderer_.drawCenteredText(UI_10_FONT_ID, pageHeight - 80, scrollInfo); + } + } + + // Button hints + const char* btn1 = "Back"; + const char* btn2 = appList_.empty() ? "" : "Launch"; + const char* btn3 = "<"; + const char* btn4 = ">"; + + auto labels = mappedInput_.mapLabels(btn1, btn2, btn3, btn4); + renderer_.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer_.displayBuffer(); +} + +void AppsActivity::renderProgress() { + renderer_.clearScreen(); + + const int pageWidth = renderer_.getScreenWidth(); + const int pageHeight = renderer_.getScreenHeight(); + + renderer_.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 40, "Flashing App..."); + + // Progress bar + const int barWidth = 300; + const int barHeight = 30; + const int barX = (pageWidth - barWidth) / 2; + const int barY = pageHeight / 2; + + // Border + renderer_.drawRect(barX, barY, barWidth, barHeight); + + // Fill + int fillWidth = (flashProgress_ * barWidth) / 100; + if (fillWidth > 0) { + renderer_.fillRect(barX + 1, barY + 1, fillWidth - 2, barHeight - 2); + } + + // Percentage text + char percentStr[16]; + snprintf(percentStr, sizeof(percentStr), "%d%%", flashProgress_); + renderer_.drawCenteredText(UI_12_FONT_ID, barY + barHeight + 20, percentStr); + + renderer_.displayBuffer(); +} + +void AppsActivity::showProgress(size_t written, size_t total) { + flashProgress_ = static_cast((written * 100) / total); + needsUpdate_ = true; +} diff --git a/src/activities/apps/AppsActivity.h b/src/activities/apps/AppsActivity.h new file mode 100644 index 00000000..dd937e30 --- /dev/null +++ b/src/activities/apps/AppsActivity.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +#include +#include + +#include "../../extension/AppLoader.h" +#include "../Activity.h" + +class AppsActivity : public Activity { + public: + using ExitCallback = std::function; + + AppsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, ExitCallback exitCallback); + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + GfxRenderer& renderer_; + MappedInputManager& mappedInput_; + ExitCallback exitCallback_; + + std::vector appList_; + int selectedIndex_; + bool needsUpdate_; + bool isFlashing_; + int flashProgress_; + + void scanApps(); + void launchApp(); + void render(); + void renderProgress(); + void showProgress(size_t written, size_t total); +}; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 678af7cb..573fbde3 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -24,7 +24,7 @@ void HomeActivity::taskTrampoline(void* param) { } int HomeActivity::getMenuItemCount() const { - int count = 3; // My Library, File transfer, Settings + int count = 4; // My Library, File transfer, Apps, Settings if (hasContinueReading) count++; if (hasOpdsUrl) count++; return count; @@ -176,6 +176,7 @@ void HomeActivity::loop() { const int myLibraryIdx = idx++; const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int fileTransferIdx = idx++; + const int appsIdx = idx++; const int settingsIdx = idx; if (selectorIndex == continueIdx) { @@ -186,6 +187,8 @@ void HomeActivity::loop() { onOpdsBrowserOpen(); } else if (selectorIndex == fileTransferIdx) { onFileTransferOpen(); + } else if (selectorIndex == appsIdx) { + onAppsOpen(); } else if (selectorIndex == settingsIdx) { onSettingsOpen(); } @@ -504,7 +507,8 @@ void HomeActivity::render() { // --- Bottom menu tiles --- // Build menu items dynamically - std::vector menuItems = {"My Library", "File Transfer", "Settings"}; + // Keep this list in sync with getMenuItemCount() and loop() index mapping. + std::vector menuItems = {"My Library", "File Transfer", "Apps", "Settings"}; if (hasOpdsUrl) { // Insert OPDS Browser after My Library menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 52963514..4d305f84 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -26,6 +26,7 @@ class HomeActivity final : public Activity { const std::function onSettingsOpen; const std::function onFileTransferOpen; const std::function onOpdsBrowserOpen; + const std::function onAppsOpen; static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); @@ -39,13 +40,14 @@ class HomeActivity final : public Activity { explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onContinueReading, const std::function& onMyLibraryOpen, const std::function& onSettingsOpen, const std::function& onFileTransferOpen, - const std::function& onOpdsBrowserOpen) + const std::function& onOpdsBrowserOpen, const std::function& onAppsOpen) : Activity("Home", renderer, mappedInput), onContinueReading(onContinueReading), onMyLibraryOpen(onMyLibraryOpen), onSettingsOpen(onSettingsOpen), onFileTransferOpen(onFileTransferOpen), - onOpdsBrowserOpen(onOpdsBrowserOpen) {} + onOpdsBrowserOpen(onOpdsBrowserOpen), + onAppsOpen(onAppsOpen) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/apps/hello-world/HelloWorldActivity.cpp b/src/apps/hello-world/HelloWorldActivity.cpp new file mode 100644 index 00000000..871a6434 --- /dev/null +++ b/src/apps/hello-world/HelloWorldActivity.cpp @@ -0,0 +1,65 @@ +#include "HelloWorldActivity.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "fontIds.h" + +RTC_DATA_ATTR int boot_count = 0; + +namespace { +EpdFont ui12RegularFont(&ubuntu_12_regular); +EpdFont ui12BoldFont(&ubuntu_12_bold); +EpdFontFamily ui12FontFamily(&ui12RegularFont, &ui12BoldFont); +} // namespace + +HelloWorldActivity::HelloWorldActivity(HalDisplay& display, HalGPIO& input) + : display_(display), input_(input), needsUpdate_(true) {} + +void HelloWorldActivity::onEnter() { + display_.begin(); + + boot_count++; + if (boot_count > 3) { + returnToLauncher(); + return; + } + + needsUpdate_ = true; +} + +void HelloWorldActivity::loop() { + if (input_.wasPressed(HalGPIO::BTN_BACK)) { + returnToLauncher(); + return; + } + + if (needsUpdate_) { + render(); + needsUpdate_ = false; + } +} + +void HelloWorldActivity::onExit() {} + +void HelloWorldActivity::render() { + GfxRenderer renderer(display_); + renderer.insertFont(UI_12_FONT_ID, ui12FontFamily); + renderer.clearScreen(); + + const int pageHeight = renderer.getScreenHeight(); + const int y = (pageHeight - renderer.getLineHeight(UI_12_FONT_ID)) / 2; + renderer.drawCenteredText(UI_12_FONT_ID, y, "Hello World!"); + renderer.displayBuffer(); +} + +void HelloWorldActivity::returnToLauncher() { + boot_count = 0; + esp_ota_set_boot_partition(esp_ota_get_next_update_partition(NULL)); + esp_restart(); +} diff --git a/src/apps/hello-world/HelloWorldActivity.h b/src/apps/hello-world/HelloWorldActivity.h new file mode 100644 index 00000000..b2ebdf8e --- /dev/null +++ b/src/apps/hello-world/HelloWorldActivity.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +class HelloWorldActivity { + public: + HelloWorldActivity(HalDisplay& display, HalGPIO& input); + + void onEnter(); + void loop(); + void onExit(); + + private: + HalDisplay& display_; + HalGPIO& input_; + bool needsUpdate_; + + void render(); + void returnToLauncher(); +}; diff --git a/src/apps/hello-world/main.cpp b/src/apps/hello-world/main.cpp new file mode 100644 index 00000000..1a32497a --- /dev/null +++ b/src/apps/hello-world/main.cpp @@ -0,0 +1,31 @@ +#include +#include +#include + +#include "HelloWorldActivity.h" + +HalDisplay display; +HalGPIO gpio; +HelloWorldActivity activity(display, gpio); + +void setup() { + gpio.begin(); + + // Only start serial if USB connected + if (gpio.isUsbConnected()) { + Serial.begin(115200); + Serial.println("[HelloWorld] Starting..."); + } + + activity.onEnter(); + + if (Serial) { + Serial.println("[HelloWorld] Activity started"); + } +} + +void loop() { + gpio.update(); + activity.loop(); + delay(10); +} diff --git a/src/extension/AppLoader.cpp b/src/extension/AppLoader.cpp new file mode 100644 index 00000000..18a76d7e --- /dev/null +++ b/src/extension/AppLoader.cpp @@ -0,0 +1,312 @@ +#include "AppLoader.h" + +#include +#include +#include + +#include + +#include "Battery.h" + +namespace CrossPoint { + +std::vector AppLoader::scanApps() { + std::vector apps; + + if (!isSDReady()) { + Serial.printf("[%lu] [AppLoader] SD card not ready\n", millis()); + return apps; + } + + FsFile appsDir = SdMan.open(APPS_BASE_PATH, O_RDONLY); + if (!appsDir || !appsDir.isDirectory()) { + Serial.printf("[%lu] [AppLoader] Apps directory not found: %s\n", millis(), APPS_BASE_PATH); + if (appsDir) appsDir.close(); + return apps; + } + + char name[128]; + FsFile entry = appsDir.openNextFile(); + + while (entry) { + if (entry.isDirectory()) { + entry.getName(name, sizeof(name)); + + String appPath = APPS_BASE_PATH; + if (!appPath.endsWith("/")) { + appPath += "/"; + } + appPath += name; + + String manifestPath = buildManifestPath(appPath); + AppManifest manifest = parseManifest(manifestPath); + + if (!manifest.name.isEmpty()) { + apps.emplace_back(manifest, appPath); + Serial.printf("[%lu] [AppLoader] Found app: %s\n", millis(), manifest.name.c_str()); + } else { + Serial.printf("[%lu] [AppLoader] Skipping directory (no valid manifest): %s\n", millis(), name); + } + } + + entry.close(); + entry = appsDir.openNextFile(); + } + + appsDir.close(); + + Serial.printf("[%lu] [AppLoader] Found %u app(s)\n", millis(), apps.size()); + return apps; +} + +AppManifest AppLoader::parseManifest(const String& path) { + AppManifest manifest; + + if (!isSDReady()) { + Serial.printf("[%lu] [AppLoader] SD card not ready, cannot parse manifest\n", millis()); + return manifest; + } + + if (!SdMan.exists(path.c_str())) { + Serial.printf("[%lu] [AppLoader] Manifest file not found: %s\n", millis(), path.c_str()); + return manifest; + } + + FsFile file = SdMan.open(path.c_str(), O_RDONLY); + if (!file) { + Serial.printf("[%lu] [AppLoader] Failed to open manifest file: %s\n", millis(), path.c_str()); + return manifest; + } + + const size_t fileSize = file.size(); + if (fileSize == 0) { + Serial.printf("[%lu] [AppLoader] Manifest file is empty: %s\n", millis(), path.c_str()); + file.close(); + return manifest; + } + + if (fileSize > MAX_MANIFEST_SIZE) { + Serial.printf("[%lu] [AppLoader] Manifest file too large (%u bytes, max %u): %s\n", millis(), fileSize, + MAX_MANIFEST_SIZE, path.c_str()); + file.close(); + return manifest; + } + + std::unique_ptr buffer(new char[fileSize + 1]); + const size_t bytesRead = file.read(buffer.get(), fileSize); + buffer[bytesRead] = '\0'; + file.close(); + + if (bytesRead != fileSize) { + Serial.printf("[%lu] [AppLoader] Failed to read complete manifest file (read %u of %u bytes): %s\n", millis(), + bytesRead, fileSize, path.c_str()); + return manifest; + } + + // Handle UTF-8 BOM if the manifest was created by an editor that writes it. + const char* json = buffer.get(); + if (bytesRead >= 3 && static_cast(json[0]) == 0xEF && static_cast(json[1]) == 0xBB && + static_cast(json[2]) == 0xBF) { + json += 3; + } + + JsonDocument doc; + const DeserializationError error = deserializeJson(doc, json); + + if (error) { + Serial.printf("[%lu] [AppLoader] JSON parse error in %s: %s\n", millis(), path.c_str(), error.c_str()); + return manifest; + } + + if (doc["name"].is()) { + manifest.name = doc["name"].as(); + } else { + Serial.printf("[%lu] [AppLoader] Missing or invalid 'name' field in: %s\n", millis(), path.c_str()); + return manifest; + } + + if (doc["version"].is()) { + manifest.version = doc["version"].as(); + } else { + manifest.version = "1.0.0"; + } + + if (doc["description"].is()) { + manifest.description = doc["description"].as(); + } else { + manifest.description = ""; + } + + if (doc["author"].is()) { + manifest.author = doc["author"].as(); + } else { + manifest.author = "Unknown"; + } + + if (doc["minFirmware"].is()) { + manifest.minFirmware = doc["minFirmware"].as(); + } else { + manifest.minFirmware = "0.0.0"; + } + + return manifest; +} + +bool AppLoader::flashApp(const String& binPath, ProgressCallback callback) { + if (!isSDReady()) { + Serial.printf("[%lu] [AppLoader] SD card not ready, cannot flash app\n", millis()); + return false; + } + + const uint16_t batteryPercentage = battery.readPercentage(); + if (batteryPercentage < 20) { + Serial.printf("[%lu] [AppLoader] Battery: %u%% - TOO LOW\n", millis(), batteryPercentage); + Serial.printf("[%lu] [AppLoader] Flash aborted: battery below 20%%\n", millis()); + return false; + } + Serial.printf("[%lu] [AppLoader] Battery: %u%% - OK\n", millis(), batteryPercentage); + + if (!SdMan.exists(binPath.c_str())) { + Serial.printf("[%lu] [AppLoader] App binary not found: %s\n", millis(), binPath.c_str()); + return false; + } + + FsFile file = SdMan.open(binPath.c_str(), O_RDONLY); + if (!file) { + Serial.printf("[%lu] [AppLoader] Failed to open app binary: %s\n", millis(), binPath.c_str()); + return false; + } + + const size_t fileSize = file.size(); + if (fileSize == 0) { + Serial.printf("[%lu] [AppLoader] App binary is empty: %s\n", millis(), binPath.c_str()); + file.close(); + return false; + } + + uint8_t magicByte = 0; + const size_t magicRead = file.read(&magicByte, 1); + if (magicRead != 1 || magicByte != 0xE9) { + Serial.printf("[%lu] [AppLoader] Invalid firmware magic byte: 0x%02X\n", millis(), magicByte); + file.close(); + return false; + } + + file.close(); + file = SdMan.open(binPath.c_str(), O_RDONLY); + if (!file) { + Serial.printf("[%lu] [AppLoader] Failed to reopen app binary: %s\n", millis(), binPath.c_str()); + return false; + } + + const esp_partition_t* running = esp_ota_get_running_partition(); + if (!running) { + Serial.printf("[%lu] [AppLoader] Failed to get running partition\n", millis()); + file.close(); + return false; + } + + const esp_partition_t* target = esp_ota_get_next_update_partition(NULL); + if (!target) { + Serial.printf("[%lu] [AppLoader] No OTA partition available\n", millis()); + file.close(); + return false; + } + + if (target->address == running->address) { + Serial.printf("[%lu] [AppLoader] Target partition matches running partition, aborting\n", millis()); + file.close(); + return false; + } + + if (fileSize >= target->size) { + Serial.printf("[%lu] [AppLoader] Firmware too large (%u bytes, max %u)\n", millis(), fileSize, target->size); + file.close(); + return false; + } + + Serial.printf("[%lu] [AppLoader] Flashing to partition: %s (offset: 0x%06X)\n", millis(), target->label, + target->address); + + esp_ota_handle_t otaHandle = 0; + esp_err_t err = esp_ota_begin(target, fileSize, &otaHandle); + if (err != ESP_OK) { + Serial.printf("[%lu] [AppLoader] OTA begin failed: %d\n", millis(), err); + file.close(); + return false; + } + + if (callback) { + callback(0, fileSize); + } + + size_t totalWritten = 0; + // Larger chunks reduce SD/OTA overhead significantly. + // 32KB is a good balance on ESP32-C3: faster writes without blowing RAM. + static constexpr size_t flashChunkSize = 32 * 1024; + static uint8_t buffer[flashChunkSize]; + + size_t lastNotifiedPercent = 0; + + while (totalWritten < fileSize) { + const size_t remaining = fileSize - totalWritten; + const size_t toRead = remaining < flashChunkSize ? remaining : flashChunkSize; + const size_t bytesRead = file.read(buffer, toRead); + if (bytesRead == 0) { + Serial.printf("[%lu] [AppLoader] Failed to read firmware data\n", millis()); + esp_ota_end(otaHandle); + file.close(); + return false; + } + + err = esp_ota_write(otaHandle, buffer, bytesRead); + if (err != ESP_OK) { + Serial.printf("[%lu] [AppLoader] OTA write failed at %u/%u bytes: %d\n", millis(), totalWritten, fileSize, err); + esp_ota_end(otaHandle); + file.close(); + return false; + } + + totalWritten += bytesRead; + + if (callback) { + const size_t percent = (totalWritten * 100) / fileSize; + // Throttle UI updates; each screen refresh is ~400ms. + if (percent >= lastNotifiedPercent + 10 || percent == 100) { + lastNotifiedPercent = percent; + callback(totalWritten, fileSize); + } + } + } + + file.close(); + + err = esp_ota_end(otaHandle); + if (err != ESP_OK) { + Serial.printf("[%lu] [AppLoader] OTA end failed: %d\n", millis(), err); + return false; + } + + err = esp_ota_set_boot_partition(target); + if (err != ESP_OK) { + Serial.printf("[%lu] [AppLoader] Failed to set boot partition: %d\n", millis(), err); + return false; + } + + Serial.printf("[%lu] [AppLoader] Flash complete. Rebooting...\n", millis()); + esp_restart(); + return true; +} + +String AppLoader::buildManifestPath(const String& appDir) const { + String path = appDir; + if (!path.endsWith("/")) { + path += "/"; + } + path += MANIFEST_FILENAME; + return path; +} + +bool AppLoader::isSDReady() const { return SdMan.ready(); } + +} // namespace CrossPoint diff --git a/src/extension/AppLoader.h b/src/extension/AppLoader.h new file mode 100644 index 00000000..c75af201 --- /dev/null +++ b/src/extension/AppLoader.h @@ -0,0 +1,125 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace CrossPoint { + +/** + * @brief App manifest data structure + * + * Contains the metadata parsed from app.json files + */ +struct AppManifest { + String name; ///< Display name of the app + String version; ///< Version string (e.g., "1.0.0") + String description; ///< Brief description of the app + String author; ///< Author/creator name + String minFirmware; ///< Minimum firmware version required + + AppManifest() = default; + AppManifest(const String& n, const String& v, const String& d, const String& a, const String& f) + : name(n), version(v), description(d), author(a), minFirmware(f) {} +}; + +/** + * @brief Complete app information including manifest and path + * + * Combines the parsed manifest with file system path information + */ +struct AppInfo { + AppManifest manifest; ///< The parsed app manifest + String path; ///< Full path to the app directory (e.g., "/.crosspoint/apps/test") + + AppInfo() = default; + AppInfo(const AppManifest& m, const String& p) : manifest(m), path(p) {} +}; + +/** + * @brief Utility class for loading and managing apps from SD card + * + * Handles scanning for app manifests in the /.crosspoint/apps directory, + * parsing JSON manifests, and providing access to app information. + * + * Usage: + * AppLoader loader; + * std::vector apps = loader.scanApps(); + * for (const auto& app : apps) { + * Serial.println(app.manifest.name); + * } + */ +class AppLoader { + public: + AppLoader() = default; + ~AppLoader() = default; + + using ProgressCallback = std::function; + + /** + * @brief Scan for apps in the /.crosspoint/apps directory + * + * Searches for subdirectories under /.crosspoint/apps and attempts to + * parse app.json files in each directory. Invalid or missing manifests + * are skipped gracefully. + * + * @return Vector of AppInfo objects for all valid apps found + */ + std::vector scanApps(); + + /** + * @brief Parse an app.json manifest file + * + * Reads and parses a JSON manifest file, extracting the required fields. + * Logs errors for malformed JSON but does not throw exceptions. + * + * @param path Full path to the app.json file + * @return AppManifest object with parsed data (empty on failure) + */ + AppManifest parseManifest(const String& path); + + /** + * @brief Flash an app binary from SD card to the OTA partition + * + * @param binPath Full path to the binary file on SD card + * @param callback Optional progress callback (values 0-100) + * @return true if flashing succeeded, false on error + */ + bool flashApp(const String& binPath, ProgressCallback callback = nullptr); + + private: + /** + * @brief Base path for apps directory + */ + static constexpr const char* APPS_BASE_PATH = "/.crosspoint/apps"; + + /** + * @brief Name of the manifest file in each app directory + */ + static constexpr const char* MANIFEST_FILENAME = "app.json"; + + /** + * @brief Maximum file size to read for manifest (prevents memory issues) + */ + static constexpr size_t MAX_MANIFEST_SIZE = 8192; // 8KB + + /** + * @brief Helper to build manifest file path from app directory path + * + * @param appDir Path to the app directory + * @return Full path to the app.json file + */ + String buildManifestPath(const String& appDir) const; + + /** + * @brief Check if SD card is ready + * + * @return true if SD card is initialized and ready + */ + bool isSDReady() const; +}; + +} // namespace CrossPoint diff --git a/src/main.cpp b/src/main.cpp index 89c4e13c..266bada5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,7 @@ #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" +#include "activities/apps/AppsActivity.h" #include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/SleepActivity.h" #include "activities/browser/OpdsBookBrowserActivity.h" @@ -236,10 +237,15 @@ void onGoToBrowser() { enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome)); } +void onGoToApps() { + exitActivity(); + enterNewActivity(new AppsActivity(renderer, mappedInputManager, onGoHome)); +} + void onGoHome() { exitActivity(); enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings, - onGoToFileTransfer, onGoToBrowser)); + onGoToFileTransfer, onGoToBrowser, onGoToApps)); } void setupDisplayAndFonts() { diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index a135c9f0..750e1b55 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -9,6 +9,7 @@ #include +#include "html/AppsPageHtml.generated.h" #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" #include "util/StringUtils.h" @@ -98,6 +99,7 @@ void CrossPointWebServer::begin() { Serial.printf("[%lu] [WEB] Setting up routes...\n", millis()); server->on("/", HTTP_GET, [this] { handleRoot(); }); server->on("/files", HTTP_GET, [this] { handleFileList(); }); + server->on("/apps", HTTP_GET, [this] { handleAppsPage(); }); server->on("/api/status", HTTP_GET, [this] { handleStatus(); }); server->on("/api/files", HTTP_GET, [this] { handleFileListData(); }); @@ -106,6 +108,9 @@ void CrossPointWebServer::begin() { // Upload endpoint with special handling for multipart form data server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); }); + // App upload endpoint (developer feature) + server->on("/upload-app", HTTP_POST, [this] { handleUploadAppPost(); }, [this] { handleUploadApp(); }); + // Create folder endpoint server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); }); @@ -256,6 +261,11 @@ void CrossPointWebServer::handleRoot() const { Serial.printf("[%lu] [WEB] Served root page\n", millis()); } +void CrossPointWebServer::handleAppsPage() const { + server->send(200, "text/html", AppsPageHtml); + Serial.printf("[%lu] [WEB] Served apps page\n", millis()); +} + void CrossPointWebServer::handleNotFound() const { String message = "404 Not Found\n\n"; message += "URI: " + server->uri() + "\n"; @@ -466,6 +476,118 @@ static size_t uploadSize = 0; static bool uploadSuccess = false; static String uploadError = ""; +// Static variables for app upload handling (developer feature) +static FsFile appUploadFile; +static String appUploadAppId; +static String appUploadName; +static String appUploadVersion; +static String appUploadAuthor; +static String appUploadDescription; +static String appUploadMinFirmware; +static String appUploadTempPath; +static String appUploadFinalPath; +static String appUploadManifestPath; +static size_t appUploadSize = 0; +static bool appUploadSuccess = false; +static String appUploadError = ""; + +constexpr size_t APP_UPLOAD_BUFFER_SIZE = 4096; +static uint8_t appUploadBuffer[APP_UPLOAD_BUFFER_SIZE]; +static size_t appUploadBufferPos = 0; + +static bool flushAppUploadBuffer() { + if (appUploadBufferPos > 0 && appUploadFile) { + esp_task_wdt_reset(); + const size_t written = appUploadFile.write(appUploadBuffer, appUploadBufferPos); + esp_task_wdt_reset(); + + if (written != appUploadBufferPos) { + appUploadBufferPos = 0; + return false; + } + appUploadBufferPos = 0; + } + return true; +} + +static bool isValidAppId(const String& appId) { + if (appId.isEmpty() || appId.length() > 64) { + return false; + } + if (appId.startsWith(".")) { + return false; + } + if (appId.indexOf("..") >= 0) { + return false; + } + if (appId.indexOf('/') >= 0 || appId.indexOf('\\') >= 0) { + return false; + } + for (size_t i = 0; i < appId.length(); i++) { + const char c = appId.charAt(i); + const bool ok = + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + if (!ok) { + return false; + } + } + return true; +} + +static bool renameFileAtomic(const String& from, const String& to) { + if (!SdMan.exists(from.c_str())) { + return false; + } + if (SdMan.exists(to.c_str())) { + SdMan.remove(to.c_str()); + } + + FsFile src = SdMan.open(from.c_str(), O_RDONLY); + if (!src) { + return false; + } + + // Try SdFat rename first. + if (src.rename(to.c_str())) { + src.close(); + return true; + } + src.close(); + + // Fallback: copy + delete. + FsFile in = SdMan.open(from.c_str(), O_RDONLY); + if (!in) { + return false; + } + + FsFile out; + if (!SdMan.openFileForWrite("WEB", to, out)) { + in.close(); + return false; + } + + static uint8_t copyBuf[2048]; + while (true) { + const int n = in.read(copyBuf, sizeof(copyBuf)); + if (n <= 0) { + break; + } + if (out.write(copyBuf, n) != static_cast(n)) { + out.close(); + in.close(); + SdMan.remove(to.c_str()); + return false; + } + yield(); + esp_task_wdt_reset(); + } + + out.close(); + in.close(); + SdMan.remove(from.c_str()); + return true; +} + // Upload write buffer - batches small writes into larger SD card operations // 4KB is a good balance: large enough to reduce syscall overhead, small enough // to keep individual write times short and avoid watchdog issues @@ -655,6 +777,186 @@ void CrossPointWebServer::handleUploadPost() const { } } +void CrossPointWebServer::handleUploadApp() const { + // Reset watchdog at start of every upload callback + esp_task_wdt_reset(); + + if (!running || !server) { + Serial.printf("[%lu] [WEB] [APPUPLOAD] ERROR: handleUploadApp called but server not running!\n", millis()); + return; + } + + const HTTPUpload& upload = server->upload(); + + if (upload.status == UPLOAD_FILE_START) { + appUploadSuccess = false; + appUploadError = ""; + appUploadSize = 0; + appUploadBufferPos = 0; + + // NOTE: we use query args (not multipart fields) because multipart fields + // aren't reliably available until after upload completes. + if (!server->hasArg("appId") || !server->hasArg("name") || !server->hasArg("version")) { + appUploadError = "Missing required fields: appId, name, version"; + return; + } + + appUploadAppId = server->arg("appId"); + appUploadName = server->arg("name"); + appUploadVersion = server->arg("version"); + appUploadAuthor = server->hasArg("author") ? server->arg("author") : ""; + appUploadDescription = server->hasArg("description") ? server->arg("description") : ""; + appUploadMinFirmware = server->hasArg("minFirmware") ? server->arg("minFirmware") : ""; + + if (!isValidAppId(appUploadAppId)) { + appUploadError = "Invalid appId"; + return; + } + + if (!SdMan.ready()) { + appUploadError = "SD card not ready"; + return; + } + + const String appDir = String("/.crosspoint/apps/") + appUploadAppId; + if (!SdMan.ensureDirectoryExists("/.crosspoint") || !SdMan.ensureDirectoryExists("/.crosspoint/apps") || + !SdMan.ensureDirectoryExists(appDir.c_str())) { + appUploadError = "Failed to create app directory"; + return; + } + + appUploadTempPath = appDir + "/app.bin.tmp"; + appUploadFinalPath = appDir + "/app.bin"; + appUploadManifestPath = appDir + "/app.json"; + + if (SdMan.exists(appUploadTempPath.c_str())) { + SdMan.remove(appUploadTempPath.c_str()); + } + + if (!SdMan.openFileForWrite("APPUPLOAD", appUploadTempPath, appUploadFile)) { + appUploadError = "Failed to create app.bin.tmp"; + return; + } + + Serial.printf("[%lu] [WEB] [APPUPLOAD] START: %s (%s v%s)\n", millis(), appUploadAppId.c_str(), + appUploadName.c_str(), appUploadVersion.c_str()); + } else if (upload.status == UPLOAD_FILE_WRITE) { + if (appUploadFile && appUploadError.isEmpty()) { + const uint8_t* data = upload.buf; + size_t remaining = upload.currentSize; + + while (remaining > 0) { + const size_t space = APP_UPLOAD_BUFFER_SIZE - appUploadBufferPos; + const size_t toCopy = (remaining < space) ? remaining : space; + memcpy(appUploadBuffer + appUploadBufferPos, data, toCopy); + appUploadBufferPos += toCopy; + data += toCopy; + remaining -= toCopy; + + if (appUploadBufferPos >= APP_UPLOAD_BUFFER_SIZE) { + if (!flushAppUploadBuffer()) { + appUploadError = "Failed writing app.bin.tmp (disk full?)"; + appUploadFile.close(); + SdMan.remove(appUploadTempPath.c_str()); + return; + } + } + } + + appUploadSize += upload.currentSize; + } + } else if (upload.status == UPLOAD_FILE_END) { + if (appUploadFile) { + if (!flushAppUploadBuffer()) { + appUploadError = "Failed writing final app.bin.tmp data"; + } + appUploadFile.close(); + + if (!appUploadError.isEmpty()) { + SdMan.remove(appUploadTempPath.c_str()); + return; + } + + if (appUploadSize == 0) { + appUploadError = "Uploaded file is empty"; + SdMan.remove(appUploadTempPath.c_str()); + return; + } + + // Quick firmware sanity check: first byte should be 0xE9. + FsFile checkFile = SdMan.open(appUploadTempPath.c_str(), O_RDONLY); + if (!checkFile) { + appUploadError = "Failed to reopen uploaded file"; + SdMan.remove(appUploadTempPath.c_str()); + return; + } + uint8_t magic = 0; + if (checkFile.read(&magic, 1) != 1 || magic != 0xE9) { + checkFile.close(); + appUploadError = "Invalid firmware image (bad magic byte)"; + SdMan.remove(appUploadTempPath.c_str()); + return; + } + checkFile.close(); + + if (!renameFileAtomic(appUploadTempPath, appUploadFinalPath)) { + appUploadError = "Failed to finalize app.bin"; + SdMan.remove(appUploadTempPath.c_str()); + return; + } + + // Write manifest JSON (atomic). + JsonDocument doc; + doc["name"] = appUploadName; + doc["version"] = appUploadVersion; + doc["description"] = appUploadDescription; + doc["author"] = appUploadAuthor; + doc["minFirmware"] = appUploadMinFirmware; + doc["id"] = appUploadAppId; + doc["uploadMs"] = millis(); + + String manifestJson; + serializeJson(doc, manifestJson); + + const String manifestTmp = appUploadManifestPath + ".tmp"; + if (!SdMan.writeFile(manifestTmp.c_str(), manifestJson)) { + appUploadError = "Failed to write app.json"; + return; + } + if (!renameFileAtomic(manifestTmp, appUploadManifestPath)) { + SdMan.remove(manifestTmp.c_str()); + appUploadError = "Failed to finalize app.json"; + return; + } + + appUploadSuccess = true; + Serial.printf("[%lu] [WEB] [APPUPLOAD] Complete: %s (%u bytes)\n", millis(), appUploadAppId.c_str(), + static_cast(appUploadSize)); + } + } else if (upload.status == UPLOAD_FILE_ABORTED) { + appUploadBufferPos = 0; + if (appUploadFile) { + appUploadFile.close(); + } + if (!appUploadTempPath.isEmpty()) { + SdMan.remove(appUploadTempPath.c_str()); + } + appUploadError = "Upload aborted"; + Serial.printf("[%lu] [WEB] [APPUPLOAD] Upload aborted\n", millis()); + } +} + +void CrossPointWebServer::handleUploadAppPost() const { + if (appUploadSuccess) { + server->send(200, "text/plain", + "App uploaded. Install on device: Home -> Apps -> " + appUploadName + " v" + appUploadVersion); + } else { + const String error = appUploadError.isEmpty() ? "Unknown error during app upload" : appUploadError; + const int code = (error.startsWith("Missing") || error.startsWith("Invalid")) ? 400 : 500; + server->send(code, "text/plain", error); + } +} + void CrossPointWebServer::handleCreateFolder() const { // Get folder name from form data if (!server->hasArg("name")) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 36030292..cac92eec 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -69,6 +69,7 @@ class CrossPointWebServer { // Request handlers void handleRoot() const; + void handleAppsPage() const; void handleNotFound() const; void handleStatus() const; void handleFileList() const; @@ -76,6 +77,8 @@ class CrossPointWebServer { void handleDownload() const; void handleUpload() const; void handleUploadPost() const; + void handleUploadApp() const; + void handleUploadAppPost() const; void handleCreateFolder() const; void handleDelete() const; }; diff --git a/src/network/html/AppsPage.html b/src/network/html/AppsPage.html new file mode 100644 index 00000000..8f7bd3cb --- /dev/null +++ b/src/network/html/AppsPage.html @@ -0,0 +1,355 @@ + + + + + + CrossPoint Reader - Apps (Developer) + + + + + +

Apps (Developer)

+

Upload an app binary to the device SD card for installation via the on-device Apps menu.

+ +
+
+ This is a developer feature. It only uploads files to the SD card. + To install/run an app, use the device UI: Home → Apps → select app → Install. +
+ +
+ + +
Allowed: letters, numbers, dot, underscore, dash. No slashes. Max 64 chars.
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+
+
+ +
+
+ +
+

+ CrossPoint E-Reader • Open Source +

+
+ + + + diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 95993b8e..d03a5384 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -575,6 +575,7 @@