Skip to content

Commit e08cff6

Browse files
committed
apploader - install-app-from-files - fix android upload; handle data/edge cases
1 parent 67f9a26 commit e08cff6

File tree

1 file changed

+195
-23
lines changed

1 file changed

+195
-23
lines changed

install_from_files.js

Lines changed: 195 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
/**
22
* Apploader - Install App from selected files
3+
*
4+
* This function allows users to install BangleJS apps by selecting files from their local filesystem.
5+
* It reads metadata.json and uploads all referenced files to the watch.
36
*/
47
function installFromFiles() {
58
return new Promise(resolve => {
6-
// Ask user to select all files at once (multi-select)
9+
var MAX_WAIT_MS = 5000; // maximum time to wait for metadata.json
10+
var RESCHEDULE_MS = 400; // retry interval while waiting
11+
12+
// SOURCE: core/lib/espruinotools.js fileOpenDialog
13+
// Request multi-file selection from user
714
Espruino.Core.Utils.fileOpenDialog({
815
id:"installappfiles",
916
type:"arraybuffer",
@@ -14,7 +21,8 @@ function installFromFiles() {
1421
if (!installFromFiles.fileCollection) {
1522
installFromFiles.fileCollection = {
1623
files: [],
17-
count: 0
24+
count: 0,
25+
firstTs: Date.now() // Track when first file arrived for timeout
1826
};
1927
}
2028

@@ -28,20 +36,41 @@ function installFromFiles() {
2836

2937
// Use setTimeout to batch-process after all callbacks complete
3038
clearTimeout(installFromFiles.processTimeout);
31-
installFromFiles.processTimeout = setTimeout(function() {
32-
var files = installFromFiles.fileCollection.files;
33-
installFromFiles.fileCollection = null; // reset for next use
34-
35-
if (!files || files.length === 0) return resolve();
39+
40+
// ANDROID FIX: Debounce and reschedule until metadata.json appears or timeout
41+
// Standard desktop browsers deliver all files quickly; Android can have 100-500ms gaps
42+
installFromFiles.processTimeout = setTimeout(function processSelection() {
43+
var fc = installFromFiles.fileCollection;
44+
var files = fc ? fc.files : null;
45+
46+
if (!files || files.length === 0) {
47+
// nothing yet; keep waiting until max wait then resolve silently
48+
if (fc && (Date.now() - fc.firstTs) < MAX_WAIT_MS) {
49+
installFromFiles.processTimeout = setTimeout(processSelection, RESCHEDULE_MS);
50+
return;
51+
}
52+
installFromFiles.fileCollection = null; // reset
53+
return resolve();
54+
}
3655

3756
// Find metadata.json
3857
var metadataFile = files.find(f => f.name === 'metadata.json' || f.name.endsWith('/metadata.json'));
3958

4059
if (!metadataFile) {
60+
if (fc && (Date.now() - fc.firstTs) < MAX_WAIT_MS) {
61+
// Keep waiting for the rest of the files
62+
installFromFiles.processTimeout = setTimeout(processSelection, RESCHEDULE_MS);
63+
return;
64+
}
65+
// Timed out waiting for metadata.json
66+
installFromFiles.fileCollection = null; // reset
4167
showToast('No metadata.json found in selected files', 'error');
4268
return resolve();
4369
}
4470

71+
// We have metadata.json; stop collecting and proceed
72+
installFromFiles.fileCollection = null; // reset for next use
73+
4574
// Parse metadata.json
4675
var metadata;
4776
try {
@@ -52,6 +81,7 @@ function installFromFiles() {
5281
return resolve();
5382
}
5483

84+
// Validate required fields per README.md
5585
if (!metadata.id) {
5686
showToast('metadata.json missing required "id" field', 'error');
5787
return resolve();
@@ -62,14 +92,17 @@ function installFromFiles() {
6292
return resolve();
6393
}
6494

95+
// SOURCE: core/js/appinfo.js getFiles() - build file map for lookup
6596
// Build file map by name (both simple filename and full path)
97+
// This handles both "app.js" selections and "folder/app.js" selections
6698
var fileMap = {};
6799
files.forEach(f => {
68100
var simpleName = f.name.split('/').pop();
69101
fileMap[simpleName] = f;
70102
fileMap[f.name] = f;
71103
});
72104

105+
// SOURCE: core/js/appinfo.js createAppJSON() - build app object from metadata
73106
// Build app object from metadata
74107
var app = {
75108
id: metadata.id,
@@ -79,10 +112,25 @@ function installFromFiles() {
79112
tags: metadata.tags,
80113
sortorder: metadata.sortorder,
81114
storage: metadata.storage,
82-
data: metadata.data || []
115+
data: metadata.data || [] // NOTE: data[] files are NOT uploaded unless they have url/content
83116
};
84117

118+
// SOURCE: core/js/appinfo.js getFiles() - filter by device support
119+
// Filter storage files by device compatibility (supports[] field)
120+
if (app.storage.some(file => file.supports)) {
121+
if (!device || !device.id) {
122+
showToast('App requires device-specific files, but no device connected', 'error');
123+
return resolve();
124+
}
125+
// Only keep files that either have no 'supports' field or that support this device
126+
app.storage = app.storage.filter(file => {
127+
if (!file.supports) return true;
128+
return file.supports.includes(device.id);
129+
});
130+
}
131+
85132
// Determine number of files that will actually be transferred
133+
// This counts only files from storage[] that we found in the selected files
86134
var transferCount = app.storage.filter(storageEntry => {
87135
var url = storageEntry.url || storageEntry.name;
88136
return fileMap[url];
@@ -97,11 +145,14 @@ function installFromFiles() {
97145
var sourceContents = {}; // url -> content
98146
var missingFiles = [];
99147

148+
// SOURCE: core/js/appinfo.js parseJS() - detect text files by extension
100149
function isTextPath(p){
101150
return /\.(js|json|txt|md|html|css)$/i.test(p);
102151
}
103152

153+
// SOURCE: core/js/appinfo.js getFiles() - process all files referenced in storage
104154
// Process all files referenced in storage
155+
// NOTE: We do NOT process data[] files here unless they have url/content specified
105156
app.storage.forEach(storageEntry => {
106157
var url = storageEntry.url || storageEntry.name;
107158
var file = fileMap[url];
@@ -113,13 +164,17 @@ function installFromFiles() {
113164
}
114165

115166
try {
167+
// EVALUATE FILES: If evaluate:true, file contains JS expression to evaluate on device
168+
// Common use: app-icon.js with heatshrink-compressed image data
169+
// Pattern from core/js/appinfo.js getFiles() and README.md
116170
var isText = storageEntry.evaluate || isTextPath(url);
117171

118172
if (isText) {
119173
// Convert to text
120174
sourceContents[url] = new TextDecoder().decode(new Uint8Array(file.data));
121175
} else {
122-
// Convert ArrayBuffer to binary string
176+
// SOURCE: core/js/appinfo.js asJSExpr() - convert ArrayBuffer to binary string
177+
// Convert ArrayBuffer to binary string (for images, etc.)
123178
var a = new Uint8Array(file.data);
124179
var s = "";
125180
for (var i=0; i<a.length; i++) s += String.fromCharCode(a[i]);
@@ -131,13 +186,50 @@ function installFromFiles() {
131186
}
132187
});
133188

189+
// SOURCE: README.md metadata.json - handle data[] files with url/content
190+
// Process data[] files that have url or content specified (initial data files to upload)
191+
if (app.data && Array.isArray(app.data)) {
192+
app.data.forEach(dataEntry => {
193+
// Skip entries that are just tracking patterns (wildcard, or name-only without url/content)
194+
if (dataEntry.wildcard) return;
195+
if (!dataEntry.url && !dataEntry.content) return;
196+
197+
var url = dataEntry.url || dataEntry.name;
198+
var file = fileMap[url];
199+
200+
if (!file && !dataEntry.content) {
201+
console.warn(`Data file not found: ${url}`);
202+
// Don't add to missingFiles - data files are optional
203+
return;
204+
}
205+
206+
if (file) {
207+
try {
208+
var isText = dataEntry.evaluate || isTextPath(url);
209+
if (isText) {
210+
sourceContents[url] = new TextDecoder().decode(new Uint8Array(file.data));
211+
} else {
212+
var a = new Uint8Array(file.data);
213+
var s = "";
214+
for (var i=0; i<a.length; i++) s += String.fromCharCode(a[i]);
215+
sourceContents[url] = s;
216+
}
217+
} catch(err) {
218+
console.error(`Failed to read data file ${url}:`, err);
219+
}
220+
}
221+
});
222+
}
223+
134224
if (missingFiles.length > 0) {
135225
Progress.hide({sticky:true});
136226
showToast('Missing or unreadable files: ' + missingFiles.join(', '), 'error');
137227
return resolve();
138228
}
139229

140-
// Build app object with inline contents
230+
// SOURCE: core/js/appinfo.js createAppJSON() - build app object with inline contents
231+
// Build app object with inline contents for upload
232+
// This matches the structure expected by Comms.uploadApp
141233
var appForUpload = {
142234
id: app.id,
143235
name: app.name,
@@ -153,32 +245,111 @@ function installFromFiles() {
153245
name: storageEntry.name,
154246
url: storageEntry.url,
155247
content: content,
156-
evaluate: !!storageEntry.evaluate,
157-
noOverwrite: !!storageEntry.noOverwrite,
158-
dataFile: !!storageEntry.dataFile,
159-
supports: storageEntry.supports
248+
evaluate: !!storageEntry.evaluate, // JS expression to eval on device
249+
noOverwrite: !!storageEntry.noOverwrite, // Don't overwrite if exists (checked below)
250+
dataFile: !!storageEntry.dataFile, // File written by app (not uploaded)
251+
supports: storageEntry.supports // Device compatibility (already filtered above)
160252
};
161253
}).filter(Boolean),
162-
data: app.data || []
254+
data: app.data || [] // Files app writes - tracked for uninstall, optionally uploaded if url/content provided
163255
};
256+
257+
// Add data[] files with content to storage for upload
258+
if (app.data && Array.isArray(app.data)) {
259+
app.data.forEach(dataEntry => {
260+
// Only add if we have content and it's meant to be uploaded initially
261+
if (!dataEntry.url && !dataEntry.content) return;
262+
if (dataEntry.wildcard) return;
263+
264+
var url = dataEntry.url || dataEntry.name;
265+
var content = dataEntry.content || sourceContents[url];
266+
if (content === undefined) return;
267+
268+
appForUpload.storage.push({
269+
name: dataEntry.name,
270+
url: dataEntry.url,
271+
content: content,
272+
evaluate: !!dataEntry.evaluate,
273+
noOverwrite: true, // Data files should not overwrite by default
274+
dataFile: true,
275+
storageFile: !!dataEntry.storageFile
276+
});
277+
});
278+
}
164279

165-
return Promise.resolve(appForUpload)
166-
.then(appForUpload => {
167-
// Delete existing app if installed using the same pattern as updateApp
280+
// SOURCE: core/js/index.js updateApp() lines 963-978
281+
// Check for noOverwrite files that exist on device
282+
var noOverwriteChecks = Promise.resolve();
283+
var filesToCheck = appForUpload.storage.filter(f => f.noOverwrite);
284+
285+
if (filesToCheck.length > 0) {
286+
Progress.hide({sticky:true});
287+
Progress.show({title:`Checking existing files...`});
288+
289+
// Build a single command to check all noOverwrite files at once
290+
var checkCmd = filesToCheck.map(f =>
291+
`require('Storage').read(${JSON.stringify(f.name)})!==undefined`
292+
).join(',');
293+
294+
noOverwriteChecks = new Promise((resolveCheck, rejectCheck) => {
295+
Comms.eval(`[${checkCmd}]`, (result, err) => {
296+
if (err) {
297+
console.warn('Error checking noOverwrite files:', err);
298+
resolveCheck(); // Continue anyway
299+
return;
300+
}
301+
try {
302+
var existsArray = result;
303+
// Remove files that already exist from the upload list
304+
filesToCheck.forEach((file, idx) => {
305+
if (existsArray[idx]) {
306+
console.log(`Skipping ${file.name} (noOverwrite and already exists)`);
307+
var fileIdx = appForUpload.storage.indexOf(file);
308+
if (fileIdx !== -1) {
309+
appForUpload.storage.splice(fileIdx, 1);
310+
}
311+
}
312+
});
313+
resolveCheck();
314+
} catch(e) {
315+
console.warn('Error parsing noOverwrite check results:', e);
316+
resolveCheck(); // Continue anyway
317+
}
318+
});
319+
});
320+
}
321+
322+
// SOURCE: core/js/index.js updateApp() lines 963-978
323+
// Delete existing app if installed using the same pattern as updateApp
324+
return noOverwriteChecks.then(appForUpload => {
325+
// SOURCE: core/js/index.js updateApp() line 963
326+
// Check if app is already installed
168327
Progress.hide({sticky:true});
169328
Progress.show({title:`Checking for existing version...`});
170329
return Comms.getAppInfo(appForUpload)
171330
.then(remove => {
172331
if (!remove) return appForUpload; // not installed
173332
Progress.hide({sticky:true});
174333
Progress.show({title:`Removing old version...`});
175-
// containsFileList:true so we trust the watch's file list
334+
// SOURCE: core/js/index.js updateApp() line 978
335+
// containsFileList:true tells removeApp to trust the watch's file list
336+
// This matches the updateApp pattern exactly
176337
return Comms.removeApp(remove, {containsFileList:true}).then(() => appForUpload);
177338
});
178339
}).then(appForUpload => {
340+
// SOURCE: core/js/index.js uploadApp() line 840 and updateApp() line 983
179341
// Upload using the standard pipeline
180342
Progress.hide({sticky:true});
181343
Progress.show({title:`Installing ${appForUpload.name}...`, sticky:true});
344+
// Pass device and language options like uploadApp/updateApp do
345+
// NOTE: Comms.uploadApp handles:
346+
// - Creating .info file via AppInfo.createAppJSON
347+
// - Minification/pretokenisation via AppInfo.parseJS if settings.minify=true
348+
// - Module resolution
349+
// - Language translation
350+
// - File upload commands
351+
// - Progress updates
352+
// - Final success message via showUploadFinished()
182353
return Comms.uploadApp(appForUpload, {device: device, language: LANGUAGE});
183354
}).then(() => {
184355
Progress.hide({sticky:true});
@@ -196,17 +367,18 @@ function installFromFiles() {
196367
console.error(err);
197368
resolve();
198369
});
199-
}, 100); // Small delay to ensure all file callbacks complete
370+
}, 1200); // Debounce to gather all files (Android-friendly)
200371
});
201372
});
202373
}
203374

204-
// Attach UI handler to the button
375+
// Attach UI handler to the button on window load
205376
window.addEventListener('load', (event) => {
206377
var btn = document.getElementById("installappfromfiles");
207378
if (!btn) return;
208379
btn.addEventListener("click", event => {
380+
// SOURCE: core/js/index.js uploadApp/updateApp pattern
381+
// Wrap in startOperation for consistent UI feedback
209382
startOperation({name:"Install App from Files"}, () => installFromFiles());
210383
});
211-
});
212-
384+
});

0 commit comments

Comments
 (0)