feat(extensions): add web app sideloading

This commit is contained in:
Daniel 2026-02-03 00:02:24 -08:00
parent 4ffbc2a641
commit 3aa7691210
5 changed files with 662 additions and 0 deletions

View File

@ -11,6 +11,7 @@
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "html/AppsPageHtml.generated.h"
#include "util/StringUtils.h"
namespace {
@ -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")) {

View File

@ -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;
};

View 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>

View File

@ -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">

View File

@ -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">