Merge origin/master into fix-button-handler and resolve sd-fonts manifest conflict

This commit is contained in:
jpirnay
2026-05-02 23:53:36 +02:00
5 changed files with 483 additions and 28 deletions
@@ -8,7 +8,7 @@
#include "util/ButtonNavigator.h"
#ifndef FONT_MANIFEST_URL
#define FONT_MANIFEST_URL "https://github.com/jpirnay/crosspoint-reader/assets/sd-fonts/fonts.json"
#define FONT_MANIFEST_URL "https://github.com/jpirnay/crosspoint-reader/blob/master/assets/sd-fonts/sd-fonts.yaml"
#endif
class FontDownloadActivity : public Activity {
+333
View File
@@ -26,6 +26,7 @@
#include "html/SettingsPageHtml.generated.h"
#include "html/WelcomePageHtml.generated.h"
#include "html/js/jszip_minJs.generated.h"
#include "network/HttpDownloader.h"
namespace {
// Folders/files to hide from the web interface file browser
@@ -35,6 +36,10 @@ constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS
constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678};
constexpr uint16_t LOCAL_UDP_PORT = 8134;
#ifndef FONT_MANIFEST_URL
#define FONT_MANIFEST_URL "https://github.com/jpirnay/crosspoint-reader/blob/master/assets/sd-fonts/sd-fonts.yaml"
#endif
// Static pointer for WebSocket callback (WebSocketsServer requires C-style callback)
CrossPointWebServer* wsInstance = nullptr;
@@ -203,6 +208,8 @@ void CrossPointWebServer::begin() {
// Font management endpoints
server->on("/fonts", HTTP_GET, [this] { handleFontsPage(); });
server->on("/api/fonts", HTTP_GET, [this] { handleFontList(); });
server->on("/api/fonts/manifest", HTTP_GET, [this] { handleFontManifest(); });
server->on("/api/fonts/download", HTTP_POST, [this] { handleFontDownload(); });
server->on("/api/fonts/upload", HTTP_POST, [this] { handleFontUpload(); }, [this] { handleFontUploadData(); });
server->on("/api/fonts/delete", HTTP_POST, [this] { handleFontDelete(); });
@@ -1411,6 +1418,220 @@ void CrossPointWebServer::handlePostSettings() {
// ---- Font Management API ----
namespace {
struct RemoteManifestFile {
std::string name;
size_t size = 0;
};
struct RemoteManifestFamily {
std::string name;
std::string description;
std::vector<RemoteManifestFile> files;
size_t totalSize = 0;
bool installed = false;
bool hasUpdate = false;
};
// Cap chosen so that FONTS_DIR + "/" + family + "__staging/" + filename stays
// well under the 128-byte path buffers used below. snprintf truncation checks
// are the actual guard; this just rejects obviously-bogus entries early.
static constexpr size_t MAX_FONT_FILE_NAME_LEN = 60;
bool isValidFontFileName(const std::string& name) {
if (name.empty() || name.size() > MAX_FONT_FILE_NAME_LEN) return false;
if (name.find('/') != std::string::npos) return false;
if (name.find('\\') != std::string::npos) return false;
if (name.find("..") != std::string::npos) return false;
return true;
}
bool fetchRemoteFontManifest(FontInstaller& installer, std::vector<RemoteManifestFamily>& outFamilies,
std::string& outBaseUrl, std::string& outError) {
static constexpr const char* MANIFEST_TMP = "/fonts_manifest_web.tmp";
auto result = HttpDownloader::downloadToFile(FONT_MANIFEST_URL, MANIFEST_TMP, nullptr);
if (result != HttpDownloader::OK) {
outError = "Failed to fetch font manifest";
Storage.remove(MANIFEST_TMP);
return false;
}
FsFile manifestFile;
if (!Storage.openFileForRead("WEB", MANIFEST_TMP, manifestFile)) {
outError = "Failed to open downloaded manifest";
Storage.remove(MANIFEST_TMP);
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, manifestFile);
manifestFile.close();
Storage.remove(MANIFEST_TMP);
if (err) {
outError = "Failed to parse font manifest";
return false;
}
const int version = doc["version"] | 0;
if (version != 1) {
outError = "Unsupported manifest version";
return false;
}
outBaseUrl = doc["baseUrl"] | "";
outFamilies.clear();
JsonArray familiesArr = doc["families"].as<JsonArray>();
outFamilies.reserve(familiesArr.size());
for (JsonObject fObj : familiesArr) {
RemoteManifestFamily family;
family.name = fObj["name"] | "";
family.description = fObj["description"] | "";
if (!FontInstaller::isValidFamilyName(family.name.c_str())) {
LOG_ERR("WEB", "Manifest entry rejected, invalid family name: %s", family.name.c_str());
continue;
}
bool fileNamesOk = true;
for (JsonObject fileObj : fObj["files"].as<JsonArray>()) {
RemoteManifestFile file;
file.name = fileObj["name"] | "";
file.size = static_cast<size_t>(fileObj["size"] | 0);
if (!isValidFontFileName(file.name)) {
LOG_ERR("WEB", "Manifest entry rejected, invalid file name in %s: %s", family.name.c_str(), file.name.c_str());
fileNamesOk = false;
break;
}
family.totalSize += file.size;
family.files.push_back(std::move(file));
}
if (!fileNamesOk) continue;
family.installed = installer.isFamilyInstalled(family.name.c_str());
family.hasUpdate = false;
if (family.installed) {
for (const auto& file : family.files) {
char path[128];
FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), path, sizeof(path));
FsFile f;
if (Storage.openFileForRead("WEB", path, f)) {
const size_t actual = static_cast<size_t>(f.size());
f.close();
if (actual != file.size) {
family.hasUpdate = true;
break;
}
} else {
family.hasUpdate = true;
break;
}
}
}
outFamilies.push_back(std::move(family));
}
return true;
}
bool installRemoteFamily(const RemoteManifestFamily& family, const std::string& baseUrl, FontInstaller& installer,
std::string& outError) {
if (!FontInstaller::isValidFamilyName(family.name.c_str())) {
outError = "Invalid family name";
return false;
}
for (const auto& file : family.files) {
if (!isValidFontFileName(file.name)) {
outError = std::string("Invalid file name: ") + file.name;
return false;
}
}
char liveDir[128];
char stagingDir[128];
char backupDir[128];
int n;
n = snprintf(liveDir, sizeof(liveDir), "%s/%s", SdCardFontRegistry::FONTS_DIR, family.name.c_str());
if (n < 0 || static_cast<size_t>(n) >= sizeof(liveDir)) {
outError = "Family path too long";
return false;
}
n = snprintf(stagingDir, sizeof(stagingDir), "%s/%s__staging", SdCardFontRegistry::FONTS_DIR, family.name.c_str());
if (n < 0 || static_cast<size_t>(n) >= sizeof(stagingDir)) {
outError = "Family path too long";
return false;
}
n = snprintf(backupDir, sizeof(backupDir), "%s/%s__backup", SdCardFontRegistry::FONTS_DIR, family.name.c_str());
if (n < 0 || static_cast<size_t>(n) >= sizeof(backupDir)) {
outError = "Family path too long";
return false;
}
if (Storage.exists(stagingDir) && !Storage.removeDir(stagingDir)) {
outError = "Failed to prepare staging area";
return false;
}
if (!Storage.mkdir(stagingDir)) {
outError = "Failed to create staging area";
return false;
}
for (const auto& file : family.files) {
esp_task_wdt_reset();
char stagedPath[128];
int sn = snprintf(stagedPath, sizeof(stagedPath), "%s/%s", stagingDir, file.name.c_str());
if (sn < 0 || static_cast<size_t>(sn) >= sizeof(stagedPath)) {
Storage.removeDir(stagingDir);
outError = std::string("File path too long: ") + file.name;
return false;
}
const std::string url = baseUrl + file.name;
auto result = HttpDownloader::downloadToFile(url, stagedPath, nullptr);
if (result != HttpDownloader::OK) {
Storage.removeDir(stagingDir);
outError = std::string("Download failed: ") + file.name;
return false;
}
if (!installer.validateCpfontFile(stagedPath)) {
Storage.removeDir(stagingDir);
outError = std::string("Invalid font file: ") + file.name;
return false;
}
}
const bool hadLiveDir = Storage.exists(liveDir);
if (Storage.exists(backupDir) && !Storage.removeDir(backupDir)) {
Storage.removeDir(stagingDir);
outError = "Failed to prepare backup area";
return false;
}
if (hadLiveDir && !Storage.rename(liveDir, backupDir)) {
Storage.removeDir(stagingDir);
outError = "Failed to replace installed font";
return false;
}
if (!Storage.rename(stagingDir, liveDir)) {
if (hadLiveDir && Storage.exists(backupDir)) {
Storage.rename(backupDir, liveDir);
}
Storage.removeDir(stagingDir);
outError = "Failed to finalize font install";
return false;
}
if (Storage.exists(backupDir)) {
Storage.removeDir(backupDir);
}
return true;
}
} // namespace
void CrossPointWebServer::handleFontsPage() const {
sendHtmlContent(server.get(), FontsPageHtml, sizeof(FontsPageHtml));
LOG_DBG("WEB", "Served fonts page");
@@ -1455,6 +1676,118 @@ void CrossPointWebServer::handleFontList() {
server->send(200, "application/json", json);
}
void CrossPointWebServer::handleFontManifest() {
FontInstaller installer(sdFontSystem.registry());
installer.refreshRegistry();
std::vector<RemoteManifestFamily> families;
std::string baseUrl;
std::string error;
if (!fetchRemoteFontManifest(installer, families, baseUrl, error)) {
JsonDocument errDoc;
errDoc["ok"] = false;
errDoc["error"] = error;
String out;
serializeJson(errDoc, out);
server->send(500, "application/json", out);
return;
}
JsonDocument doc;
doc["ok"] = true;
doc["baseUrl"] = baseUrl;
JsonArray arr = doc["families"].to<JsonArray>();
for (const auto& family : families) {
JsonObject obj = arr.add<JsonObject>();
obj["name"] = family.name;
obj["description"] = family.description;
obj["installed"] = family.installed;
obj["hasUpdate"] = family.hasUpdate;
obj["totalSize"] = static_cast<unsigned long>(family.totalSize);
obj["fileCount"] = static_cast<unsigned>(family.files.size());
}
String out;
serializeJson(doc, out);
server->send(200, "application/json", out);
}
void CrossPointWebServer::handleFontDownload() {
String body = server->arg("plain");
JsonDocument req;
if (deserializeJson(req, body)) {
server->send(400, "application/json", "{\"ok\":false,\"error\":\"Invalid request\"}");
return;
}
const bool installAll = req["all"] | false;
const std::string requestedFamily = req["family"] | "";
if (!installAll && requestedFamily.empty()) {
server->send(400, "application/json", "{\"ok\":false,\"error\":\"Missing family\"}");
return;
}
FontInstaller installer(sdFontSystem.registry());
installer.refreshRegistry();
std::vector<RemoteManifestFamily> families;
std::string baseUrl;
std::string error;
if (!fetchRemoteFontManifest(installer, families, baseUrl, error)) {
JsonDocument errDoc;
errDoc["ok"] = false;
errDoc["error"] = error;
String out;
serializeJson(errDoc, out);
server->send(500, "application/json", out);
return;
}
std::vector<RemoteManifestFamily*> targets;
if (installAll) {
for (auto& family : families) {
if (!family.installed || family.hasUpdate) {
targets.push_back(&family);
}
}
} else {
for (auto& family : families) {
if (family.name == requestedFamily) {
targets.push_back(&family);
break;
}
}
if (targets.empty()) {
server->send(404, "application/json", "{\"ok\":false,\"error\":\"Family not found in manifest\"}");
return;
}
}
size_t installedCount = 0;
for (auto* family : targets) {
esp_task_wdt_reset();
if (!installRemoteFamily(*family, baseUrl, installer, error)) {
JsonDocument errDoc;
errDoc["ok"] = false;
errDoc["error"] = error;
errDoc["family"] = family->name;
errDoc["installedCount"] = static_cast<unsigned>(installedCount);
String out;
serializeJson(errDoc, out);
server->send(500, "application/json", out);
return;
}
installedCount++;
}
installer.refreshRegistry();
JsonDocument res;
res["ok"] = true;
res["installedCount"] = static_cast<unsigned>(installedCount);
String out;
serializeJson(res, out);
server->send(200, "application/json", out);
}
void CrossPointWebServer::handleFontUploadData() {
HTTPUpload& up = server->upload();
+2
View File
@@ -113,6 +113,8 @@ class CrossPointWebServer {
// Font management handlers
void handleFontsPage() const;
void handleFontList();
void handleFontManifest();
void handleFontDownload();
void handleFontUpload();
void handleFontUploadData();
void handleFontDelete();
+145 -25
View File
@@ -104,6 +104,10 @@
color: white;
}
.btn-primary:hover { background: var(--accent-hover-color); }
.btn[disabled] {
opacity: 0.5;
cursor: default;
}
.upload-form {
display: flex;
gap: 10px;
@@ -137,6 +141,13 @@
font-size: 0.95em;
}
.notice a { color: inherit; font-weight: 600; }
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 12px;
}
</style>
</head>
<body>
@@ -154,6 +165,16 @@
<div id="families"><p class="empty">Loading...</p></div>
</div>
<div class="card">
<h2>Download Fonts</h2>
<div class="toolbar">
<button id="refreshManifest" class="btn btn-primary" type="button">Refresh Catalog</button>
<button id="downloadAllRemote" class="btn btn-primary" type="button">Download All Updates</button>
</div>
<div id="remoteFamilies"><p class="empty">Catalog not loaded</p></div>
<div id="remoteStatus"></div>
</div>
<div class="card">
<h2>Upload Font</h2>
<p class="notice">
@@ -176,6 +197,13 @@
return bytes + ' B';
}
function setStatus(id, text, ok) {
const el = document.getElementById(id);
el.className = ok === undefined ? '' : (ok ? 'status-ok' : 'status-err');
el.style.display = 'block';
el.textContent = text;
}
async function loadFonts() {
try {
const res = await fetch('/api/fonts');
@@ -230,10 +258,7 @@
async function deleteFamily(name) {
if (!confirm('Delete font family "' + name + '"?')) return;
const status = document.getElementById('status');
status.className = '';
status.style.display = 'block';
status.textContent = 'Deleting ' + name + '...';
setStatus('status', 'Deleting ' + name + '...');
try {
const res = await fetch('/api/fonts/delete', {
method: 'POST',
@@ -241,17 +266,115 @@
body: JSON.stringify({family: name})
});
if (res.ok) {
status.className = 'status-ok';
status.textContent = 'Deleted "' + name + '".';
setStatus('status', 'Deleted "' + name + '".', true);
} else {
status.className = 'status-err';
status.textContent = 'Failed to delete "' + name + '".';
setStatus('status', 'Failed to delete "' + name + '".', false);
}
} catch (err) {
status.className = 'status-err';
status.textContent = 'Delete error: ' + err.message;
setStatus('status', 'Delete error: ' + err.message, false);
}
await loadFonts();
await loadRemoteManifest();
}
async function loadRemoteManifest() {
const el = document.getElementById('remoteFamilies');
el.innerHTML = '<p class="empty">Loading catalog...</p>';
try {
const res = await fetch('/api/fonts/manifest');
const data = await res.json();
if (!res.ok || !data.ok) {
throw new Error(data.error || 'catalog unavailable');
}
el.innerHTML = '';
if (!Array.isArray(data.families) || data.families.length === 0) {
el.innerHTML = '<p class="empty">No remote fonts found</p>';
return;
}
data.families.forEach(f => {
const row = document.createElement('div');
row.className = 'family';
const info = document.createElement('div');
info.className = 'family-info';
const title = document.createElement('h3');
title.textContent = f.name;
const meta = document.createElement('span');
meta.className = 'family-meta';
const state = f.installed ? (f.hasUpdate ? 'Installed, update available' : 'Installed, up to date') : 'Not installed';
const details = state + ' - ' + (f.fileCount || 0) + ' file(s), ' + formatSize(f.totalSize || 0);
meta.textContent = f.description ? (f.description + ' - ' + details) : details;
info.appendChild(title);
info.appendChild(meta);
const btn = document.createElement('button');
btn.className = 'btn btn-primary';
btn.dataset.family = f.name;
btn.textContent = f.installed ? (f.hasUpdate ? 'Update' : 'Reinstall') : 'Download';
row.appendChild(info);
row.appendChild(btn);
el.appendChild(row);
});
el.querySelectorAll('button[data-family]').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
await downloadRemoteFamily(btn.dataset.family);
btn.disabled = false;
});
});
} catch (err) {
el.innerHTML = '<p class="empty">Failed to load catalog: ' + err.message + '</p>';
}
}
async function downloadRemoteFamily(family) {
setStatus('remoteStatus', 'Downloading "' + family + '"...');
try {
const res = await fetch('/api/fonts/download', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({family})
});
const data = await res.json();
if (!res.ok || !data.ok) {
throw new Error(data.error || 'download failed');
}
setStatus('remoteStatus', 'Installed "' + family + '".', true);
await loadFonts();
await loadRemoteManifest();
} catch (err) {
setStatus('remoteStatus', 'Install failed for "' + family + '": ' + err.message, false);
}
}
async function downloadAllRemoteUpdates() {
setStatus('remoteStatus', 'Downloading all available updates...');
const btn = document.getElementById('downloadAllRemote');
btn.disabled = true;
try {
const res = await fetch('/api/fonts/download', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({all: true})
});
const data = await res.json();
if (!res.ok || !data.ok) {
throw new Error(data.error || 'download failed');
}
setStatus('remoteStatus', 'Installed/updated ' + (data.installedCount || 0) + ' family(s).', true);
await loadFonts();
await loadRemoteManifest();
} catch (err) {
setStatus('remoteStatus', 'Download-all failed: ' + err.message, false);
}
btn.disabled = false;
}
// Derive family name from a .cpfont filename: take everything before the
@@ -293,25 +416,20 @@
const status = document.getElementById('status');
const files = cpfontFilesOnly(document.getElementById('fontFiles').files);
if (files.length === 0) {
status.className = 'status-err';
status.style.display = 'block';
status.textContent = 'No .cpfont files selected.';
setStatus('status', 'No .cpfont files selected.', false);
return;
}
const families = files.map(f => sanitizeFamily(familyFromFilename(f.name)));
const uniqueFamilies = new Set(families);
if (uniqueFamilies.size !== 1) {
status.className = 'status-err';
status.style.display = 'block';
status.textContent = 'Selected files belong to multiple families.';
setStatus('status', 'Selected files belong to multiple families.', false);
return;
}
const family = families[0];
status.className = '';
status.style.display = 'block';
setStatus('status', 'Preparing upload...');
let uploaded = 0;
for (const file of files) {
@@ -323,27 +441,29 @@
const res = await fetch('/api/fonts/upload', { method: 'POST', body: formData });
const data = await res.json();
if (!data.ok) {
status.className = 'status-err';
status.textContent = 'Failed on ' + file.name + ': ' + (data.error || 'unknown error');
setStatus('status', 'Failed on ' + file.name + ': ' + (data.error || 'unknown error'), false);
await loadFonts();
return;
}
} catch (err) {
status.className = 'status-err';
status.textContent = 'Upload error on ' + file.name + ': ' + err.message;
setStatus('status', 'Upload error on ' + file.name + ': ' + err.message, false);
await loadFonts();
return;
}
uploaded++;
}
status.className = 'status-ok';
status.textContent = 'Uploaded ' + uploaded + ' file' + (uploaded === 1 ? '' : 's') +
' to family "' + family + '".';
setStatus('status', 'Uploaded ' + uploaded + ' file' + (uploaded === 1 ? '' : 's') +
' to family "' + family + '".', true);
await loadFonts();
await loadRemoteManifest();
});
document.getElementById('refreshManifest').addEventListener('click', loadRemoteManifest);
document.getElementById('downloadAllRemote').addEventListener('click', downloadAllRemoteUpdates);
loadFonts();
loadRemoteManifest();
</script>
</body>
</html>