Added customised app loading

pull/5/head
Gordon Williams 2019-11-06 17:25:02 +00:00
parent 2f9c24d433
commit eb71f80ef2
13 changed files with 297 additions and 32 deletions

84
README.md Normal file
View File

@ -0,0 +1,84 @@
Bangle.js App Loader (and Apps)
================================
### How does it work?
* A list of apps is in `apps.json`
* Each element references an app in `apps/` which is uploaded
* When it starts, BangleAppLoader checks the JSON and compares
it with the files it sees in the watch's storage.
* To upload an app, BangleAppLoader checks the files that are
listed in `apps.json`, loads them, and sends them over Web Bluetooth.
### What filenames are used
Filenames in storage are limited to 8 characters. To
easily distinguish between file types, we use the following:
* `+stuff` is JSON for an app
* `*stuff` is an image
* `-stuff` is JS code
* `=stuff` is JS code for stuff that is run at boot time - eg. handling settings or creating widgets on the clock screen
### Developing your own app
* Start writing your code in the IDE, with `Save on Send` in settings set to
the *default* of `To RAM`
* When you have your app as you want it, add it as a file in `apps/`, lets assume `apps/my-great-app.js`
* Come up with a unique 7 character name, we'll assume `7chname`
* Create `apps/my-great-app.png` as a 48px icon
* Use http://www.espruino.com/Image+Converter to create as 1 bit, 4 bit or 8 bit Web Palette "Image String" and save it as `apps/my-great-app-icon.js`
* Create an entry in `apps/my-great-app.json` as follows:
```
{
"name":"Short Name",
"icon":"*7chname",
"src":"-7chname"
}
```
* Create an entry in `apps.json` as follows:
```
{ "id": "7chname",
"name": "My app's human readable name",
"icon": "my-great-app.png",
"description": "A detailed description of my great app",
"tags": "",
"storage": [
{"name":"+7chname","url":"my-great-app.json"},
{"name":"-7chname","url":"my-great-app.js"},
{"name":"*7chname","url":"my-great-app.js","evaluate":true}
]
},
```
### `apps.json` format
```
{ "id": "appid", // 7 character app id
"name": "Readable name", // readable name
"icon": "icon.png", // icon in apps/
"description": "...", // long description
"tags": "", // comma separated tag list for searching
"custom": "custom.html", // if supplied, apps/custom.html is loaded in an
// iframe, and it must post back an 'app' structure
// like this one with 'storage','name' and 'id' set up
"storage": [ // list of files to add to storage
{"name":"-appid", // filename to use in storage
"url":"", // URL of file to load (currently relative to apps/)
"content":"..." // if supplied, this content is loaded directly
"evaluate":true // if supplied, data isn't quoted into a String before upload
// (eg it's evaluated as JS)
},
]
}
```
### Credits
The majority of icons used for these apps are from [Icons8](https://icons8.com/) - we have a commercial license but icons are also free for Open Source projects.

View File

@ -5,9 +5,9 @@
"description": "T-Rex game in the style of Chrome's offline game",
"tags": "game",
"storage": [
{"name":"+trex","file":"trex.json"},
{"name":"-trex","file":"trex.js"},
{"name":"*trex","file":"trex-icon.js","evaluate":true}
{"name":"+trex","url":"trex.json"},
{"name":"-trex","url":"trex.js"},
{"name":"*trex","url":"trex-icon.js","evaluate":true}
]
},
{ "id": "compass",
@ -16,9 +16,9 @@
"description": "Simple compass that points North",
"tags": "tool,outdoors",
"storage": [
{"name":"+compass","file":"compass.json"},
{"name":"-compass","file":"compass.js"},
{"name":"*compass","file":"compass-icon.js","evaluate":true}
{"name":"+compass","url":"compass.json"},
{"name":"-compass","url":"compass.js"},
{"name":"*compass","url":"compass-icon.js","evaluate":true}
]
},
{ "id": "clock",
@ -27,9 +27,9 @@
"description": "7 segment clock that morphs between minutes and hours",
"tags": "clock",
"storage": [
{"name":"+clock","file":"clock.json"},
{"name":"-clock","file":"clock-morphing.js"},
{"name":"*clock","file":"clock-icon.js","evaluate":true}
{"name":"+clock","url":"clock.json"},
{"name":"-clock","url":"clock-morphing.js"},
{"name":"*clock","url":"clock-icon.js","evaluate":true}
]
},
{ "id": "gpstime",
@ -38,9 +38,9 @@
"description": "Update the Bangle.js's clock based on the time from the GPS receiver",
"tags": "tool",
"storage": [
{"name":"+gpstime","file":"gpstime.json"},
{"name":"-gpstime","file":"gpstime.js"},
{"name":"*gpstime","file":"gpstime-icon.js","evaluate":true}
{"name":"+gpstime","url":"gpstime.json"},
{"name":"-gpstime","url":"gpstime.js"},
{"name":"*gpstime","url":"gpstime-icon.js","evaluate":true}
]
},
{ "id": "openloc",
@ -49,8 +49,8 @@
"description": "Convert your current GPS location to a series of characters",
"tags": "tool,outdoors",
"storage": [
{"name":"+openloc","file":"openlocation.json"},
{"name":"-openloc","file":"openlocation.js","evaluate":true}
{"name":"+openloc","url":"openlocation.json"},
{"name":"-openloc","url":"openlocation.js","evaluate":true}
]
},
{ "id": "speedo",
@ -59,9 +59,9 @@
"description": "Show the current speed according to the GPS",
"tags": "tool,outdoors",
"storage": [
{"name":"+speedo","file":"speedo.json"},
{"name":"-speedo","file":"speedo.js"},
{"name":"*speedo","file":"speedo-icon.js","evaluate":true}
{"name":"+speedo","url":"speedo.json"},
{"name":"-speedo","url":"speedo.js"},
{"name":"*speedo","url":"speedo-icon.js","evaluate":true}
]
},
{ "id": "slevel",
@ -70,9 +70,30 @@
"description": "Show the current angle of the watch, so you can use it to make sure something is absolutely flat",
"tags": "tool",
"storage": [
{"name":"+slevel","file":"spiritlevel.json"},
{"name":"-slevel","file":"spiritlevel.js"},
{"name":"*slevel","file":"spiritlevel-icon.js","evaluate":true}
{"name":"+slevel","url":"spiritlevel.json"},
{"name":"-slevel","url":"spiritlevel.js"},
{"name":"*slevel","url":"spiritlevel-icon.js","evaluate":true}
]
},
{ "id": "sbat",
"name": "Battery Level Widget",
"icon": "widget-battery.png",
"description": "Show the current battery level and charging status in the top right of the clock",
"tags": "widget,battery",
"storage": [
{"name":"=sbat","url":"widget-battery.js"}
]
},
{ "id": "qrcode",
"name": "Custom QR Code",
"icon": "qrcode.png",
"description": "Use this to upload a customised QR code to Bangle.js",
"tags": "",
"custom": "qrcode.html",
"storage": [
{"name":"-qrcode"},
{"name":"+qrcode"},
{"name":"=qrcode"}
]
}
]

65
apps/qrcode.html Normal file
View File

@ -0,0 +1,65 @@
<html>
<head>
<link rel="stylesheet" href="../css/spectre.min.css">
</head>
<body>
<p>Enter a URL: <input type="text" id="url" value="http://espruino.com"></p>
<p>Try your QR Code: <div id="qrcode"></div></p>
<p>Click <button id="upload" class="btn btn-primary">Upload</button></p>
<script src="../lib/qrcode.min.js"></script><!-- https://davidshimjs.github.io/qrcodejs/ -->
<script src="https://espruino.github.io/EspruinoWebTools/heatshrink.js"></script>
<script src="https://espruino.github.io/EspruinoWebTools/imageconverter.js"></script>
<script>
var qrcode = new QRCode("qrcode", {
text: document.getElementById("url").value,
width: 200,
height: 200,
colorDark : "#000000",
colorLight : "#ffffff",
});
document.getElementById("url").addEventListener("change", function() {
qrcode.clear(); // clear the code.
qrcode.makeCode(document.getElementById("url").value); // make another code.
});
document.getElementById("upload").addEventListener("click", function() {
var url = document.getElementById("url").value;
var img = imageconverter.canvastoString(document.getElementsByTagName("canvas")[0],{mode:"1bit",output:"string",compression:true});
var app = `var img = ${img};
var url = ${JSON.stringify(url)};
g.setColor(1,1,1);
g.fillRect(0,0,239,239);
g.drawImage(img,20,20);
g.setFontAlign(0,0);
g.setFont("6x8");
g.setColor(0,0,0);
g.drawString(url,120,230);
g.setColor(1,1,1);
`;
console.log(app);
var json = JSON.stringify({
name:"QR Code",
icon:"*qrcode",
src:"-qrcode"
});
var icon = `require("heatshrink").decompress(atob("mEwgP/AEX8gE8nkAn4FSngCWF6xfYDgIABHAQFPDQXD4YgDApxNDMooFOAQIdDAqIvWfcYA="))`;
window.postMessage({
id : "qrcode",
storage:[
{name:"-qrcode", content:app},
{name:"+qrcode", content:json},
{name:"*qrcode", content:icon, "evaluate":true},
]
});
});
</script>
</body>
</html>

BIN
apps/qrcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

24
apps/widget-battery.js Normal file
View File

@ -0,0 +1,24 @@
(function(){
var img_charge = E.toArrayBuffer(atob("DhgBHOBzgc4HOP////////////////////3/4HgB4AeAHgB4AeAHgB4AeAHg"));
var xpos = WIDGETPOS.tr-64;
WIDGETPOS.tr-=68;
function draw() {
var s = 63;
var x = xpos, y = 0;
g.clearRect(x,y,x+s,y+23);
if (Bangle.isCharging()) {
g.drawImage(img_charge,x,y);
x+=16;
s-=16;
}
g.setColor(1,1,1);
g.fillRect(x,y+2,x+s-4,y+21);
g.clearRect(x+2,y+4,x+s-6,y+19);
g.fillRect(x+s-3,y+10,x+s,y+14);
g.fillRect(x+4,y+6,x+4+E.getBattery()*(s-12)/100,y+17);
g.setColor(1,1,1);
}
Bangle.on('charging',function(charging) { draw(); g.flip(); if(charging)Bangle.buzz(); });
WIDGETS["battery"]={draw:draw};
})()

BIN
apps/widget-battery.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

View File

@ -17,9 +17,17 @@ var Comms = {
uploadApp : app => {
return new Promise((resolve,reject) => {
// Load all files
Promise.all(app.storage.map(storageFile => httpGet("apps/"+storageFile.file)
// map each file to a command to load into storage
.then(contents=>`\x10require('Storage').write(${toJS(storageFile.name)},${storageFile.evaluate ? contents.trim() : toJS(contents)});`)))
Promise.all(app.storage.map(storageFile => {
var promise;
if (storageFile.content)
promise = Promise.resolve(storageFile.content);
else if (storageFile.url)
promise = httpGet("apps/"+storageFile.url);
else promise = Promise.resolve();
// then map each file to a command to load into storage
return promise.then(contents =>
contents?`\x10require('Storage').write(${toJS(storageFile.name)},${storageFile.evaluate ? contents.trim() : toJS(contents)});`:"")
})) // now we just have a list of commands...
.then((fileContents) => {
fileContents = fileContents.join("\n")+"\n";
console.log("uploadApp",fileContents);

1
css/spectre-exp.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
css/spectre-icons.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
css/spectre.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -3,9 +3,9 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://unpkg.com/spectre.css/dist/spectre.min.css">
<link rel="stylesheet" href="https://unpkg.com/spectre.css/dist/spectre-exp.min.css">
<link rel="stylesheet" href="https://unpkg.com/spectre.css/dist/spectre-icons.min.css">
<link rel="stylesheet" href="css/spectre.min.css">
<link rel="stylesheet" href="css/spectre-exp.min.css">
<link rel="stylesheet" href="css/spectre-icons.min.css">
<title>Bangle.js loader</title>
<style>
.navbar { background-color: #5755d9; padding: 0.5em 1em 0.5em 1em; }
@ -91,8 +91,6 @@
<p>Using <a href="https://espruino.com/">Espruino</a>, Icons from <a href="https://icons8.com/">icons8.com</a></p>
</div>
<script src="https://www.puck-js.com/puck.js"></script>
<script src="utils.js"></script>
<script src="comms.js"></script>

View File

@ -11,8 +11,12 @@ httpGet("apps.json").then(apps=>{
// =========================================== Top Navigation
function showToast(message, type) {
// toast-primary, toast-success, toast-warning or toast-error
var style = "toast-primary";
if (type=="success") style = "toast-success";
else if (type=="error") style = "toast-error";
else if (type!==undefined) console.log("showToast: unknown toast "+type);
var toastcontainer = document.getElementById("toastcontainer");
var msgDiv = htmlElement(`<div class="toast toast-primary"></div>`);
var msgDiv = htmlElement(`<div class="toast ${style}"></div>`);
msgDiv.innerHTML = message;
toastcontainer.append(msgDiv);
setTimeout(function() {
@ -44,6 +48,7 @@ function showPrompt(title, text) {
document.body.append(modal);
htmlToArray(modal.getElementsByTagName("button")).forEach(button => {
button.addEventListener("click",event => {
event.preventDefault();
var isYes = event.target.getAttribute("isyes")=="1";
if (isYes) resolve();
else reject();
@ -52,6 +57,40 @@ function showPrompt(title, text) {
});
});
}
function handleCustomApp(app) {
return new Promise((resolve,reject) => {
var modal = htmlElement(`<div class="modal active">
<a href="#close" class="modal-overlay " aria-label="Close"></a>
<div class="modal-container" style="height:100%">
<div class="modal-header">
<a href="#close" class="btn btn-clear float-right" aria-label="Close"></a>
<div class="modal-title h5">${escapeHtml(app.name)}</div>
</div>
<div class="modal-body" style="height:100%">
<div class="content" style="height:100%">
<iframe src="apps/${app.custom}" style="width:100%;height:100%;border:0px;">
</div>
</div>
</div>
</div>`);
document.body.append(modal);
htmlToArray(modal.getElementsByTagName("a")).forEach(button => {
button.addEventListener("click",event => {
event.preventDefault();
modal.remove();
reject("Window closed");
});
});
var iframe = modal.getElementsByTagName("iframe")[0];
iframe.contentWindow.addEventListener("message", function(event) {
var app = event.data;
console.log("Received custom app", app);
modal.remove();
Comms.uploadApp(app).then(resolve,reject);
}, false);
});
}
// =========================================== Top Navigation
function showTab(tabname) {
htmlToArray(document.querySelectorAll("#tab-navigate .tab-item")).forEach(tab => {
@ -67,7 +106,13 @@ function showTab(tabname) {
// =========================================== Library
function refreshLibrary() {
var panelbody = document.querySelector("#librarycontainer .panel-body");
panelbody.innerHTML = appJSON.map((app,idx) => `<div class="tile column col-6 col-sm-12 col-xs-12">
panelbody.innerHTML = appJSON.map((app,idx) => {
var icon = "icon-upload";
if (app.custom)
icon = "icon-menu";
if (appsInstalled.includes(app.id))
icon = "icon-delete";
return `<div class="tile column col-6 col-sm-12 col-xs-12">
<div class="tile-icon">
<figure class="avatar"><img src="apps/${app.icon?app.icon:"apps/unknown.png"}" alt="${escapeHtml(app.name)}"></figure>
</div>
@ -76,10 +121,10 @@ function refreshLibrary() {
<p class="tile-subtitle">${escapeHtml(app.description)}</p>
</div>
<div class="tile-action">
<button class="btn btn-link btn-action btn-lg"><i class="icon ${appsInstalled.includes(app.id)?"icon-delete":"icon-upload"}" appid="${app.id}"></i></button>
<button class="btn btn-link btn-action btn-lg"><i class="icon ${icon}" appid="${app.id}"></i></button>
</div>
</div>
`).join("");
`;}).join("");
// set badge up top
var tab = document.querySelector("#tab-librarycontainer a");
tab.classList.add("badge");
@ -104,6 +149,22 @@ function refreshLibrary() {
icon.classList.remove("loading");
icon.classList.add("icon-upload");
});
} else if (icon.classList.contains("icon-menu")) {
if (app.custom) {
icon.classList.remove("icon-menu");
icon.classList.add("loading");
handleCustomApp(app).then(() => {
appsInstalled.push(app.id);
showToast(app.name+" Uploaded!", "success");
icon.classList.remove("loading");
icon.classList.add("icon-delete");
refreshMyApps();
}).catch(err => {
showToast("Customise failed, "+err, "error");
icon.classList.remove("loading");
icon.classList.add("icon-menu");
});
}
} else {
icon.classList.remove("icon-delete");
icon.classList.add("loading");

1
lib/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long