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 - }); + } + } }); }); }