let appJSON = []; // List of apps and info from apps.json let appsInstalled = []; // list of app JSON let appSortInfo = {}; // list of data to sort by, from appdates.csv { created, modified } let files = []; // list of files on Bangle let DEFAULTSETTINGS = { pretokenise : true, favourites : ["boot","launch","setting"] }; let SETTINGS = JSON.parse(JSON.stringify(DEFAULTSETTINGS)); // clone httpGet("apps.json").then(apps=>{ try { appJSON = JSON.parse(apps); } catch(e) { console.log(e); showToast("App List Corrupted","error"); } refreshLibrary(); refreshFilter(); }); httpGet("appdates.csv").then(csv=>{ document.querySelector(".sort-nav").classList.remove("hidden"); csv.split("\n").forEach(line=>{ let l = line.split(","); appSortInfo[l[0]] = { created : Date.parse(l[1]), modified : Date.parse(l[2]) }; }); }).catch(err=>{ console.log("No recent.csv - app sort disabled"); }); // =========================================== Top Navigation function showChangeLog(appid) { let app = appNameToApp(appid); function show(contents) { showPrompt(app.name+" Change Log",contents,{ok:true}).catch(()=>{}); } httpGet(`apps/${appid}/ChangeLog`). then(show).catch(()=>show("No Change Log available")); } function showReadme(appid) { let app = appNameToApp(appid); let appPath = `apps/${appid}/`; let markedOptions = { baseUrl : appPath }; function show(contents) { if (!contents) return; showPrompt(app.name + " Documentation", marked(contents, markedOptions), {ok: true}, false).catch(() => {}); } httpGet(appPath+app.readme).then(show).catch(()=>show("Failed to load README.")); } function getAppDescription(app) { let appPath = `apps/${app.id}/`; let markedOptions = { baseUrl : appPath }; return marked(app.description, markedOptions); } function handleCustomApp(appTemplate) { // Pops up an IFRAME that allows an app to be customised if (!appTemplate.custom) throw new Error("App doesn't have custom HTML"); return new Promise((resolve,reject) => { let modal = htmlElement(`
`); document.body.append(modal); htmlToArray(modal.getElementsByTagName("a")).forEach(button => { button.addEventListener("click",event => { event.preventDefault(); modal.remove(); reject("Window closed"); }); }); let iframe = modal.getElementsByTagName("iframe")[0]; iframe.contentWindow.addEventListener("message", function(event) { let appFiles = event.data; let app = JSON.parse(JSON.stringify(appTemplate)); // clone template // copy extra keys from appFiles Object.keys(appFiles).forEach(k => { if (k!="storage") app[k] = appFiles[k] }); appFiles.storage.forEach(f => { app.storage = app.storage.filter(s=>s.name!=f.name); // remove existing item app.storage.push(f); // add new }); console.log("Received custom app", app); modal.remove(); checkDependencies(app) .then(()=>Comms.uploadApp(app)) .then(()=>{ Progress.hide({sticky:true}); resolve(); }).catch(e => { Progress.hide({sticky:true}); reject(e); }); }, false); }); } function handleAppInterface(app) { // IFRAME interface window that can be used to get data from the app if (!app.interface) throw new Error("App doesn't have interface HTML"); return new Promise((resolve,reject) => { let modal = htmlElement(``); document.body.append(modal); htmlToArray(modal.getElementsByTagName("a")).forEach(button => { button.addEventListener("click",event => { event.preventDefault(); modal.remove(); //reject("Window closed"); }); }); let iframe = modal.getElementsByTagName("iframe")[0]; iframe.onload = function() { let iwin = iframe.contentWindow; iwin.addEventListener("message", function(event) { let msg = event.data; if (msg.type=="eval") { Puck.eval(msg.data, function(result) { iwin.postMessage({ type : "evalrsp", data : result, id : msg.id }); }); } else if (msg.type=="write") { Puck.write(msg.data, function(result) { iwin.postMessage({ type : "writersp", data : result, id : msg.id }); }); } else if (msg.type=="readstoragefile") { Comms.readStorageFile(msg.data/*filename*/).then(function(result) { iwin.postMessage({ type : "readstoragefilersp", data : result, id : msg.id }); }); } }, false); iwin.postMessage({type:"init"}); }; iframe.src = `apps/${app.id}/${app.interface}`; }); } function changeAppFavourite(favourite, app) { let favourites = SETTINGS.favourites; if (favourite) { SETTINGS.favourites = SETTINGS.favourites.concat([app.id]); } else { if ([ "boot","setting"].includes(app.id)) { showToast(app.name + ' is required, can\'t remove it' , 'warning'); }else { SETTINGS.favourites = SETTINGS.favourites.filter(e => e != app.id); } } saveSettings(); refreshLibrary(); } // =========================================== Top Navigation function showTab(tabname) { htmlToArray(document.querySelectorAll("#tab-navigate .tab-item")).forEach(tab => { tab.classList.remove("active"); }); htmlToArray(document.querySelectorAll(".bangle-tab")).forEach(tab => { tab.style.display = "none"; }); document.getElementById("tab-"+tabname).classList.add("active"); document.getElementById(tabname).style.display = "inherit"; } // =========================================== Library // Can't use chip.attributes.filterid.value here because Safari/Apple's WebView doesn't handle it let chips = Array.from(document.querySelectorAll('.filter-nav .chip')).map(chip => chip.getAttribute("filterid")); let hash = window.location.hash ? window.location.hash.slice(1) : ''; let activeFilter = ~chips.indexOf(hash) ? hash : ''; let activeSort = ''; let currentSearch = activeFilter ? '' : hash; function refreshFilter(){ let filtersContainer = document.querySelector("#librarycontainer .filter-nav"); filtersContainer.querySelector('.active').classList.remove('active'); if(activeFilter) filtersContainer.querySelector('.chip[filterid="'+activeFilter+'"]').classList.add('active'); else filtersContainer.querySelector('.chip[filterid]').classList.add('active'); } function refreshSort(){ let sortContainer = document.querySelector("#librarycontainer .sort-nav"); sortContainer.querySelector('.active').classList.remove('active'); if(activeSort) sortContainer.querySelector('.chip[sortid="'+activeSort+'"]').classList.add('active'); else sortContainer.querySelector('.chip[sortid]').classList.add('active'); } function refreshLibrary() { let panelbody = document.querySelector("#librarycontainer .panel-body"); let visibleApps = appJSON.slice(); // clone so we don't mess with the original let favourites = SETTINGS.favourites; if (activeFilter) { if ( activeFilter == "favourites" ) { visibleApps = visibleApps.filter(app => app.id && (favourites.filter( e => e == app.id).length)); } else { visibleApps = visibleApps.filter(app => app.tags && app.tags.split(',').includes(activeFilter)); } } if (currentSearch) { visibleApps = visibleApps.filter(app => app.name.toLowerCase().includes(currentSearch) || app.tags.includes(currentSearch)); } visibleApps.sort(appSorter); if (activeSort) { if (activeSort=="created" || activeSort=="modified") { visibleApps = visibleApps.sort((a,b) => appSortInfo[b.id][activeSort] - appSortInfo[a.id][activeSort]); } else throw new Error("Unknown sort type "+activeSort); } panelbody.innerHTML = visibleApps.map((app,idx) => { let appInstalled = appsInstalled.find(a=>a.id==app.id); let version = getVersionInfo(app, appInstalled); let versionInfo = version.text; if (versionInfo) versionInfo = " ("+versionInfo+")"; let readme = `Read more...`; let favourite = favourites.find(e => e == app.id); let username = "espruino"; let githubMatch = window.location.href.match(/\/(\w+)\.github\.io/); if(githubMatch) username = githubMatch[1]; let url = `https://github.com/${username}/BangleApps/tree/master/apps/${app.id}`; return ` `;}).join(""); // set badge up top let tab = document.querySelector("#tab-librarycontainer a"); tab.classList.add("badge"); tab.setAttribute("data-badge", appJSON.length); htmlToArray(panelbody.getElementsByTagName("button")).forEach(button => { button.addEventListener("click",event => { let button = event.currentTarget; let icon = button.firstChild; let appid = button.getAttribute("appid"); let app = appNameToApp(appid); if (!app) throw new Error("App "+appid+" not found"); // check icon to figure out what we should do if (icon.classList.contains("icon-share")) { // emulator let file = app.storage.find(f=>f.name.endsWith('.js')); if (!file) { console.error("No entrypoint found for "+appid); return; } let baseurl = window.location.href; baseurl = baseurl.substr(0,baseurl.lastIndexOf("/")); let url = baseurl+"/apps/"+app.id+"/"+file.url; window.open(`https://espruino.com/ide/emulator.html?codeurl=${url}&upload`); } else if (icon.classList.contains("icon-upload")) { // upload icon.classList.remove("icon-upload"); icon.classList.add("loading"); uploadApp(app); } else if (icon.classList.contains("icon-menu")) { // custom HTML update icon.classList.remove("icon-menu"); icon.classList.add("loading"); customApp(app); } else if (icon.classList.contains("icon-delete")) { // Remove app icon.classList.remove("icon-delete"); icon.classList.add("loading"); removeApp(app); } else if (icon.classList.contains("icon-refresh")) { // Update app icon.classList.remove("icon-refresh"); icon.classList.add("loading"); updateApp(app); } else if (icon.classList.contains("icon-download")) { handleAppInterface(app); } else if ( button.innerText == String.fromCharCode(0x2661)) { changeAppFavourite(true, app); } else if ( button.innerText == String.fromCharCode(0x2665) ) { changeAppFavourite(false, app); } }); }); } refreshFilter(); refreshLibrary(); // =========================================== My Apps function uploadApp(app) { return getInstalledApps().then(()=>{ if (appsInstalled.some(i => i.id === app.id)) { return updateApp(app); } checkDependencies(app) .then(()=>Comms.uploadApp(app)) .then((appJSON) => { Progress.hide({ sticky: true }); if (appJSON) { appsInstalled.push(appJSON); } showToast(app.name + ' Uploaded!', 'success'); }).catch(err => { Progress.hide({ sticky: true }); showToast('Upload failed, ' + err, 'error'); }).finally(()=>{ refreshMyApps(); refreshLibrary(); }); }).catch(err => { showToast("Device connection failed, "+err,"error"); }); } function removeApp(app) { return showPrompt("Delete","Really remove '"+app.name+"'?").then(() => { return getInstalledApps().then(()=>{ // a = from appid.info, app = from apps.json return Comms.removeApp(appsInstalled.find(a => a.id === app.id)); }); }).then(()=>{ appsInstalled = appsInstalled.filter(a=>a.id!=app.id); showToast(app.name+" removed successfully","success"); refreshMyApps(); refreshLibrary(); }, err=>{ showToast(app.name+" removal failed, "+err,"error"); }); } function customApp(app) { return handleCustomApp(app).then((appJSON) => { if (appJSON) appsInstalled.push(appJSON); showToast(app.name+" Uploaded!", "success"); refreshMyApps(); refreshLibrary(); }).catch(err => { showToast("Customise failed, "+err, "error"); refreshMyApps(); refreshLibrary(); }); } /// check for dependencies the app needs and install them if required function checkDependencies(app, uploadOptions) { let promise = Promise.resolve(); if (app.dependencies) { Object.keys(app.dependencies).forEach(dependency=>{ if (app.dependencies[dependency]!="type") throw new Error("Only supporting dependencies on app types right now"); console.log(`Searching for dependency on app type '${dependency}'`); let found = appsInstalled.find(app=>app.type==dependency); if (found) console.log(`Found dependency in installed app '${found.id}'`); else { let foundApps = appJSON.filter(app=>app.type==dependency); if (!foundApps.length) throw new Error(`Dependency of '${dependency}' listed, but nothing satisfies it!`); console.log(`Apps ${foundApps.map(f=>`'${f.id}'`).join("/")} implement '${dependency}'`); found = foundApps[0]; // choose first app in list console.log(`Dependency not installed. Installing app id '${found.id}'`); promise = promise.then(()=>new Promise((resolve,reject)=>{ console.log(`Install dependency '${dependency}':'${found.id}'`); return Comms.uploadApp(found).then(appJSON => { if (appJSON) appsInstalled.push(appJSON); }); })); } }); } return promise; } function updateApp(app) { if (app.custom) return customApp(app); return getInstalledApps().then(() => { // a = from appid.info, app = from apps.json let remove = appsInstalled.find(a => a.id === app.id); // no need to remove files which will be overwritten anyway remove.files = remove.files.split(',') .filter(f => f !== app.id + '.info') .filter(f => !app.storage.some(s => s.name === f)) .join(','); let data = AppInfo.parseDataString(remove.data) if ('data' in app) { // only remove data files which are no longer declared in new app version const removeData = (f) => !app.data.some(d => (d.name || d.wildcard)===f) data.dataFiles = data.dataFiles.filter(removeData) data.storageFiles = data.storageFiles.filter(removeData) } remove.data = AppInfo.makeDataString(data) return Comms.removeApp(remove); }).then(()=>{ showToast(`Updating ${app.name}...`); appsInstalled = appsInstalled.filter(a=>a.id!=app.id); return checkDependencies(app); }).then(()=>Comms.uploadApp(app) ).then((appJSON) => { if (appJSON) appsInstalled.push(appJSON); showToast(app.name+" Updated!", "success"); refreshMyApps(); refreshLibrary(); }, err=>{ showToast(app.name+" update failed, "+err,"error"); refreshMyApps(); refreshLibrary(); }); } function appNameToApp(appName) { let app = appJSON.find(app=>app.id==appName); if (app) return app; /* If app not known, add just one file which is the JSON - so we'll remove it from the menu but may not get rid of all files. */ return { id: appName, name: "Unknown app "+appName, icon: "../unknown.png", description: "Unknown app", storage: [ {name:appName+".info"}], unknown: true, }; } function showLoadingIndicator(id) { let panelbody = document.querySelector(`#${id} .panel-body`); let tab = document.querySelector(`#tab-${id} a`); // set badge up top tab.classList.add("badge"); tab.setAttribute("data-badge", ""); // Loading indicator panelbody.innerHTML = '