mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 14:47:37 +03:00
Merge f3ec7b8800 into 78d6e5931c
This commit is contained in:
commit
39449e9846
117
docs/apps.md
Normal file
117
docs/apps.md
Normal file
@ -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/<appId>/
|
||||
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/<appId>/` 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/<env>/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.
|
||||
@ -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/<appId>/` 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 -> <Name> v<Version>
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
@ -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/<appId>/` (install on device)
|
||||
- **File Manager** - Access file management features
|
||||
|
||||
<img src="./images/wifi/webserver_homepage.png" width="600">
|
||||
|
||||
@ -51,12 +51,25 @@ lib_deps =
|
||||
|
||||
[env:default]
|
||||
extends = base
|
||||
src_filter =
|
||||
+<*>
|
||||
-<apps/hello-world/>
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\"
|
||||
|
||||
[env:gh_release]
|
||||
extends = base
|
||||
src_filter =
|
||||
+<*>
|
||||
-<apps/hello-world/>
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
|
||||
|
||||
[env:hello-world]
|
||||
extends = base
|
||||
src_filter =
|
||||
-<*>
|
||||
+<apps/hello-world/>
|
||||
+<MappedInputManager.cpp>
|
||||
|
||||
201
src/activities/apps/AppsActivity.cpp
Normal file
201
src/activities/apps/AppsActivity.cpp
Normal file
@ -0,0 +1,201 @@
|
||||
#include "AppsActivity.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <EInkDisplay.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <InputManager.h>
|
||||
#include <MappedInputManager.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <fontIds.h>
|
||||
|
||||
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<int>(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<int>(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<int>((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<int>(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<int>(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<int>((written * 100) / total);
|
||||
needsUpdate_ = true;
|
||||
}
|
||||
38
src/activities/apps/AppsActivity.h
Normal file
38
src/activities/apps/AppsActivity.h
Normal file
@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <MappedInputManager.h>
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
#include "../../extension/AppLoader.h"
|
||||
#include "../Activity.h"
|
||||
|
||||
class AppsActivity : public Activity {
|
||||
public:
|
||||
using ExitCallback = std::function<void()>;
|
||||
|
||||
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<CrossPoint::AppInfo> 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);
|
||||
};
|
||||
@ -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<const char*> menuItems = {"My Library", "File Transfer", "Settings"};
|
||||
// Keep this list in sync with getMenuItemCount() and loop() index mapping.
|
||||
std::vector<const char*> menuItems = {"My Library", "File Transfer", "Apps", "Settings"};
|
||||
if (hasOpdsUrl) {
|
||||
// Insert OPDS Browser after My Library
|
||||
menuItems.insert(menuItems.begin() + 1, "OPDS Browser");
|
||||
|
||||
@ -26,6 +26,7 @@ class HomeActivity final : public Activity {
|
||||
const std::function<void()> onSettingsOpen;
|
||||
const std::function<void()> onFileTransferOpen;
|
||||
const std::function<void()> onOpdsBrowserOpen;
|
||||
const std::function<void()> 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<void()>& onContinueReading, const std::function<void()>& onMyLibraryOpen,
|
||||
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen,
|
||||
const std::function<void()>& onOpdsBrowserOpen)
|
||||
const std::function<void()>& onOpdsBrowserOpen, const std::function<void()>& 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;
|
||||
|
||||
65
src/apps/hello-world/HelloWorldActivity.cpp
Normal file
65
src/apps/hello-world/HelloWorldActivity.cpp
Normal file
@ -0,0 +1,65 @@
|
||||
#include "HelloWorldActivity.h"
|
||||
|
||||
#include <EpdFont.h>
|
||||
#include <EpdFontFamily.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <builtinFonts/all.h>
|
||||
#include <esp_attr.h>
|
||||
#include <esp_ota_ops.h>
|
||||
#include <esp_system.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
21
src/apps/hello-world/HelloWorldActivity.h
Normal file
21
src/apps/hello-world/HelloWorldActivity.h
Normal file
@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalDisplay.h>
|
||||
#include <HalGPIO.h>
|
||||
|
||||
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();
|
||||
};
|
||||
31
src/apps/hello-world/main.cpp
Normal file
31
src/apps/hello-world/main.cpp
Normal file
@ -0,0 +1,31 @@
|
||||
#include <Arduino.h>
|
||||
#include <HalDisplay.h>
|
||||
#include <HalGPIO.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
312
src/extension/AppLoader.cpp
Normal file
312
src/extension/AppLoader.cpp
Normal file
@ -0,0 +1,312 @@
|
||||
#include "AppLoader.h"
|
||||
|
||||
#include <esp_ota_ops.h>
|
||||
#include <esp_partition.h>
|
||||
#include <esp_system.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "Battery.h"
|
||||
|
||||
namespace CrossPoint {
|
||||
|
||||
std::vector<AppInfo> AppLoader::scanApps() {
|
||||
std::vector<AppInfo> 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<char[]> 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<uint8_t>(json[0]) == 0xEF && static_cast<uint8_t>(json[1]) == 0xBB &&
|
||||
static_cast<uint8_t>(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<const char*>()) {
|
||||
manifest.name = doc["name"].as<String>();
|
||||
} else {
|
||||
Serial.printf("[%lu] [AppLoader] Missing or invalid 'name' field in: %s\n", millis(), path.c_str());
|
||||
return manifest;
|
||||
}
|
||||
|
||||
if (doc["version"].is<const char*>()) {
|
||||
manifest.version = doc["version"].as<String>();
|
||||
} else {
|
||||
manifest.version = "1.0.0";
|
||||
}
|
||||
|
||||
if (doc["description"].is<const char*>()) {
|
||||
manifest.description = doc["description"].as<String>();
|
||||
} else {
|
||||
manifest.description = "";
|
||||
}
|
||||
|
||||
if (doc["author"].is<const char*>()) {
|
||||
manifest.author = doc["author"].as<String>();
|
||||
} else {
|
||||
manifest.author = "Unknown";
|
||||
}
|
||||
|
||||
if (doc["minFirmware"].is<const char*>()) {
|
||||
manifest.minFirmware = doc["minFirmware"].as<String>();
|
||||
} 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
|
||||
125
src/extension/AppLoader.h
Normal file
125
src/extension/AppLoader.h
Normal file
@ -0,0 +1,125 @@
|
||||
#pragma once
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <WString.h>
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
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<AppInfo> apps = loader.scanApps();
|
||||
* for (const auto& app : apps) {
|
||||
* Serial.println(app.manifest.name);
|
||||
* }
|
||||
*/
|
||||
class AppLoader {
|
||||
public:
|
||||
AppLoader() = default;
|
||||
~AppLoader() = default;
|
||||
|
||||
using ProgressCallback = std::function<void(size_t written, size_t total)>;
|
||||
|
||||
/**
|
||||
* @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<AppInfo> 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
|
||||
@ -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() {
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#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<size_t>(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<unsigned>(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")) {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
355
src/network/html/AppsPage.html
Normal file
355
src/network/html/AppsPage.html
Normal file
@ -0,0 +1,355 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CrossPoint Reader - Apps (Developer)</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #7f8c8d;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 15px 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.nav-links {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.nav-links a {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.nav-links a:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
.warning {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
color: #856404;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.field {
|
||||
margin: 12px 0;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #34495e;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="file"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1em;
|
||||
}
|
||||
.hint {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background-color: #219a52;
|
||||
}
|
||||
.btn:disabled {
|
||||
background-color: #95a5a6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#progress-container {
|
||||
display: none;
|
||||
margin-top: 12px;
|
||||
}
|
||||
#progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#progress-fill {
|
||||
height: 100%;
|
||||
background-color: #27ae60;
|
||||
width: 0%;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
#progress-text {
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
font-size: 0.9em;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
.message {
|
||||
display: none;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 12px;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
.message.success {
|
||||
display: block;
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.message.error {
|
||||
display: block;
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.card {
|
||||
padding: 12px;
|
||||
}
|
||||
.nav-links a {
|
||||
padding: 8px 12px;
|
||||
margin-right: 6px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/files">File Manager</a>
|
||||
<a href="/apps">Apps (Developer)</a>
|
||||
</div>
|
||||
|
||||
<h1>Apps (Developer)</h1>
|
||||
<p class="subtitle">Upload an app binary to the device SD card for installation via the on-device Apps menu.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="warning">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="appId">App ID</label>
|
||||
<input id="appId" type="text" placeholder="e.g. chess-puzzles or org.example.myapp" />
|
||||
<div class="hint">Allowed: letters, numbers, dot, underscore, dash. No slashes. Max 64 chars.</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" type="text" placeholder="Display name" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="version">Version</label>
|
||||
<input id="version" type="text" placeholder="e.g. 0.1.0" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="author">Author (optional)</label>
|
||||
<input id="author" type="text" placeholder="Your name" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="description">Description (optional)</label>
|
||||
<input id="description" type="text" placeholder="Short description" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="minFirmware">Min Firmware (optional)</label>
|
||||
<input id="minFirmware" type="text" placeholder="e.g. 0.14.0" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="file">App Binary (app.bin)</label>
|
||||
<input id="file" type="file" />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="uploadBtn" class="btn primary" onclick="uploadApp()" disabled>Upload App</button>
|
||||
</div>
|
||||
|
||||
<div id="progress-container">
|
||||
<div id="progress-bar"><div id="progress-fill"></div></div>
|
||||
<div id="progress-text"></div>
|
||||
</div>
|
||||
|
||||
<div id="message" class="message"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p style="text-align: center; color: #95a5a6; margin: 0">
|
||||
CrossPoint E-Reader • Open Source
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const appIdInput = document.getElementById('appId');
|
||||
const nameInput = document.getElementById('name');
|
||||
const versionInput = document.getElementById('version');
|
||||
const authorInput = document.getElementById('author');
|
||||
const descriptionInput = document.getElementById('description');
|
||||
const minFirmwareInput = document.getElementById('minFirmware');
|
||||
const fileInput = document.getElementById('file');
|
||||
const uploadBtn = document.getElementById('uploadBtn');
|
||||
|
||||
function setMessage(kind, text) {
|
||||
const el = document.getElementById('message');
|
||||
el.className = 'message ' + kind;
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
function clearMessage() {
|
||||
const el = document.getElementById('message');
|
||||
el.className = 'message';
|
||||
el.textContent = '';
|
||||
}
|
||||
|
||||
function isValidAppId(appId) {
|
||||
if (!appId) return false;
|
||||
if (appId.length > 64) return false;
|
||||
if (appId.startsWith('.')) return false;
|
||||
if (appId.indexOf('..') >= 0) return false;
|
||||
if (appId.indexOf('/') >= 0) return false;
|
||||
if (appId.indexOf('\\') >= 0) return false;
|
||||
return /^[A-Za-z0-9._-]+$/.test(appId);
|
||||
}
|
||||
|
||||
function updateButtonState() {
|
||||
const appId = appIdInput.value.trim();
|
||||
const name = nameInput.value.trim();
|
||||
const version = versionInput.value.trim();
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
uploadBtn.disabled = !(isValidAppId(appId) && name && version && file);
|
||||
}
|
||||
|
||||
[appIdInput, nameInput, versionInput, authorInput, descriptionInput, minFirmwareInput, fileInput].forEach((el) => {
|
||||
el.addEventListener('input', updateButtonState);
|
||||
el.addEventListener('change', updateButtonState);
|
||||
});
|
||||
|
||||
function uploadApp() {
|
||||
clearMessage();
|
||||
|
||||
const appId = appIdInput.value.trim();
|
||||
const name = nameInput.value.trim();
|
||||
const version = versionInput.value.trim();
|
||||
const author = authorInput.value.trim();
|
||||
const description = descriptionInput.value.trim();
|
||||
const minFirmware = minFirmwareInput.value.trim();
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
|
||||
if (!isValidAppId(appId)) {
|
||||
setMessage('error', 'Invalid App ID. Use letters/numbers/dot/underscore/dash only.');
|
||||
return;
|
||||
}
|
||||
if (!name || !version || !file) {
|
||||
setMessage('error', 'App ID, Name, Version, and app.bin are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('appId', appId);
|
||||
qs.set('name', name);
|
||||
qs.set('version', version);
|
||||
if (author) qs.set('author', author);
|
||||
if (description) qs.set('description', description);
|
||||
if (minFirmware) qs.set('minFirmware', minFirmware);
|
||||
|
||||
const url = '/upload-app?' + qs.toString();
|
||||
const form = new FormData();
|
||||
form.append('file', file, 'app.bin');
|
||||
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
const progressFill = document.getElementById('progress-fill');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
progressContainer.style.display = 'block';
|
||||
progressFill.style.width = '0%';
|
||||
progressText.textContent = 'Starting upload...';
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, true);
|
||||
|
||||
xhr.upload.onprogress = function (evt) {
|
||||
if (evt.lengthComputable) {
|
||||
const pct = Math.round((evt.loaded / evt.total) * 100);
|
||||
progressFill.style.width = pct + '%';
|
||||
progressText.textContent = 'Uploading: ' + pct + '% (' + Math.round(evt.loaded / 1024) + ' KB)';
|
||||
} else {
|
||||
progressText.textContent = 'Uploading...';
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function () {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
progressFill.style.width = '100%';
|
||||
progressText.textContent = 'Upload complete.';
|
||||
setMessage('success', xhr.responseText || 'Upload succeeded.');
|
||||
} else {
|
||||
setMessage('error', xhr.responseText || 'Upload failed (HTTP ' + xhr.status + ').');
|
||||
}
|
||||
updateButtonState();
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
setMessage('error', 'Upload failed due to a network error.');
|
||||
updateButtonState();
|
||||
};
|
||||
|
||||
xhr.send(form);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -575,6 +575,7 @@
|
||||
<div class="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/files">File Manager</a>
|
||||
<a href="/apps">Apps (Developer)</a>
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
|
||||
@ -77,6 +77,7 @@
|
||||
<div class="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/files">File Manager</a>
|
||||
<a href="/apps">Apps (Developer)</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user