From 67f9a266da988aae35a5579f2751271e65570edb Mon Sep 17 00:00:00 2001
From: Logan B <3870583+thinkpoop@users.noreply.github.com>
Date: Sun, 16 Nov 2025 20:34:59 -0600
Subject: [PATCH 1/7] apploader - add install app from files option
---
index.html | 3 +
install_from_files.js | 212 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 215 insertions(+)
create mode 100644 install_from_files.js
diff --git a/index.html b/index.html
index 5e646088fa..f29d104452 100644
--- a/index.html
+++ b/index.html
@@ -146,6 +146,8 @@
Utilities
+
+
@@ -232,6 +234,7 @@ Device info
+
diff --git a/install_from_files.js b/install_from_files.js
new file mode 100644
index 0000000000..00cae4a6a0
--- /dev/null
+++ b/install_from_files.js
@@ -0,0 +1,212 @@
+/**
+ * Apploader - Install App from selected files
+ */
+function installFromFiles() {
+ return new Promise(resolve => {
+ // Ask user to select all files at once (multi-select)
+ Espruino.Core.Utils.fileOpenDialog({
+ id:"installappfiles",
+ type:"arraybuffer",
+ multi:true,
+ mimeType:"*/*"}, function(fileData, mimeType, fileName) {
+
+ // Collect all files (callback is invoked once per file when multi:true)
+ if (!installFromFiles.fileCollection) {
+ installFromFiles.fileCollection = {
+ files: [],
+ count: 0
+ };
+ }
+
+ // Store this file
+ installFromFiles.fileCollection.files.push({
+ name: fileName,
+ data: fileData,
+ mimeType: mimeType
+ });
+ installFromFiles.fileCollection.count++;
+
+ // Use setTimeout to batch-process after all callbacks complete
+ clearTimeout(installFromFiles.processTimeout);
+ installFromFiles.processTimeout = setTimeout(function() {
+ var files = installFromFiles.fileCollection.files;
+ installFromFiles.fileCollection = null; // reset for next use
+
+ if (!files || files.length === 0) return resolve();
+
+ // Find metadata.json
+ var metadataFile = files.find(f => f.name === 'metadata.json' || f.name.endsWith('/metadata.json'));
+
+ if (!metadataFile) {
+ showToast('No metadata.json found in selected files', 'error');
+ return resolve();
+ }
+
+ // Parse metadata.json
+ var metadata;
+ try {
+ var metadataText = new TextDecoder().decode(new Uint8Array(metadataFile.data));
+ metadata = JSON.parse(metadataText);
+ } catch(err) {
+ showToast('Failed to parse metadata.json: ' + err, 'error');
+ return resolve();
+ }
+
+ if (!metadata.id) {
+ showToast('metadata.json missing required "id" field', 'error');
+ return resolve();
+ }
+
+ if (!metadata.storage || !Array.isArray(metadata.storage)) {
+ showToast('metadata.json missing or invalid "storage" array', 'error');
+ return resolve();
+ }
+
+ // Build file map by name (both simple filename and full path)
+ var fileMap = {};
+ files.forEach(f => {
+ var simpleName = f.name.split('/').pop();
+ fileMap[simpleName] = f;
+ fileMap[f.name] = f;
+ });
+
+ // Build app object from metadata
+ var app = {
+ id: metadata.id,
+ name: metadata.name || metadata.id,
+ version: metadata.version || "0.0.0",
+ type: metadata.type,
+ tags: metadata.tags,
+ sortorder: metadata.sortorder,
+ storage: metadata.storage,
+ data: metadata.data || []
+ };
+
+ // Determine number of files that will actually be transferred
+ var transferCount = app.storage.filter(storageEntry => {
+ var url = storageEntry.url || storageEntry.name;
+ return fileMap[url];
+ }).length;
+
+ // Confirm with user, listing transfer count instead of raw selected file count
+ showPrompt("Install App from Files",
+ `Install app "${app.name}" (${app.id}) version ${app.version}?\n\nWill transfer ${transferCount} file(s) from metadata.\n\nThis will delete the existing version if installed.`
+ ).then(() => {
+ Progress.show({title:`Reading files...`});
+
+ var sourceContents = {}; // url -> content
+ var missingFiles = [];
+
+ function isTextPath(p){
+ return /\.(js|json|txt|md|html|css)$/i.test(p);
+ }
+
+ // Process all files referenced in storage
+ app.storage.forEach(storageEntry => {
+ var url = storageEntry.url || storageEntry.name;
+ var file = fileMap[url];
+
+ if (!file) {
+ console.warn(`File not found: ${url}`);
+ missingFiles.push(url);
+ return;
+ }
+
+ try {
+ var isText = storageEntry.evaluate || isTextPath(url);
+
+ if (isText) {
+ // Convert to text
+ sourceContents[url] = new TextDecoder().decode(new Uint8Array(file.data));
+ } else {
+ // Convert ArrayBuffer to binary string
+ var a = new Uint8Array(file.data);
+ var s = "";
+ for (var i=0; i 0) {
+ Progress.hide({sticky:true});
+ showToast('Missing or unreadable files: ' + missingFiles.join(', '), 'error');
+ return resolve();
+ }
+
+ // Build app object with inline contents
+ var appForUpload = {
+ id: app.id,
+ name: app.name,
+ version: app.version,
+ type: app.type,
+ tags: app.tags,
+ sortorder: app.sortorder,
+ storage: app.storage.map(storageEntry => {
+ var url = storageEntry.url || storageEntry.name;
+ var content = sourceContents[url];
+ if (content === undefined) return null;
+ return {
+ name: storageEntry.name,
+ url: storageEntry.url,
+ content: content,
+ evaluate: !!storageEntry.evaluate,
+ noOverwrite: !!storageEntry.noOverwrite,
+ dataFile: !!storageEntry.dataFile,
+ supports: storageEntry.supports
+ };
+ }).filter(Boolean),
+ data: app.data || []
+ };
+
+ return Promise.resolve(appForUpload)
+ .then(appForUpload => {
+ // Delete existing app if installed using the same pattern as updateApp
+ Progress.hide({sticky:true});
+ Progress.show({title:`Checking for existing version...`});
+ return Comms.getAppInfo(appForUpload)
+ .then(remove => {
+ if (!remove) return appForUpload; // not installed
+ Progress.hide({sticky:true});
+ Progress.show({title:`Removing old version...`});
+ // containsFileList:true so we trust the watch's file list
+ return Comms.removeApp(remove, {containsFileList:true}).then(() => appForUpload);
+ });
+ }).then(appForUpload => {
+ // Upload using the standard pipeline
+ Progress.hide({sticky:true});
+ Progress.show({title:`Installing ${appForUpload.name}...`, sticky:true});
+ return Comms.uploadApp(appForUpload, {device: device, language: LANGUAGE});
+ }).then(() => {
+ Progress.hide({sticky:true});
+ showToast(`App "${app.name}" installed successfully!`, 'success');
+ resolve();
+ }).catch(err => {
+ Progress.hide({sticky:true});
+ showToast('Install failed: ' + err, 'error');
+ console.error(err);
+ resolve();
+ });
+ }).catch(err => {
+ Progress.hide({sticky:true});
+ showToast('Install cancelled or failed: ' + err, 'error');
+ console.error(err);
+ resolve();
+ });
+ }, 100); // Small delay to ensure all file callbacks complete
+ });
+ });
+}
+
+// Attach UI handler to the button
+window.addEventListener('load', (event) => {
+ var btn = document.getElementById("installappfromfiles");
+ if (!btn) return;
+ btn.addEventListener("click", event => {
+ startOperation({name:"Install App from Files"}, () => installFromFiles());
+ });
+});
+
From e08cff66f8497239e58bcda4eaa0e5427cbb42b4 Mon Sep 17 00:00:00 2001
From: Logan B <3870583+thinkpoop@users.noreply.github.com>
Date: Wed, 19 Nov 2025 19:09:27 -0600
Subject: [PATCH 2/7] apploader - install-app-from-files - fix android upload;
handle data/edge cases
---
install_from_files.js | 218 +++++++++++++++++++++++++++++++++++++-----
1 file changed, 195 insertions(+), 23 deletions(-)
diff --git a/install_from_files.js b/install_from_files.js
index 00cae4a6a0..d5a0dbc3f8 100644
--- a/install_from_files.js
+++ b/install_from_files.js
@@ -1,9 +1,16 @@
/**
* Apploader - Install App from selected files
+ *
+ * This function allows users to install BangleJS apps by selecting files from their local filesystem.
+ * It reads metadata.json and uploads all referenced files to the watch.
*/
function installFromFiles() {
return new Promise(resolve => {
- // Ask user to select all files at once (multi-select)
+ var MAX_WAIT_MS = 5000; // maximum time to wait for metadata.json
+ var RESCHEDULE_MS = 400; // retry interval while waiting
+
+ // SOURCE: core/lib/espruinotools.js fileOpenDialog
+ // Request multi-file selection from user
Espruino.Core.Utils.fileOpenDialog({
id:"installappfiles",
type:"arraybuffer",
@@ -14,7 +21,8 @@ function installFromFiles() {
if (!installFromFiles.fileCollection) {
installFromFiles.fileCollection = {
files: [],
- count: 0
+ count: 0,
+ firstTs: Date.now() // Track when first file arrived for timeout
};
}
@@ -28,20 +36,41 @@ function installFromFiles() {
// Use setTimeout to batch-process after all callbacks complete
clearTimeout(installFromFiles.processTimeout);
- installFromFiles.processTimeout = setTimeout(function() {
- var files = installFromFiles.fileCollection.files;
- installFromFiles.fileCollection = null; // reset for next use
-
- if (!files || files.length === 0) return resolve();
+
+ // ANDROID FIX: Debounce and reschedule until metadata.json appears or timeout
+ // Standard desktop browsers deliver all files quickly; Android can have 100-500ms gaps
+ installFromFiles.processTimeout = setTimeout(function processSelection() {
+ var fc = installFromFiles.fileCollection;
+ var files = fc ? fc.files : null;
+
+ if (!files || files.length === 0) {
+ // nothing yet; keep waiting until max wait then resolve silently
+ if (fc && (Date.now() - fc.firstTs) < MAX_WAIT_MS) {
+ installFromFiles.processTimeout = setTimeout(processSelection, RESCHEDULE_MS);
+ return;
+ }
+ installFromFiles.fileCollection = null; // reset
+ return resolve();
+ }
// Find metadata.json
var metadataFile = files.find(f => f.name === 'metadata.json' || f.name.endsWith('/metadata.json'));
if (!metadataFile) {
+ if (fc && (Date.now() - fc.firstTs) < MAX_WAIT_MS) {
+ // Keep waiting for the rest of the files
+ installFromFiles.processTimeout = setTimeout(processSelection, RESCHEDULE_MS);
+ return;
+ }
+ // Timed out waiting for metadata.json
+ installFromFiles.fileCollection = null; // reset
showToast('No metadata.json found in selected files', 'error');
return resolve();
}
+ // We have metadata.json; stop collecting and proceed
+ installFromFiles.fileCollection = null; // reset for next use
+
// Parse metadata.json
var metadata;
try {
@@ -52,6 +81,7 @@ function installFromFiles() {
return resolve();
}
+ // Validate required fields per README.md
if (!metadata.id) {
showToast('metadata.json missing required "id" field', 'error');
return resolve();
@@ -62,7 +92,9 @@ function installFromFiles() {
return resolve();
}
+ // SOURCE: core/js/appinfo.js getFiles() - build file map for lookup
// Build file map by name (both simple filename and full path)
+ // This handles both "app.js" selections and "folder/app.js" selections
var fileMap = {};
files.forEach(f => {
var simpleName = f.name.split('/').pop();
@@ -70,6 +102,7 @@ function installFromFiles() {
fileMap[f.name] = f;
});
+ // SOURCE: core/js/appinfo.js createAppJSON() - build app object from metadata
// Build app object from metadata
var app = {
id: metadata.id,
@@ -79,10 +112,25 @@ function installFromFiles() {
tags: metadata.tags,
sortorder: metadata.sortorder,
storage: metadata.storage,
- data: metadata.data || []
+ data: metadata.data || [] // NOTE: data[] files are NOT uploaded unless they have url/content
};
+ // SOURCE: core/js/appinfo.js getFiles() - filter by device support
+ // Filter storage files by device compatibility (supports[] field)
+ if (app.storage.some(file => file.supports)) {
+ if (!device || !device.id) {
+ showToast('App requires device-specific files, but no device connected', 'error');
+ return resolve();
+ }
+ // Only keep files that either have no 'supports' field or that support this device
+ app.storage = app.storage.filter(file => {
+ if (!file.supports) return true;
+ return file.supports.includes(device.id);
+ });
+ }
+
// Determine number of files that will actually be transferred
+ // This counts only files from storage[] that we found in the selected files
var transferCount = app.storage.filter(storageEntry => {
var url = storageEntry.url || storageEntry.name;
return fileMap[url];
@@ -97,11 +145,14 @@ function installFromFiles() {
var sourceContents = {}; // url -> content
var missingFiles = [];
+ // SOURCE: core/js/appinfo.js parseJS() - detect text files by extension
function isTextPath(p){
return /\.(js|json|txt|md|html|css)$/i.test(p);
}
+ // SOURCE: core/js/appinfo.js getFiles() - process all files referenced in storage
// Process all files referenced in storage
+ // NOTE: We do NOT process data[] files here unless they have url/content specified
app.storage.forEach(storageEntry => {
var url = storageEntry.url || storageEntry.name;
var file = fileMap[url];
@@ -113,13 +164,17 @@ function installFromFiles() {
}
try {
+ // EVALUATE FILES: If evaluate:true, file contains JS expression to evaluate on device
+ // Common use: app-icon.js with heatshrink-compressed image data
+ // Pattern from core/js/appinfo.js getFiles() and README.md
var isText = storageEntry.evaluate || isTextPath(url);
if (isText) {
// Convert to text
sourceContents[url] = new TextDecoder().decode(new Uint8Array(file.data));
} else {
- // Convert ArrayBuffer to binary string
+ // SOURCE: core/js/appinfo.js asJSExpr() - convert ArrayBuffer to binary string
+ // Convert ArrayBuffer to binary string (for images, etc.)
var a = new Uint8Array(file.data);
var s = "";
for (var i=0; i {
+ // Skip entries that are just tracking patterns (wildcard, or name-only without url/content)
+ if (dataEntry.wildcard) return;
+ if (!dataEntry.url && !dataEntry.content) return;
+
+ var url = dataEntry.url || dataEntry.name;
+ var file = fileMap[url];
+
+ if (!file && !dataEntry.content) {
+ console.warn(`Data file not found: ${url}`);
+ // Don't add to missingFiles - data files are optional
+ return;
+ }
+
+ if (file) {
+ try {
+ var isText = dataEntry.evaluate || isTextPath(url);
+ if (isText) {
+ sourceContents[url] = new TextDecoder().decode(new Uint8Array(file.data));
+ } else {
+ var a = new Uint8Array(file.data);
+ var s = "";
+ for (var i=0; i 0) {
Progress.hide({sticky:true});
showToast('Missing or unreadable files: ' + missingFiles.join(', '), 'error');
return resolve();
}
- // Build app object with inline contents
+ // SOURCE: core/js/appinfo.js createAppJSON() - build app object with inline contents
+ // Build app object with inline contents for upload
+ // This matches the structure expected by Comms.uploadApp
var appForUpload = {
id: app.id,
name: app.name,
@@ -153,18 +245,85 @@ function installFromFiles() {
name: storageEntry.name,
url: storageEntry.url,
content: content,
- evaluate: !!storageEntry.evaluate,
- noOverwrite: !!storageEntry.noOverwrite,
- dataFile: !!storageEntry.dataFile,
- supports: storageEntry.supports
+ evaluate: !!storageEntry.evaluate, // JS expression to eval on device
+ noOverwrite: !!storageEntry.noOverwrite, // Don't overwrite if exists (checked below)
+ dataFile: !!storageEntry.dataFile, // File written by app (not uploaded)
+ supports: storageEntry.supports // Device compatibility (already filtered above)
};
}).filter(Boolean),
- data: app.data || []
+ data: app.data || [] // Files app writes - tracked for uninstall, optionally uploaded if url/content provided
};
+
+ // Add data[] files with content to storage for upload
+ if (app.data && Array.isArray(app.data)) {
+ app.data.forEach(dataEntry => {
+ // Only add if we have content and it's meant to be uploaded initially
+ if (!dataEntry.url && !dataEntry.content) return;
+ if (dataEntry.wildcard) return;
+
+ var url = dataEntry.url || dataEntry.name;
+ var content = dataEntry.content || sourceContents[url];
+ if (content === undefined) return;
+
+ appForUpload.storage.push({
+ name: dataEntry.name,
+ url: dataEntry.url,
+ content: content,
+ evaluate: !!dataEntry.evaluate,
+ noOverwrite: true, // Data files should not overwrite by default
+ dataFile: true,
+ storageFile: !!dataEntry.storageFile
+ });
+ });
+ }
- return Promise.resolve(appForUpload)
- .then(appForUpload => {
- // Delete existing app if installed using the same pattern as updateApp
+ // SOURCE: core/js/index.js updateApp() lines 963-978
+ // Check for noOverwrite files that exist on device
+ var noOverwriteChecks = Promise.resolve();
+ var filesToCheck = appForUpload.storage.filter(f => f.noOverwrite);
+
+ if (filesToCheck.length > 0) {
+ Progress.hide({sticky:true});
+ Progress.show({title:`Checking existing files...`});
+
+ // Build a single command to check all noOverwrite files at once
+ var checkCmd = filesToCheck.map(f =>
+ `require('Storage').read(${JSON.stringify(f.name)})!==undefined`
+ ).join(',');
+
+ noOverwriteChecks = new Promise((resolveCheck, rejectCheck) => {
+ Comms.eval(`[${checkCmd}]`, (result, err) => {
+ if (err) {
+ console.warn('Error checking noOverwrite files:', err);
+ resolveCheck(); // Continue anyway
+ return;
+ }
+ try {
+ var existsArray = result;
+ // Remove files that already exist from the upload list
+ filesToCheck.forEach((file, idx) => {
+ if (existsArray[idx]) {
+ console.log(`Skipping ${file.name} (noOverwrite and already exists)`);
+ var fileIdx = appForUpload.storage.indexOf(file);
+ if (fileIdx !== -1) {
+ appForUpload.storage.splice(fileIdx, 1);
+ }
+ }
+ });
+ resolveCheck();
+ } catch(e) {
+ console.warn('Error parsing noOverwrite check results:', e);
+ resolveCheck(); // Continue anyway
+ }
+ });
+ });
+ }
+
+ // SOURCE: core/js/index.js updateApp() lines 963-978
+ // Delete existing app if installed using the same pattern as updateApp
+ return noOverwriteChecks.then(appForUpload => {
+ // SOURCE: core/js/index.js updateApp() line 963
+ // Check if app is already installed
Progress.hide({sticky:true});
Progress.show({title:`Checking for existing version...`});
return Comms.getAppInfo(appForUpload)
@@ -172,13 +331,25 @@ function installFromFiles() {
if (!remove) return appForUpload; // not installed
Progress.hide({sticky:true});
Progress.show({title:`Removing old version...`});
- // containsFileList:true so we trust the watch's file list
+ // SOURCE: core/js/index.js updateApp() line 978
+ // containsFileList:true tells removeApp to trust the watch's file list
+ // This matches the updateApp pattern exactly
return Comms.removeApp(remove, {containsFileList:true}).then(() => appForUpload);
});
}).then(appForUpload => {
+ // SOURCE: core/js/index.js uploadApp() line 840 and updateApp() line 983
// Upload using the standard pipeline
Progress.hide({sticky:true});
Progress.show({title:`Installing ${appForUpload.name}...`, sticky:true});
+ // Pass device and language options like uploadApp/updateApp do
+ // NOTE: Comms.uploadApp handles:
+ // - Creating .info file via AppInfo.createAppJSON
+ // - Minification/pretokenisation via AppInfo.parseJS if settings.minify=true
+ // - Module resolution
+ // - Language translation
+ // - File upload commands
+ // - Progress updates
+ // - Final success message via showUploadFinished()
return Comms.uploadApp(appForUpload, {device: device, language: LANGUAGE});
}).then(() => {
Progress.hide({sticky:true});
@@ -196,17 +367,18 @@ function installFromFiles() {
console.error(err);
resolve();
});
- }, 100); // Small delay to ensure all file callbacks complete
+ }, 1200); // Debounce to gather all files (Android-friendly)
});
});
}
-// Attach UI handler to the button
+// Attach UI handler to the button on window load
window.addEventListener('load', (event) => {
var btn = document.getElementById("installappfromfiles");
if (!btn) return;
btn.addEventListener("click", event => {
+ // SOURCE: core/js/index.js uploadApp/updateApp pattern
+ // Wrap in startOperation for consistent UI feedback
startOperation({name:"Install App from Files"}, () => installFromFiles());
});
-});
-
+});
\ No newline at end of file
From 67d5e06b33c5b089c9f68f092cdee345253cb145 Mon Sep 17 00:00:00 2001
From: Logan B <3870583+thinkpoop@users.noreply.github.com>
Date: Wed, 19 Nov 2025 19:09:55 -0600
Subject: [PATCH 3/7] apploader - install-app-from-files - add count of data
files
---
install_from_files.js | 22 +++++++++++++++++-----
1 file changed, 17 insertions(+), 5 deletions(-)
diff --git a/install_from_files.js b/install_from_files.js
index d5a0dbc3f8..025b4ccac0 100644
--- a/install_from_files.js
+++ b/install_from_files.js
@@ -129,16 +129,28 @@ function installFromFiles() {
});
}
- // Determine number of files that will actually be transferred
- // This counts only files from storage[] that we found in the selected files
- var transferCount = app.storage.filter(storageEntry => {
+ // Determine number of storage files that will actually be transferred
+ var storageTransferCount = app.storage.filter(storageEntry => {
var url = storageEntry.url || storageEntry.name;
return fileMap[url];
}).length;
- // Confirm with user, listing transfer count instead of raw selected file count
+ // Determine number of data files expected to be transferred (url/content present, not wildcard)
+ var dataTransferCount = 0;
+ if (app.data && Array.isArray(app.data)) {
+ app.data.forEach(dataEntry => {
+ if (dataEntry.wildcard) return; // pattern only
+ if (!dataEntry.url && !dataEntry.content) return; // no source specified
+ var url = dataEntry.url || dataEntry.name;
+ if (dataEntry.content || fileMap[url]) dataTransferCount++;
+ });
+ }
+
+ // Build breakdown string (omit data if zero)
+ var breakdown = `${storageTransferCount} storage file(s)` + (dataTransferCount>0 ? ` and ${dataTransferCount} data file(s)` : "");
+
showPrompt("Install App from Files",
- `Install app "${app.name}" (${app.id}) version ${app.version}?\n\nWill transfer ${transferCount} file(s) from metadata.\n\nThis will delete the existing version if installed.`
+ `Install app "${app.name}" (${app.id}) version ${app.version}?\n\nWill transfer ${breakdown} from metadata.\n\nThis will delete the existing version if installed.`
).then(() => {
Progress.show({title:`Reading files...`});
From 97da5b9fab1f01c8a64e11e682b64f70b0b9ba87 Mon Sep 17 00:00:00 2001
From: Logan B <3870583+thinkpoop@users.noreply.github.com>
Date: Wed, 19 Nov 2025 20:00:06 -0600
Subject: [PATCH 4/7] apploader - install-app-from-files - enhance metadata
handling and add dependency checks
---
install_from_files.js | 63 ++++++++++++++++++++++++++++++++++++++-----
1 file changed, 56 insertions(+), 7 deletions(-)
diff --git a/install_from_files.js b/install_from_files.js
index 025b4ccac0..8645d0a054 100644
--- a/install_from_files.js
+++ b/install_from_files.js
@@ -112,7 +112,11 @@ function installFromFiles() {
tags: metadata.tags,
sortorder: metadata.sortorder,
storage: metadata.storage,
- data: metadata.data || [] // NOTE: data[] files are NOT uploaded unless they have url/content
+ data: metadata.data || [], // NOTE: data[] files are NOT uploaded unless they have url/content
+ dependencies: metadata.dependencies, // Dependencies on other apps/modules/widgets
+ provides_modules: metadata.provides_modules, // Modules this app provides
+ provides_widgets: metadata.provides_widgets, // Widgets this app provides
+ provides_features: metadata.provides_features // Features this app provides
};
// SOURCE: core/js/appinfo.js getFiles() - filter by device support
@@ -148,9 +152,15 @@ function installFromFiles() {
// Build breakdown string (omit data if zero)
var breakdown = `${storageTransferCount} storage file(s)` + (dataTransferCount>0 ? ` and ${dataTransferCount} data file(s)` : "");
+ // Dependency note (if any declared in metadata)
+ var dependencyNote = "";
+ if (metadata && metadata.dependencies && typeof metadata.dependencies === 'object' && Object.keys(metadata.dependencies).length) {
+ var depNames = Object.keys(metadata.dependencies).join(", ");
+ dependencyNote = `\n\nRequires dependencies: ${depNames}. We'll attempt to install these if missing.`;
+ }
showPrompt("Install App from Files",
- `Install app "${app.name}" (${app.id}) version ${app.version}?\n\nWill transfer ${breakdown} from metadata.\n\nThis will delete the existing version if installed.`
+ `Install app "${app.name}" (${app.id}) version ${app.version}?\n\nWill transfer ${breakdown} from metadata.${dependencyNote}\n\nThis will delete the existing version if installed.`
).then(() => {
Progress.show({title:`Reading files...`});
@@ -249,6 +259,10 @@ function installFromFiles() {
type: app.type,
tags: app.tags,
sortorder: app.sortorder,
+ dependencies: app.dependencies, // Pass through dependencies for checking
+ provides_modules: app.provides_modules,
+ provides_widgets: app.provides_widgets,
+ provides_features: app.provides_features,
storage: app.storage.map(storageEntry => {
var url = storageEntry.url || storageEntry.name;
var content = sourceContents[url];
@@ -291,7 +305,7 @@ function installFromFiles() {
// SOURCE: core/js/index.js updateApp() lines 963-978
// Check for noOverwrite files that exist on device
- var noOverwriteChecks = Promise.resolve();
+ var noOverwriteChecks = Promise.resolve(appForUpload);
var filesToCheck = appForUpload.storage.filter(f => f.noOverwrite);
if (filesToCheck.length > 0) {
@@ -307,7 +321,7 @@ function installFromFiles() {
Comms.eval(`[${checkCmd}]`, (result, err) => {
if (err) {
console.warn('Error checking noOverwrite files:', err);
- resolveCheck(); // Continue anyway
+ resolveCheck(appForUpload); // Continue anyway, pass appForUpload through
return;
}
try {
@@ -322,10 +336,10 @@ function installFromFiles() {
}
}
});
- resolveCheck();
+ resolveCheck(appForUpload); // Pass appForUpload through the promise chain
} catch(e) {
console.warn('Error parsing noOverwrite check results:', e);
- resolveCheck(); // Continue anyway
+ resolveCheck(appForUpload); // Continue anyway, pass appForUpload through
}
});
});
@@ -348,6 +362,33 @@ function installFromFiles() {
// This matches the updateApp pattern exactly
return Comms.removeApp(remove, {containsFileList:true}).then(() => appForUpload);
});
+ }).then(appForUpload => {
+ // SOURCE: core/js/index.js uploadApp() line 839
+ // Check and install dependencies before uploading
+ Progress.hide({sticky:true});
+ Progress.show({title:`Checking dependencies...`});
+ return AppInfo.checkDependencies(appForUpload, device, {
+ apps: appJSON,
+ device: device,
+ language: LANGUAGE,
+ needsApp: (depApp, uploadOptions) => Comms.uploadApp(depApp, uploadOptions),
+ showQuery: (msg, appToRemove) => {
+ return showPrompt("App Dependencies",
+ `${msg}. What would you like to do?`,
+ {options:["Replace","Keep Both","Cancel"]})
+ .then(choice => {
+ if (choice === "Replace") {
+ return Comms.removeApp(appToRemove).then(() => {
+ device.appsInstalled = device.appsInstalled.filter(a=>a.id!=appToRemove.id);
+ });
+ } else if (choice === "Keep Both") {
+ return Promise.resolve();
+ } else {
+ return Promise.reject("User cancelled");
+ }
+ });
+ }
+ }).then(() => appForUpload);
}).then(appForUpload => {
// SOURCE: core/js/index.js uploadApp() line 840 and updateApp() line 983
// Upload using the standard pipeline
@@ -363,9 +404,17 @@ function installFromFiles() {
// - Progress updates
// - Final success message via showUploadFinished()
return Comms.uploadApp(appForUpload, {device: device, language: LANGUAGE});
- }).then(() => {
+ }).then((appJSON) => {
Progress.hide({sticky:true});
+ if (appJSON) {
+ // Keep device.appsInstalled in sync like the normal loader
+ device.appsInstalled = device.appsInstalled.filter(a=>a.id!=app.id);
+ device.appsInstalled.push(appJSON);
+ }
showToast(`App "${app.name}" installed successfully!`, 'success');
+ // Refresh UI panels like normal loader flows
+ if (typeof refreshMyApps === 'function') refreshMyApps();
+ if (typeof refreshLibrary === 'function') refreshLibrary();
resolve();
}).catch(err => {
Progress.hide({sticky:true});
From bcbe4cd89a148c3d07ddb80d189954e408ce36ba Mon Sep 17 00:00:00 2001
From: Logan B <3870583+thinkpoop@users.noreply.github.com>
Date: Thu, 20 Nov 2025 18:04:20 -0600
Subject: [PATCH 5/7] apploader - install-app-from-files - simplify; remove
duplicate stuff
---
install_from_files.js | 428 +++++++-----------------------------------
1 file changed, 73 insertions(+), 355 deletions(-)
diff --git a/install_from_files.js b/install_from_files.js
index 8645d0a054..fe3de6c333 100644
--- a/install_from_files.js
+++ b/install_from_files.js
@@ -2,14 +2,13 @@
* Apploader - Install App from selected files
*
* This function allows users to install BangleJS apps by selecting files from their local filesystem.
- * It reads metadata.json and uploads all referenced files to the watch.
+ * It reads metadata.json and uploads all referenced files to the watch using the standard upload pipeline.
*/
function installFromFiles() {
return new Promise(resolve => {
- var MAX_WAIT_MS = 5000; // maximum time to wait for metadata.json
- var RESCHEDULE_MS = 400; // retry interval while waiting
+ const MAX_WAIT_MS = 5000;
+ const RESCHEDULE_MS = 400;
- // SOURCE: core/lib/espruinotools.js fileOpenDialog
// Request multi-file selection from user
Espruino.Core.Utils.fileOpenDialog({
id:"installappfiles",
@@ -21,425 +20,144 @@ function installFromFiles() {
if (!installFromFiles.fileCollection) {
installFromFiles.fileCollection = {
files: [],
- count: 0,
- firstTs: Date.now() // Track when first file arrived for timeout
+ firstTs: Date.now()
};
}
- // Store this file
installFromFiles.fileCollection.files.push({
name: fileName,
- data: fileData,
- mimeType: mimeType
+ data: fileData
});
- installFromFiles.fileCollection.count++;
- // Use setTimeout to batch-process after all callbacks complete
clearTimeout(installFromFiles.processTimeout);
- // ANDROID FIX: Debounce and reschedule until metadata.json appears or timeout
- // Standard desktop browsers deliver all files quickly; Android can have 100-500ms gaps
+ // ANDROID FIX: Debounce until metadata.json appears or timeout
+ // Desktop browsers deliver all files quickly; Android can have 100-500ms gaps
installFromFiles.processTimeout = setTimeout(function processSelection() {
- var fc = installFromFiles.fileCollection;
- var files = fc ? fc.files : null;
+ const fc = installFromFiles.fileCollection;
+ const files = fc ? fc.files : null;
if (!files || files.length === 0) {
- // nothing yet; keep waiting until max wait then resolve silently
if (fc && (Date.now() - fc.firstTs) < MAX_WAIT_MS) {
installFromFiles.processTimeout = setTimeout(processSelection, RESCHEDULE_MS);
return;
}
- installFromFiles.fileCollection = null; // reset
+ installFromFiles.fileCollection = null;
return resolve();
}
- // Find metadata.json
- var metadataFile = files.find(f => f.name === 'metadata.json' || f.name.endsWith('/metadata.json'));
+ const metadataFile = files.find(f => f.name === 'metadata.json' || f.name.endsWith('/metadata.json'));
if (!metadataFile) {
if (fc && (Date.now() - fc.firstTs) < MAX_WAIT_MS) {
- // Keep waiting for the rest of the files
installFromFiles.processTimeout = setTimeout(processSelection, RESCHEDULE_MS);
return;
}
- // Timed out waiting for metadata.json
- installFromFiles.fileCollection = null; // reset
+ installFromFiles.fileCollection = null;
showToast('No metadata.json found in selected files', 'error');
return resolve();
}
- // We have metadata.json; stop collecting and proceed
- installFromFiles.fileCollection = null; // reset for next use
+ installFromFiles.fileCollection = null;
// Parse metadata.json
- var metadata;
+ let app;
try {
- var metadataText = new TextDecoder().decode(new Uint8Array(metadataFile.data));
- metadata = JSON.parse(metadataText);
+ const metadataText = new TextDecoder().decode(new Uint8Array(metadataFile.data));
+ app = JSON.parse(metadataText);
} catch(err) {
showToast('Failed to parse metadata.json: ' + err, 'error');
return resolve();
}
- // Validate required fields per README.md
- if (!metadata.id) {
- showToast('metadata.json missing required "id" field', 'error');
+ if (!app.id || !app.storage || !Array.isArray(app.storage)) {
+ showToast('Invalid metadata.json', 'error');
return resolve();
}
- if (!metadata.storage || !Array.isArray(metadata.storage)) {
- showToast('metadata.json missing or invalid "storage" array', 'error');
- return resolve();
- }
-
- // SOURCE: core/js/appinfo.js getFiles() - build file map for lookup
- // Build file map by name (both simple filename and full path)
- // This handles both "app.js" selections and "folder/app.js" selections
- var fileMap = {};
+ // Build file map for lookup (both simple filename and full path)
+ const fileMap = {};
files.forEach(f => {
- var simpleName = f.name.split('/').pop();
+ const simpleName = f.name.split('/').pop();
fileMap[simpleName] = f;
fileMap[f.name] = f;
});
- // SOURCE: core/js/appinfo.js createAppJSON() - build app object from metadata
- // Build app object from metadata
- var app = {
- id: metadata.id,
- name: metadata.name || metadata.id,
- version: metadata.version || "0.0.0",
- type: metadata.type,
- tags: metadata.tags,
- sortorder: metadata.sortorder,
- storage: metadata.storage,
- data: metadata.data || [], // NOTE: data[] files are NOT uploaded unless they have url/content
- dependencies: metadata.dependencies, // Dependencies on other apps/modules/widgets
- provides_modules: metadata.provides_modules, // Modules this app provides
- provides_widgets: metadata.provides_widgets, // Widgets this app provides
- provides_features: metadata.provides_features // Features this app provides
- };
-
- // SOURCE: core/js/appinfo.js getFiles() - filter by device support
- // Filter storage files by device compatibility (supports[] field)
- if (app.storage.some(file => file.supports)) {
- if (!device || !device.id) {
- showToast('App requires device-specific files, but no device connected', 'error');
- return resolve();
+ // Populate content directly into storage entries so AppInfo.getFiles doesn't fetch URLs
+ app.storage.forEach(storageEntry => {
+ const fileName = storageEntry.url || storageEntry.name;
+ const file = fileMap[fileName];
+ if (file) {
+ const data = new Uint8Array(file.data);
+ let content = "";
+ for (let i = 0; i < data.length; i++) {
+ content += String.fromCharCode(data[i]);
+ }
+ storageEntry.content = content;
}
- // Only keep files that either have no 'supports' field or that support this device
- app.storage = app.storage.filter(file => {
- if (!file.supports) return true;
- return file.supports.includes(device.id);
- });
- }
-
- // Determine number of storage files that will actually be transferred
- var storageTransferCount = app.storage.filter(storageEntry => {
- var url = storageEntry.url || storageEntry.name;
- return fileMap[url];
- }).length;
+ });
- // Determine number of data files expected to be transferred (url/content present, not wildcard)
- var dataTransferCount = 0;
+ // Populate content into data entries as well
if (app.data && Array.isArray(app.data)) {
app.data.forEach(dataEntry => {
- if (dataEntry.wildcard) return; // pattern only
- if (!dataEntry.url && !dataEntry.content) return; // no source specified
- var url = dataEntry.url || dataEntry.name;
- if (dataEntry.content || fileMap[url]) dataTransferCount++;
+ if (dataEntry.content) return; // already has inline content
+ const fileName = dataEntry.url || dataEntry.name;
+ const file = fileMap[fileName];
+ if (file) {
+ const data = new Uint8Array(file.data);
+ let content = "";
+ for (let i = 0; i < data.length; i++) {
+ content += String.fromCharCode(data[i]);
+ }
+ dataEntry.content = content;
+ }
});
}
- // Build breakdown string (omit data if zero)
- var breakdown = `${storageTransferCount} storage file(s)` + (dataTransferCount>0 ? ` and ${dataTransferCount} data file(s)` : "");
- // Dependency note (if any declared in metadata)
- var dependencyNote = "";
- if (metadata && metadata.dependencies && typeof metadata.dependencies === 'object' && Object.keys(metadata.dependencies).length) {
- var depNames = Object.keys(metadata.dependencies).join(", ");
- dependencyNote = `\n\nRequires dependencies: ${depNames}. We'll attempt to install these if missing.`;
- }
-
showPrompt("Install App from Files",
- `Install app "${app.name}" (${app.id}) version ${app.version}?\n\nWill transfer ${breakdown} from metadata.${dependencyNote}\n\nThis will delete the existing version if installed.`
+ `Install "${app.name}" (${app.id}) v${app.version}?\n\nThis will delete the existing version if installed.`
).then(() => {
- Progress.show({title:`Reading files...`});
-
- var sourceContents = {}; // url -> content
- var missingFiles = [];
-
- // SOURCE: core/js/appinfo.js parseJS() - detect text files by extension
- function isTextPath(p){
- return /\.(js|json|txt|md|html|css)$/i.test(p);
- }
-
- // SOURCE: core/js/appinfo.js getFiles() - process all files referenced in storage
- // Process all files referenced in storage
- // NOTE: We do NOT process data[] files here unless they have url/content specified
- app.storage.forEach(storageEntry => {
- var url = storageEntry.url || storageEntry.name;
- var file = fileMap[url];
-
- if (!file) {
- console.warn(`File not found: ${url}`);
- missingFiles.push(url);
- return;
- }
-
- try {
- // EVALUATE FILES: If evaluate:true, file contains JS expression to evaluate on device
- // Common use: app-icon.js with heatshrink-compressed image data
- // Pattern from core/js/appinfo.js getFiles() and README.md
- var isText = storageEntry.evaluate || isTextPath(url);
-
- if (isText) {
- // Convert to text
- sourceContents[url] = new TextDecoder().decode(new Uint8Array(file.data));
- } else {
- // SOURCE: core/js/appinfo.js asJSExpr() - convert ArrayBuffer to binary string
- // Convert ArrayBuffer to binary string (for images, etc.)
- var a = new Uint8Array(file.data);
- var s = "";
- for (var i=0; i {
- // Skip entries that are just tracking patterns (wildcard, or name-only without url/content)
- if (dataEntry.wildcard) return;
- if (!dataEntry.url && !dataEntry.content) return;
-
- var url = dataEntry.url || dataEntry.name;
- var file = fileMap[url];
-
- if (!file && !dataEntry.content) {
- console.warn(`Data file not found: ${url}`);
- // Don't add to missingFiles - data files are optional
- return;
- }
-
- if (file) {
- try {
- var isText = dataEntry.evaluate || isTextPath(url);
- if (isText) {
- sourceContents[url] = new TextDecoder().decode(new Uint8Array(file.data));
- } else {
- var a = new Uint8Array(file.data);
- var s = "";
- for (var i=0; i 0) {
- Progress.hide({sticky:true});
- showToast('Missing or unreadable files: ' + missingFiles.join(', '), 'error');
- return resolve();
- }
-
- // SOURCE: core/js/appinfo.js createAppJSON() - build app object with inline contents
- // Build app object with inline contents for upload
- // This matches the structure expected by Comms.uploadApp
- var appForUpload = {
- id: app.id,
- name: app.name,
- version: app.version,
- type: app.type,
- tags: app.tags,
- sortorder: app.sortorder,
- dependencies: app.dependencies, // Pass through dependencies for checking
- provides_modules: app.provides_modules,
- provides_widgets: app.provides_widgets,
- provides_features: app.provides_features,
- storage: app.storage.map(storageEntry => {
- var url = storageEntry.url || storageEntry.name;
- var content = sourceContents[url];
- if (content === undefined) return null;
- return {
- name: storageEntry.name,
- url: storageEntry.url,
- content: content,
- evaluate: !!storageEntry.evaluate, // JS expression to eval on device
- noOverwrite: !!storageEntry.noOverwrite, // Don't overwrite if exists (checked below)
- dataFile: !!storageEntry.dataFile, // File written by app (not uploaded)
- supports: storageEntry.supports // Device compatibility (already filtered above)
- };
- }).filter(Boolean),
- data: app.data || [] // Files app writes - tracked for uninstall, optionally uploaded if url/content provided
- };
-
- // Add data[] files with content to storage for upload
- if (app.data && Array.isArray(app.data)) {
- app.data.forEach(dataEntry => {
- // Only add if we have content and it's meant to be uploaded initially
- if (!dataEntry.url && !dataEntry.content) return;
- if (dataEntry.wildcard) return;
-
- var url = dataEntry.url || dataEntry.name;
- var content = dataEntry.content || sourceContents[url];
- if (content === undefined) return;
-
- appForUpload.storage.push({
- name: dataEntry.name,
- url: dataEntry.url,
- content: content,
- evaluate: !!dataEntry.evaluate,
- noOverwrite: true, // Data files should not overwrite by default
- dataFile: true,
- storageFile: !!dataEntry.storageFile
- });
- });
- }
-
- // SOURCE: core/js/index.js updateApp() lines 963-978
- // Check for noOverwrite files that exist on device
- var noOverwriteChecks = Promise.resolve(appForUpload);
- var filesToCheck = appForUpload.storage.filter(f => f.noOverwrite);
-
- if (filesToCheck.length > 0) {
- Progress.hide({sticky:true});
- Progress.show({title:`Checking existing files...`});
+ // Use standard updateApp flow (remove old, check deps, upload new)
+ return getInstalledApps().then(() => {
+ const isInstalled = device.appsInstalled.some(i => i.id === app.id);
- // Build a single command to check all noOverwrite files at once
- var checkCmd = filesToCheck.map(f =>
- `require('Storage').read(${JSON.stringify(f.name)})!==undefined`
- ).join(',');
+ // If installed, use update flow; otherwise use install flow
+ const uploadPromise = isInstalled
+ ? Comms.getAppInfo(app).then(remove => {
+ return Comms.removeApp(remove, {containsFileList:true});
+ }).then(() => {
+ device.appsInstalled = device.appsInstalled.filter(a => a.id != app.id);
+ return checkDependencies(app, {checkForClashes:false});
+ })
+ : checkDependencies(app);
- noOverwriteChecks = new Promise((resolveCheck, rejectCheck) => {
- Comms.eval(`[${checkCmd}]`, (result, err) => {
- if (err) {
- console.warn('Error checking noOverwrite files:', err);
- resolveCheck(appForUpload); // Continue anyway, pass appForUpload through
- return;
- }
- try {
- var existsArray = result;
- // Remove files that already exist from the upload list
- filesToCheck.forEach((file, idx) => {
- if (existsArray[idx]) {
- console.log(`Skipping ${file.name} (noOverwrite and already exists)`);
- var fileIdx = appForUpload.storage.indexOf(file);
- if (fileIdx !== -1) {
- appForUpload.storage.splice(fileIdx, 1);
- }
- }
- });
- resolveCheck(appForUpload); // Pass appForUpload through the promise chain
- } catch(e) {
- console.warn('Error parsing noOverwrite check results:', e);
- resolveCheck(appForUpload); // Continue anyway, pass appForUpload through
- }
+ return uploadPromise.then(() => {
+ return Comms.uploadApp(app, {
+ device: device,
+ language: LANGUAGE
});
+ }).then((appJSON) => {
+ if (appJSON) device.appsInstalled.push(appJSON);
+ showToast(`"${app.name}" installed!`, 'success');
+ refreshMyApps();
+ refreshLibrary();
});
- }
-
- // SOURCE: core/js/index.js updateApp() lines 963-978
- // Delete existing app if installed using the same pattern as updateApp
- return noOverwriteChecks.then(appForUpload => {
- // SOURCE: core/js/index.js updateApp() line 963
- // Check if app is already installed
- Progress.hide({sticky:true});
- Progress.show({title:`Checking for existing version...`});
- return Comms.getAppInfo(appForUpload)
- .then(remove => {
- if (!remove) return appForUpload; // not installed
- Progress.hide({sticky:true});
- Progress.show({title:`Removing old version...`});
- // SOURCE: core/js/index.js updateApp() line 978
- // containsFileList:true tells removeApp to trust the watch's file list
- // This matches the updateApp pattern exactly
- return Comms.removeApp(remove, {containsFileList:true}).then(() => appForUpload);
- });
- }).then(appForUpload => {
- // SOURCE: core/js/index.js uploadApp() line 839
- // Check and install dependencies before uploading
- Progress.hide({sticky:true});
- Progress.show({title:`Checking dependencies...`});
- return AppInfo.checkDependencies(appForUpload, device, {
- apps: appJSON,
- device: device,
- language: LANGUAGE,
- needsApp: (depApp, uploadOptions) => Comms.uploadApp(depApp, uploadOptions),
- showQuery: (msg, appToRemove) => {
- return showPrompt("App Dependencies",
- `${msg}. What would you like to do?`,
- {options:["Replace","Keep Both","Cancel"]})
- .then(choice => {
- if (choice === "Replace") {
- return Comms.removeApp(appToRemove).then(() => {
- device.appsInstalled = device.appsInstalled.filter(a=>a.id!=appToRemove.id);
- });
- } else if (choice === "Keep Both") {
- return Promise.resolve();
- } else {
- return Promise.reject("User cancelled");
- }
- });
- }
- }).then(() => appForUpload);
- }).then(appForUpload => {
- // SOURCE: core/js/index.js uploadApp() line 840 and updateApp() line 983
- // Upload using the standard pipeline
- Progress.hide({sticky:true});
- Progress.show({title:`Installing ${appForUpload.name}...`, sticky:true});
- // Pass device and language options like uploadApp/updateApp do
- // NOTE: Comms.uploadApp handles:
- // - Creating .info file via AppInfo.createAppJSON
- // - Minification/pretokenisation via AppInfo.parseJS if settings.minify=true
- // - Module resolution
- // - Language translation
- // - File upload commands
- // - Progress updates
- // - Final success message via showUploadFinished()
- return Comms.uploadApp(appForUpload, {device: device, language: LANGUAGE});
- }).then((appJSON) => {
- Progress.hide({sticky:true});
- if (appJSON) {
- // Keep device.appsInstalled in sync like the normal loader
- device.appsInstalled = device.appsInstalled.filter(a=>a.id!=app.id);
- device.appsInstalled.push(appJSON);
- }
- showToast(`App "${app.name}" installed successfully!`, 'success');
- // Refresh UI panels like normal loader flows
- if (typeof refreshMyApps === 'function') refreshMyApps();
- if (typeof refreshLibrary === 'function') refreshLibrary();
- resolve();
- }).catch(err => {
- Progress.hide({sticky:true});
- showToast('Install failed: ' + err, 'error');
- console.error(err);
- resolve();
});
- }).catch(err => {
- Progress.hide({sticky:true});
- showToast('Install cancelled or failed: ' + err, 'error');
+ }).then(resolve).catch(err => {
+ showToast('Install failed: ' + err, 'error');
console.error(err);
resolve();
});
- }, 1200); // Debounce to gather all files (Android-friendly)
+ }, 1200);
});
});
}
// Attach UI handler to the button on window load
window.addEventListener('load', (event) => {
- var btn = document.getElementById("installappfromfiles");
+ const btn = document.getElementById("installappfromfiles");
if (!btn) return;
- btn.addEventListener("click", event => {
- // SOURCE: core/js/index.js uploadApp/updateApp pattern
- // Wrap in startOperation for consistent UI feedback
- startOperation({name:"Install App from Files"}, () => installFromFiles());
+ btn.addEventListener("click", () => {
+ startOperation({name:"Install App from Files"}, installFromFiles);
});
});
\ No newline at end of file
From d4edefaedecfce6d89afa70208f51a2ccebf659f Mon Sep 17 00:00:00 2001
From: Logan B <3870583+thinkpoop@users.noreply.github.com>
Date: Mon, 24 Nov 2025 22:18:26 -0600
Subject: [PATCH 6/7] apploader - install-app-from-files - add onCancel and
onComplete to fix operation close and simplify multifile upload
---
install_from_files.js | 227 +++++++++++++++++++-----------------------
1 file changed, 105 insertions(+), 122 deletions(-)
diff --git a/install_from_files.js b/install_from_files.js
index fe3de6c333..cdf99f8a13 100644
--- a/install_from_files.js
+++ b/install_from_files.js
@@ -6,150 +6,133 @@
*/
function installFromFiles() {
return new Promise(resolve => {
- const MAX_WAIT_MS = 5000;
- const RESCHEDULE_MS = 400;
+
+ // Collect all files
+ const fileCollection = {
+ files: []
+ };
// Request multi-file selection from user
Espruino.Core.Utils.fileOpenDialog({
id:"installappfiles",
type:"arraybuffer",
multi:true,
- mimeType:"*/*"}, function(fileData, mimeType, fileName) {
-
- // Collect all files (callback is invoked once per file when multi:true)
- if (!installFromFiles.fileCollection) {
- installFromFiles.fileCollection = {
- files: [],
- firstTs: Date.now()
- };
- }
+ mimeType:"*/*",
+ onCancel: function() {
+ resolve();
+ },
+ onComplete: function() {
+ processFiles(fileCollection.files, resolve);
+ }}, function(fileData, mimeType, fileName) {
- installFromFiles.fileCollection.files.push({
+ // Collect each file as callback is invoked
+ fileCollection.files.push({
name: fileName,
data: fileData
});
+ });
+ });
+}
- clearTimeout(installFromFiles.processTimeout);
-
- // ANDROID FIX: Debounce until metadata.json appears or timeout
- // Desktop browsers deliver all files quickly; Android can have 100-500ms gaps
- installFromFiles.processTimeout = setTimeout(function processSelection() {
- const fc = installFromFiles.fileCollection;
- const files = fc ? fc.files : null;
-
- if (!files || files.length === 0) {
- if (fc && (Date.now() - fc.firstTs) < MAX_WAIT_MS) {
- installFromFiles.processTimeout = setTimeout(processSelection, RESCHEDULE_MS);
- return;
- }
- installFromFiles.fileCollection = null;
- return resolve();
- }
-
- const metadataFile = files.find(f => f.name === 'metadata.json' || f.name.endsWith('/metadata.json'));
+function processFiles(files, resolve) {
+ if (!files || files.length === 0) {
+ return resolve();
+ }
- if (!metadataFile) {
- if (fc && (Date.now() - fc.firstTs) < MAX_WAIT_MS) {
- installFromFiles.processTimeout = setTimeout(processSelection, RESCHEDULE_MS);
- return;
- }
- installFromFiles.fileCollection = null;
- showToast('No metadata.json found in selected files', 'error');
- return resolve();
- }
+ const metadataFile = files.find(f => f.name === 'metadata.json' || f.name.endsWith('/metadata.json'));
- installFromFiles.fileCollection = null;
+ if (!metadataFile) {
+ showToast('No metadata.json found in selected files', 'error');
+ return resolve();
+ }
- // Parse metadata.json
- let app;
- try {
- const metadataText = new TextDecoder().decode(new Uint8Array(metadataFile.data));
- app = JSON.parse(metadataText);
- } catch(err) {
- showToast('Failed to parse metadata.json: ' + err, 'error');
- return resolve();
- }
+ // Parse metadata.json
+ let app;
+ try {
+ const metadataText = new TextDecoder().decode(new Uint8Array(metadataFile.data));
+ app = JSON.parse(metadataText);
+ } catch(err) {
+ showToast('Failed to parse metadata.json: ' + err, 'error');
+ return resolve();
+ }
- if (!app.id || !app.storage || !Array.isArray(app.storage)) {
- showToast('Invalid metadata.json', 'error');
- return resolve();
- }
+ if (!app.id || !app.storage || !Array.isArray(app.storage)) {
+ showToast('Invalid metadata.json', 'error');
+ return resolve();
+ }
- // Build file map for lookup (both simple filename and full path)
- const fileMap = {};
- files.forEach(f => {
- const simpleName = f.name.split('/').pop();
- fileMap[simpleName] = f;
- fileMap[f.name] = f;
- });
+ // Build file map for lookup (both simple filename and full path)
+ const fileMap = {};
+ files.forEach(f => {
+ const simpleName = f.name.split('/').pop();
+ fileMap[simpleName] = f;
+ fileMap[f.name] = f;
+ });
- // Populate content directly into storage entries so AppInfo.getFiles doesn't fetch URLs
- app.storage.forEach(storageEntry => {
- const fileName = storageEntry.url || storageEntry.name;
- const file = fileMap[fileName];
- if (file) {
- const data = new Uint8Array(file.data);
- let content = "";
- for (let i = 0; i < data.length; i++) {
- content += String.fromCharCode(data[i]);
- }
- storageEntry.content = content;
- }
- });
+ // Populate content directly into storage entries so AppInfo.getFiles doesn't fetch URLs
+ app.storage.forEach(storageEntry => {
+ const fileName = storageEntry.url || storageEntry.name;
+ const file = fileMap[fileName];
+ if (file) {
+ const data = new Uint8Array(file.data);
+ let content = "";
+ for (let i = 0; i < data.length; i++) {
+ content += String.fromCharCode(data[i]);
+ }
+ storageEntry.content = content;
+ }
+ });
- // Populate content into data entries as well
- if (app.data && Array.isArray(app.data)) {
- app.data.forEach(dataEntry => {
- if (dataEntry.content) return; // already has inline content
- const fileName = dataEntry.url || dataEntry.name;
- const file = fileMap[fileName];
- if (file) {
- const data = new Uint8Array(file.data);
- let content = "";
- for (let i = 0; i < data.length; i++) {
- content += String.fromCharCode(data[i]);
- }
- dataEntry.content = content;
- }
- });
+ // Populate content into data entries as well
+ if (app.data && Array.isArray(app.data)) {
+ app.data.forEach(dataEntry => {
+ if (dataEntry.content) return; // already has inline content
+ const fileName = dataEntry.url || dataEntry.name;
+ const file = fileMap[fileName];
+ if (file) {
+ const data = new Uint8Array(file.data);
+ let content = "";
+ for (let i = 0; i < data.length; i++) {
+ content += String.fromCharCode(data[i]);
}
+ dataEntry.content = content;
+ }
+ });
+ }
- showPrompt("Install App from Files",
- `Install "${app.name}" (${app.id}) v${app.version}?\n\nThis will delete the existing version if installed.`
- ).then(() => {
- // Use standard updateApp flow (remove old, check deps, upload new)
- return getInstalledApps().then(() => {
- const isInstalled = device.appsInstalled.some(i => i.id === app.id);
-
- // If installed, use update flow; otherwise use install flow
- const uploadPromise = isInstalled
- ? Comms.getAppInfo(app).then(remove => {
- return Comms.removeApp(remove, {containsFileList:true});
- }).then(() => {
- device.appsInstalled = device.appsInstalled.filter(a => a.id != app.id);
- return checkDependencies(app, {checkForClashes:false});
- })
- : checkDependencies(app);
-
- return uploadPromise.then(() => {
- return Comms.uploadApp(app, {
- device: device,
- language: LANGUAGE
- });
- }).then((appJSON) => {
- if (appJSON) device.appsInstalled.push(appJSON);
- showToast(`"${app.name}" installed!`, 'success');
- refreshMyApps();
- refreshLibrary();
- });
- });
- }).then(resolve).catch(err => {
- showToast('Install failed: ' + err, 'error');
- console.error(err);
- resolve();
+ showPrompt("Install App from Files",
+ `Install "${app.name}" (${app.id}) v${app.version}?\n\nThis will delete the existing version if installed.`
+ ).then(() => {
+ // Use standard updateApp flow (remove old, check deps, upload new)
+ return getInstalledApps().then(() => {
+ const isInstalled = device.appsInstalled.some(i => i.id === app.id);
+
+ // If installed, use update flow; otherwise use install flow
+ const uploadPromise = isInstalled
+ ? Comms.getAppInfo(app).then(remove => {
+ return Comms.removeApp(remove, {containsFileList:true});
+ }).then(() => {
+ device.appsInstalled = device.appsInstalled.filter(a => a.id != app.id);
+ return checkDependencies(app, {checkForClashes:false});
+ })
+ : checkDependencies(app);
+
+ return uploadPromise.then(() => {
+ return Comms.uploadApp(app, {
+ device: device,
+ language: LANGUAGE
});
- }, 1200);
+ }).then((appJSON) => {
+ if (appJSON) device.appsInstalled.push(appJSON);
+ showToast(`"${app.name}" installed!`, 'success');
+ refreshMyApps();
+ refreshLibrary();
+ });
});
+ }).then(resolve).catch(err => {
+ showToast('Install failed: ' + err, 'error');
+ console.error(err);
+ resolve();
});
}
From 37b1780d3133e31cd19ee29374557caf895f0fd6 Mon Sep 17 00:00:00 2001
From: Logan B <3870583+thinkpoop@users.noreply.github.com>
Date: Tue, 25 Nov 2025 17:22:13 -0600
Subject: [PATCH 7/7] apploader - install-app-from-files - use onComplete event
---
install_from_files.js | 36 ++++++++++++++++--------------------
1 file changed, 16 insertions(+), 20 deletions(-)
diff --git a/install_from_files.js b/install_from_files.js
index cdf99f8a13..0f59b1d068 100644
--- a/install_from_files.js
+++ b/install_from_files.js
@@ -7,29 +7,25 @@
function installFromFiles() {
return new Promise(resolve => {
- // Collect all files
- const fileCollection = {
- files: []
- };
-
// Request multi-file selection from user
Espruino.Core.Utils.fileOpenDialog({
- id:"installappfiles",
- type:"arraybuffer",
- multi:true,
- mimeType:"*/*",
- onCancel: function() {
+ id:"installappfiles",
+ type:"arraybuffer",
+ multi:true,
+ mimeType:"*/*",
+ onComplete: function(files) {
+ try {
+ if (!files) return resolve(); // user cancelled
+ const mapped = files.map(function(f) {
+ return { name: f.fileName, data: f.contents };
+ });
+ processFiles(mapped, resolve);
+ } catch (err) {
+ showToast('Install failed: ' + err, 'error');
+ console.error(err);
resolve();
- },
- onComplete: function() {
- processFiles(fileCollection.files, resolve);
- }}, function(fileData, mimeType, fileName) {
-
- // Collect each file as callback is invoked
- fileCollection.files.push({
- name: fileName,
- data: fileData
- });
+ }
+ }
});
});
}