Skip to content

Commit 67f9a26

Browse files
committed
apploader - add install app from files option
1 parent 0bfa101 commit 67f9a26

File tree

2 files changed

+215
-0
lines changed

2 files changed

+215
-0
lines changed

index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ <h3>Utilities</h3>
146146
<button class="btn tooltip" id="installdefault" data-tooltip="Delete everything, install default apps">Install default apps</button>
147147
<button class="btn tooltip" id="installfavourite" data-tooltip="Delete everything, install your favourites">Install favourite apps</button>
148148
<button class="btn tooltip" id="defaultbanglesettings" data-tooltip="Reset your Bangle's settings to the defaults">Reset Settings</button>
149+
</p><p>
150+
<button class="btn tooltip" id="installappfromfiles" data-tooltip="Install an app by selecting its files">Install App from Files</button>
149151
</p><p>
150152
<button class="btn tooltip" id="newGithubIssue" data-tooltip="Create a new issue on GitHub">New issue on GitHub</button>
151153
</p>
@@ -232,6 +234,7 @@ <h3>Device info</h3>
232234
<script src="loader.js"></script>
233235
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js"></script> <!-- for backup.js -->
234236
<script src="backup.js"></script>
237+
<script src="install_from_files.js"></script>
235238
<script src="core/js/ui.js"></script>
236239
<script src="core/js/comms.js"></script>
237240
<script src="core/js/appinfo.js"></script>

install_from_files.js

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* Apploader - Install App from selected files
3+
*/
4+
function installFromFiles() {
5+
return new Promise(resolve => {
6+
// Ask user to select all files at once (multi-select)
7+
Espruino.Core.Utils.fileOpenDialog({
8+
id:"installappfiles",
9+
type:"arraybuffer",
10+
multi:true,
11+
mimeType:"*/*"}, function(fileData, mimeType, fileName) {
12+
13+
// Collect all files (callback is invoked once per file when multi:true)
14+
if (!installFromFiles.fileCollection) {
15+
installFromFiles.fileCollection = {
16+
files: [],
17+
count: 0
18+
};
19+
}
20+
21+
// Store this file
22+
installFromFiles.fileCollection.files.push({
23+
name: fileName,
24+
data: fileData,
25+
mimeType: mimeType
26+
});
27+
installFromFiles.fileCollection.count++;
28+
29+
// Use setTimeout to batch-process after all callbacks complete
30+
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();
36+
37+
// Find metadata.json
38+
var metadataFile = files.find(f => f.name === 'metadata.json' || f.name.endsWith('/metadata.json'));
39+
40+
if (!metadataFile) {
41+
showToast('No metadata.json found in selected files', 'error');
42+
return resolve();
43+
}
44+
45+
// Parse metadata.json
46+
var metadata;
47+
try {
48+
var metadataText = new TextDecoder().decode(new Uint8Array(metadataFile.data));
49+
metadata = JSON.parse(metadataText);
50+
} catch(err) {
51+
showToast('Failed to parse metadata.json: ' + err, 'error');
52+
return resolve();
53+
}
54+
55+
if (!metadata.id) {
56+
showToast('metadata.json missing required "id" field', 'error');
57+
return resolve();
58+
}
59+
60+
if (!metadata.storage || !Array.isArray(metadata.storage)) {
61+
showToast('metadata.json missing or invalid "storage" array', 'error');
62+
return resolve();
63+
}
64+
65+
// Build file map by name (both simple filename and full path)
66+
var fileMap = {};
67+
files.forEach(f => {
68+
var simpleName = f.name.split('/').pop();
69+
fileMap[simpleName] = f;
70+
fileMap[f.name] = f;
71+
});
72+
73+
// Build app object from metadata
74+
var app = {
75+
id: metadata.id,
76+
name: metadata.name || metadata.id,
77+
version: metadata.version || "0.0.0",
78+
type: metadata.type,
79+
tags: metadata.tags,
80+
sortorder: metadata.sortorder,
81+
storage: metadata.storage,
82+
data: metadata.data || []
83+
};
84+
85+
// Determine number of files that will actually be transferred
86+
var transferCount = app.storage.filter(storageEntry => {
87+
var url = storageEntry.url || storageEntry.name;
88+
return fileMap[url];
89+
}).length;
90+
91+
// Confirm with user, listing transfer count instead of raw selected file count
92+
showPrompt("Install App from Files",
93+
`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.`
94+
).then(() => {
95+
Progress.show({title:`Reading files...`});
96+
97+
var sourceContents = {}; // url -> content
98+
var missingFiles = [];
99+
100+
function isTextPath(p){
101+
return /\.(js|json|txt|md|html|css)$/i.test(p);
102+
}
103+
104+
// Process all files referenced in storage
105+
app.storage.forEach(storageEntry => {
106+
var url = storageEntry.url || storageEntry.name;
107+
var file = fileMap[url];
108+
109+
if (!file) {
110+
console.warn(`File not found: ${url}`);
111+
missingFiles.push(url);
112+
return;
113+
}
114+
115+
try {
116+
var isText = storageEntry.evaluate || isTextPath(url);
117+
118+
if (isText) {
119+
// Convert to text
120+
sourceContents[url] = new TextDecoder().decode(new Uint8Array(file.data));
121+
} else {
122+
// Convert ArrayBuffer to binary string
123+
var a = new Uint8Array(file.data);
124+
var s = "";
125+
for (var i=0; i<a.length; i++) s += String.fromCharCode(a[i]);
126+
sourceContents[url] = s;
127+
}
128+
} catch(err) {
129+
console.error(`Failed to read ${url}:`, err);
130+
missingFiles.push(url);
131+
}
132+
});
133+
134+
if (missingFiles.length > 0) {
135+
Progress.hide({sticky:true});
136+
showToast('Missing or unreadable files: ' + missingFiles.join(', '), 'error');
137+
return resolve();
138+
}
139+
140+
// Build app object with inline contents
141+
var appForUpload = {
142+
id: app.id,
143+
name: app.name,
144+
version: app.version,
145+
type: app.type,
146+
tags: app.tags,
147+
sortorder: app.sortorder,
148+
storage: app.storage.map(storageEntry => {
149+
var url = storageEntry.url || storageEntry.name;
150+
var content = sourceContents[url];
151+
if (content === undefined) return null;
152+
return {
153+
name: storageEntry.name,
154+
url: storageEntry.url,
155+
content: content,
156+
evaluate: !!storageEntry.evaluate,
157+
noOverwrite: !!storageEntry.noOverwrite,
158+
dataFile: !!storageEntry.dataFile,
159+
supports: storageEntry.supports
160+
};
161+
}).filter(Boolean),
162+
data: app.data || []
163+
};
164+
165+
return Promise.resolve(appForUpload)
166+
.then(appForUpload => {
167+
// Delete existing app if installed using the same pattern as updateApp
168+
Progress.hide({sticky:true});
169+
Progress.show({title:`Checking for existing version...`});
170+
return Comms.getAppInfo(appForUpload)
171+
.then(remove => {
172+
if (!remove) return appForUpload; // not installed
173+
Progress.hide({sticky:true});
174+
Progress.show({title:`Removing old version...`});
175+
// containsFileList:true so we trust the watch's file list
176+
return Comms.removeApp(remove, {containsFileList:true}).then(() => appForUpload);
177+
});
178+
}).then(appForUpload => {
179+
// Upload using the standard pipeline
180+
Progress.hide({sticky:true});
181+
Progress.show({title:`Installing ${appForUpload.name}...`, sticky:true});
182+
return Comms.uploadApp(appForUpload, {device: device, language: LANGUAGE});
183+
}).then(() => {
184+
Progress.hide({sticky:true});
185+
showToast(`App "${app.name}" installed successfully!`, 'success');
186+
resolve();
187+
}).catch(err => {
188+
Progress.hide({sticky:true});
189+
showToast('Install failed: ' + err, 'error');
190+
console.error(err);
191+
resolve();
192+
});
193+
}).catch(err => {
194+
Progress.hide({sticky:true});
195+
showToast('Install cancelled or failed: ' + err, 'error');
196+
console.error(err);
197+
resolve();
198+
});
199+
}, 100); // Small delay to ensure all file callbacks complete
200+
});
201+
});
202+
}
203+
204+
// Attach UI handler to the button
205+
window.addEventListener('load', (event) => {
206+
var btn = document.getElementById("installappfromfiles");
207+
if (!btn) return;
208+
btn.addEventListener("click", event => {
209+
startOperation({name:"Install App from Files"}, () => installFromFiles());
210+
});
211+
});
212+

0 commit comments

Comments
 (0)