mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 06:37:38 +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/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")) {
|
||||
|
||||
@ -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