mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 22:57:50 +03:00
feat(extensions): add web app sideloading
This commit is contained in:
parent
4ffbc2a641
commit
3aa7691210
@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
#include "html/FilesPageHtml.generated.h"
|
#include "html/FilesPageHtml.generated.h"
|
||||||
#include "html/HomePageHtml.generated.h"
|
#include "html/HomePageHtml.generated.h"
|
||||||
|
#include "html/AppsPageHtml.generated.h"
|
||||||
#include "util/StringUtils.h"
|
#include "util/StringUtils.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@ -98,6 +99,7 @@ void CrossPointWebServer::begin() {
|
|||||||
Serial.printf("[%lu] [WEB] Setting up routes...\n", millis());
|
Serial.printf("[%lu] [WEB] Setting up routes...\n", millis());
|
||||||
server->on("/", HTTP_GET, [this] { handleRoot(); });
|
server->on("/", HTTP_GET, [this] { handleRoot(); });
|
||||||
server->on("/files", HTTP_GET, [this] { handleFileList(); });
|
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/status", HTTP_GET, [this] { handleStatus(); });
|
||||||
server->on("/api/files", HTTP_GET, [this] { handleFileListData(); });
|
server->on("/api/files", HTTP_GET, [this] { handleFileListData(); });
|
||||||
@ -106,6 +108,9 @@ void CrossPointWebServer::begin() {
|
|||||||
// Upload endpoint with special handling for multipart form data
|
// Upload endpoint with special handling for multipart form data
|
||||||
server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); });
|
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
|
// Create folder endpoint
|
||||||
server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); });
|
server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); });
|
||||||
|
|
||||||
@ -256,6 +261,11 @@ void CrossPointWebServer::handleRoot() const {
|
|||||||
Serial.printf("[%lu] [WEB] Served root page\n", millis());
|
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 {
|
void CrossPointWebServer::handleNotFound() const {
|
||||||
String message = "404 Not Found\n\n";
|
String message = "404 Not Found\n\n";
|
||||||
message += "URI: " + server->uri() + "\n";
|
message += "URI: " + server->uri() + "\n";
|
||||||
@ -466,6 +476,118 @@ static size_t uploadSize = 0;
|
|||||||
static bool uploadSuccess = false;
|
static bool uploadSuccess = false;
|
||||||
static String uploadError = "";
|
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
|
// Upload write buffer - batches small writes into larger SD card operations
|
||||||
// 4KB is a good balance: large enough to reduce syscall overhead, small enough
|
// 4KB is a good balance: large enough to reduce syscall overhead, small enough
|
||||||
// to keep individual write times short and avoid watchdog issues
|
// 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 {
|
void CrossPointWebServer::handleCreateFolder() const {
|
||||||
// Get folder name from form data
|
// Get folder name from form data
|
||||||
if (!server->hasArg("name")) {
|
if (!server->hasArg("name")) {
|
||||||
|
|||||||
@ -69,6 +69,7 @@ class CrossPointWebServer {
|
|||||||
|
|
||||||
// Request handlers
|
// Request handlers
|
||||||
void handleRoot() const;
|
void handleRoot() const;
|
||||||
|
void handleAppsPage() const;
|
||||||
void handleNotFound() const;
|
void handleNotFound() const;
|
||||||
void handleStatus() const;
|
void handleStatus() const;
|
||||||
void handleFileList() const;
|
void handleFileList() const;
|
||||||
@ -76,6 +77,8 @@ class CrossPointWebServer {
|
|||||||
void handleDownload() const;
|
void handleDownload() const;
|
||||||
void handleUpload() const;
|
void handleUpload() const;
|
||||||
void handleUploadPost() const;
|
void handleUploadPost() const;
|
||||||
|
void handleUploadApp() const;
|
||||||
|
void handleUploadAppPost() const;
|
||||||
void handleCreateFolder() const;
|
void handleCreateFolder() const;
|
||||||
void handleDelete() 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">
|
<div class="nav-links">
|
||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
<a href="/files">File Manager</a>
|
<a href="/files">File Manager</a>
|
||||||
|
<a href="/apps">Apps (Developer)</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
|
|||||||
@ -77,6 +77,7 @@
|
|||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
<a href="/files">File Manager</a>
|
<a href="/files">File Manager</a>
|
||||||
|
<a href="/apps">Apps (Developer)</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user