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 */
47function 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 / \. ( j s | j s o n | t x t | m d | h t m l | c s s ) $ / 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
205376window . 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