├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── LICENSE ├── README.md ├── img ├── copy-icon.png ├── github-icon-sml.png └── github-icon.png ├── js ├── .eslintrc.json ├── appinfo.js ├── comms.js ├── index.js ├── pwa.js ├── service-worker.js ├── ui.js └── utils.js ├── lib ├── .eslintrc.json ├── apploader.js ├── customize.js ├── emulator.js ├── espruinotools.js ├── interface.js ├── marked.min.js └── qrcode.min.js ├── package-lock.json ├── package.json └── tools ├── apploader.js ├── language_render.js ├── language_scan.js └── unifont-15.0.01.ttf /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/espruinotools.js 2 | lib/qrcode.min.js 3 | lib/marked.min.js 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "eslint:recommended", 3 | "globals": { 4 | "Utils" : "writable", // defined in utils.js 5 | "UART" : "readonly", 6 | "Puck" : "readonly", 7 | "device" : "writable", // defined in index.js 8 | "appJSON" : "writable", // defined in index.js 9 | 10 | }, 11 | "rules": { 12 | "indent": [ 13 | "off", 14 | 2, 15 | { 16 | "SwitchCase": 1 17 | } 18 | ], 19 | "no-constant-condition": "off", 20 | "no-empty": ["warn", { "allowEmptyCatch": true }], 21 | "no-global-assign": "off", 22 | "no-inner-declarations": "off", 23 | "no-prototype-builtins": "off", 24 | "no-redeclare": "off", 25 | "no-unreachable": "warn", 26 | "no-cond-assign": "warn", 27 | "no-useless-catch": "warn", 28 | "no-undef": "warn", 29 | "no-unused-vars": ["warn", { "args": "none" } ], 30 | "no-useless-escape": "off", 31 | "no-control-regex" : "off" 32 | }, 33 | reportUnusedDisableDirectives: true, 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v3 12 | - name: Use Node.js 16.x 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 16.x 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Run tests 19 | run: npm test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Gordon Williams, Pur3 Ltd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | EspruinoAppLoaderCore 2 | ===================== 3 | 4 | [![Build Status](https://github.com/espruino/EspruinoAppLoaderCore/actions/workflows/nodejs.yml/badge.svg)](https://github.com/espruino/EspruinoAppLoaderCore/actions/workflows/nodejs.yml) 5 | 6 | This is the code use for both: 7 | 8 | * [Bangle.js](https://banglejs.com/) App Loader : https://github.com/espruino/BangleApps 9 | * [Espruino](http://www.espruino.com/) App Loader : https://github.com/espruino/EspruinoApps 10 | 11 | It forms a simple free "App Store" website that can be used to load applications 12 | onto embedded devices. 13 | 14 | See https://github.com/espruino/BangleApps for more details on usage and the 15 | format of `apps.json`. 16 | -------------------------------------------------------------------------------- /img/copy-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espruino/EspruinoAppLoaderCore/7e7475ba3ab253099481a81e487aaacb9384f974/img/copy-icon.png -------------------------------------------------------------------------------- /img/github-icon-sml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espruino/EspruinoAppLoaderCore/7e7475ba3ab253099481a81e487aaacb9384f974/img/github-icon-sml.png -------------------------------------------------------------------------------- /img/github-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espruino/EspruinoAppLoaderCore/7e7475ba3ab253099481a81e487aaacb9384f974/img/github-icon.png -------------------------------------------------------------------------------- /js/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2020, 4 | "sourceType": "script" 5 | }, 6 | "rules": { 7 | "indent": [ 8 | "warn", 9 | 2, 10 | { 11 | "SwitchCase": 1 12 | } 13 | ], 14 | "no-undef": "warn", 15 | "no-redeclare": "warn", 16 | "no-var": "warn", 17 | "no-global-assign": "off", // we need this to hack around heatshrink/etc for node.js 18 | "no-unused-vars":"off", // we define stuff to use in other scripts 19 | "no-control-regex" : "off" 20 | }, 21 | "env": { 22 | "browser": true, 23 | "node": true 24 | }, 25 | "extends": "eslint:recommended", 26 | "globals": { 27 | "btoa": "writable", 28 | "Espruino": "writable", 29 | 30 | "htmlElement": "readonly", 31 | "Puck": "readonly", 32 | "escapeHtml": "readonly", 33 | "htmlToArray": "readonly", 34 | "heatshrink": "readonly", 35 | "Puck": "readonly", 36 | "Promise": "readonly", 37 | "Comms": "readonly", 38 | "Const": "readonly", 39 | "Progress": "readonly", 40 | "showToast": "readonly", 41 | "showPrompt": "readonly", 42 | "httpGet": "readonly", 43 | "getVersionInfo": "readonly", 44 | "AppInfo": "readonly", 45 | "marked": "readonly", 46 | "appSorter": "readonly", 47 | "Uint8Array" : "readonly", 48 | "SETTINGS" : "readonly", 49 | "DEVICEINFO" : "readonly", 50 | "onFoundDeviceInfo" : "readonly", 51 | "appList" : "readonly", 52 | "debounce" : "readonly", 53 | "globToRegex" : "readonly", 54 | "toJS" : "readonly" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /js/appinfo.js: -------------------------------------------------------------------------------- 1 | // Node.js 2 | if ("undefined"!=typeof module) { 3 | Espruino = require("../lib/espruinotools.js"); 4 | Utils = require("./utils.js"); 5 | heatshrink = require("../../webtools/heatshrink.js"); 6 | } 7 | 8 | // Converts a string into most efficient way to send to Espruino (either json, base64, or compressed base64) 9 | function asJSExpr(txt, options) { 10 | /* options = { 11 | noHeatshrink : bool // don't allow heatshrink - this ensures the result will always be a String (Heatshrink makes an ArrayBuffer) 12 | }*/ 13 | options = options||{}; 14 | let isBinary = false; 15 | for (let i=0;i127) isBinary=true; 18 | } 19 | let json = JSON.stringify(txt); 20 | let b64 = "atob("+JSON.stringify(Espruino.Core.Utils.btoa(txt))+")"; 21 | let js = (isBinary || (b64.length < json.length)) ? b64 : json; 22 | if (txt.length>64 && typeof heatshrink !== "undefined" && !options.noHeatshrink) { 23 | let ua = new Uint8Array(txt.length); 24 | for (let i=0;i\- /\n/]*)([^<>!?]*?)([.<>!?\- /\n/]*)$/); 45 | let textToTranslate = match ? match[2] : value; 46 | // now translate 47 | if (language[app.id] && language[app.id][textToTranslate]) { 48 | return match[1]+language[app.id][textToTranslate]+match[3]; 49 | } else if (language.GLOBAL[textToTranslate]) { 50 | return match[1]+language.GLOBAL[textToTranslate]+match[3]; 51 | } else { 52 | // Unhandled translation... 53 | //console.log("Untranslated ",tokenString); 54 | } 55 | return undefined; // no translation 56 | } 57 | 58 | // Translate any strings in the app that are prefixed with /*LANG*/ 59 | // see https://github.com/espruino/BangleApps/issues/136 60 | function translateJS(options, app, code) { 61 | let lex = Espruino.Core.Utils.getLexer(code); 62 | let outjs = ""; 63 | let lastIdx = 0; 64 | let tok = lex.next(); 65 | while (tok!==undefined) { 66 | let previousString = code.substring(lastIdx, tok.startIdx); 67 | let tokenString = code.substring(tok.startIdx, tok.endIdx); 68 | if (tok.type=="STRING" && previousString.includes("/*LANG*/")) { 69 | previousString=previousString.replace("/*LANG*/",""); 70 | let translation = translateString(options,app, tok.value); 71 | if (translation!==undefined) { 72 | // remap any chars that we don't think we can display in Espruino's 73 | // built in fonts. 74 | translation = Utils.convertStringToISO8859_1(translation); 75 | tokenString = Utils.toJSString(translation); 76 | } 77 | } else if (tok.str.startsWith("`")) { 78 | // it's a tempated String! scan all clauses inside it and re-run on the JS in those 79 | let re = /\$\{[^}]*\}/g, match; 80 | while ((match = re.exec(tokenString)) != null) { 81 | let orig = match[0]; 82 | let replacement = translateJS(options, app, orig.slice(2,-1)); 83 | tokenString = tokenString.substr(0,match.index+2) + replacement + tokenString.substr(match.index + orig.length-1); 84 | } 85 | } 86 | outjs += previousString+tokenString; 87 | lastIdx = tok.endIdx; 88 | tok = lex.next(); 89 | } 90 | 91 | /*console.log("==================== IN"); 92 | console.log(code); 93 | console.log("==================== OUT"); 94 | console.log(outjs);*/ 95 | return outjs; 96 | } 97 | 98 | // Run JS through EspruinoTools to pull in modules/etc 99 | function parseJS(storageFile, options, app) { 100 | options = options||{}; 101 | options.device = options.device||{}; 102 | if (storageFile.url && storageFile.url.endsWith(".js") && !storageFile.url.endsWith(".min.js")) { 103 | // if original file ends in '.js'... 104 | let js = storageFile.content; 105 | // check for language translations 106 | if (options.language) 107 | js = translateJS(options, app, js); 108 | // handle modules 109 | let localModulesURL = "modules"; 110 | if (typeof window!=="undefined") 111 | localModulesURL = window.location.origin + window.location.pathname.replace(/[^/]*$/,"") + "modules"; 112 | let builtinModules = ["Flash","Storage","heatshrink","tensorflow","locale","notify"]; 113 | // FIXME: now we check options.device.modules below, do we need the hard-coded list above? 114 | if (options.device.modules) 115 | options.device.modules.forEach(mod => { 116 | if (!builtinModules.includes(mod)) builtinModules.push(mod); 117 | }); 118 | 119 | // add any modules that were defined for this app (no need to search for them!) 120 | builtinModules = builtinModules.concat(app.storage.map(f=>f.name).filter(name => name && !name.includes("."))); 121 | // Check for modules in pre-installed apps? 122 | if (options.device.appsInstalled) 123 | options.device.appsInstalled.forEach(app => { 124 | /* we can't use provides_modules here because these apps are loaded 125 | from the app.info file which doesn't have it. Instead, look for files 126 | with no extension listed in 'app.files'. */ 127 | if (!app.files) return; 128 | app.files.split(",").forEach(file => { 129 | if (file.length && !file.includes(".")) 130 | builtinModules.push(file); 131 | }); 132 | }); 133 | // In some cases we can't minify! 134 | let minify = options.settings.minify; 135 | if (options.settings.minify) { 136 | js = js.trim(); 137 | /* if we're uploading (function() {...}) code for app.settings.js then 138 | minification destroys it because it doesn't have side effects. It's hard 139 | to work around nicely, so disable minification in these cases */ 140 | if (js.match(/\(\s*function/) && js.match(/}\s*\)/)) 141 | minify = false; 142 | } 143 | // TODO: we could look at installed app files and add any modules defined in those? 144 | /* Don't run code that we're going to be uploading direct through EspruinoTools. This is 145 | usually an icon, and we don't want it pretokenised, minifying won't do anything, and really 146 | we don't want anything touching it at all. */ 147 | if (storageFile.evaluate) { 148 | storageFile.content = js; 149 | return storageFile; 150 | } 151 | // Now run through EspruinoTools for pretokenising/compiling/modules/etc 152 | return Espruino.transform(js, { 153 | SAVE_ON_SEND : -1, // ensure EspruinoTools doesn't try and wrap this is write commands, also stops pretokenise from assuming we're writing to RAM 154 | SET_TIME_ON_WRITE : false, 155 | PRETOKENISE : options.settings.pretokenise, 156 | MODULE_URL : localModulesURL+"|https://www.espruino.com/modules", 157 | MINIFICATION_LEVEL : minify ? "ESPRIMA" : undefined, 158 | builtinModules : builtinModules.join(","), 159 | boardData : { 160 | BOARD: options.device.id, 161 | VERSION: options.device.version, 162 | EXPTR: options.device.exptr 163 | } 164 | }).then(content => { 165 | storageFile.content = content; 166 | return storageFile; 167 | }); 168 | } else 169 | return Promise.resolve(storageFile); 170 | } 171 | 172 | let AppInfo = { 173 | /* Get a list of commands needed to upload the file */ 174 | getFileUploadCommands : (filename, data) => { 175 | const CHUNKSIZE = Const.UPLOAD_CHUNKSIZE; 176 | if (Const.FILES_IN_FS) { 177 | let cmd = `\x10require('fs').writeFileSync(${JSON.stringify(filename)},${asJSExpr(data.substr(0,CHUNKSIZE))});`; 178 | for (let i=CHUNKSIZE;i { 191 | const CHUNKSIZE = Const.UPLOAD_CHUNKSIZE; 192 | // write code in chunks, in case it is too big to fit in RAM (fix #157) 193 | function getWriteData(offset) { 194 | return asJSExpr(data.substr(offset,CHUNKSIZE), {noHeatshrink:true}); 195 | // noHeatshrink:true fixes https://github.com/espruino/BangleApps/issues/2068 196 | // If we give f.write `[65,66,67]` it writes it as `65,66,67` rather than `"ABC"` 197 | // so we must ensure we always return a String 198 | // We could use E.toString but https://github.com/espruino/BangleApps/issues/2068#issuecomment-1211717749 199 | } 200 | let cmd = `\x10f=require('Storage').open(${JSON.stringify(filename)},'w');f.write(${getWriteData(0)});`; 201 | for (let i=CHUNKSIZE;i { 214 | options = options||{}; 215 | return new Promise((resolve,reject) => { 216 | // translate app names 217 | if (options.language) { 218 | if (app.shortName) 219 | app.shortName = translateString(options, app, app.shortName)||app.shortName; 220 | app.name = translateString(options, app, app.name)||app.name; 221 | } 222 | // Load all files 223 | let appFiles = [].concat( 224 | app.storage, 225 | app.data&&app.data.filter(f=>f.url||f.content).map(f=>(f.noOverwrite=true,f.dataFile=true,f))||[]); 226 | //console.log(appFiles) 227 | // does the app's file list have a 'supports' entry? 228 | if (appFiles.some(file=>file.supports)) { 229 | if (!options.device || !options.device.id) 230 | return reject("App storage contains a 'supports' field, but no device ID found"); 231 | appFiles = appFiles.filter(file=>{ 232 | if (!file.supports) return true; 233 | return file.supports.includes(options.device.id); 234 | }); 235 | } 236 | 237 | Promise.all(appFiles.map(storageFile => { 238 | if (storageFile.content!==undefined) 239 | return Promise.resolve(storageFile).then(storageFile => parseJS(storageFile,options,app)); 240 | else if (storageFile.url) 241 | return options.fileGetter(`apps/${app.id}/${storageFile.url}`).then(content => { 242 | return { 243 | name : storageFile.name, 244 | url : storageFile.url, 245 | content : content, 246 | evaluate : storageFile.evaluate, 247 | noOverwrite : storageFile.noOverwrite, 248 | dataFile : !!storageFile.dataFile 249 | }}).then(storageFile => parseJS(storageFile, options, app)); 250 | else return Promise.resolve(); 251 | })).then(fileContents => { // now we just have a list of files + contents... 252 | // filter out empty files 253 | fileContents = fileContents.filter(x=>x!==undefined); 254 | // if it's a 'ram' app, don't add any app JSON file 255 | if (app.type=="RAM" || app.type=="defaultconfig") return fileContents; 256 | // Add app's info JSON 257 | return AppInfo.createAppJSON(app, fileContents); 258 | }).then(fileContents => { 259 | // then map each file to a command to load into storage 260 | fileContents.forEach(storageFile => { 261 | // format ready for Espruino 262 | if (storageFile.name=="RAM") { 263 | storageFile.cmd = "\x10"+storageFile.content.trim(); 264 | } else if (storageFile.evaluate) { 265 | let js = storageFile.content.trim(); 266 | if (js.endsWith(";")) 267 | js = js.slice(0,-1); 268 | storageFile.cmd = `\x10require('Storage').write(${JSON.stringify(storageFile.name)},${js});`; 269 | } else { 270 | storageFile.cmd = AppInfo.getFileUploadCommands(storageFile.name, storageFile.content); 271 | storageFile.canUploadPacket = true; // it's just treated as a normal file - so we can upload as packets (faster) 272 | } 273 | // if we're not supposed to overwrite this file... this gets set 274 | // automatically for data files that are loaded 275 | if (storageFile.noOverwrite) { 276 | storageFile.cmd = `\x10var _e = require('Storage').read(${JSON.stringify(storageFile.name)})===undefined;\n` + 277 | storageFile.cmd.replace(/\x10/g,"\x10if(_e)") + "delete _e;"; 278 | storageFile.canUploadPacket = false; // because we check, we can't do the fast upload 279 | } 280 | }); 281 | resolve(fileContents); 282 | }).catch(err => reject(err)); 283 | }); 284 | }, 285 | getAppInfoFilename : (app) => { 286 | if (Const.SINGLE_APP_ONLY) // only one app on device, info file is in app.info 287 | return "app.info"; 288 | else if (Const.FILES_IN_FS) 289 | return "APPINFO/"+app.id+".info"; 290 | else 291 | return app.id+".info"; 292 | }, 293 | createAppJSON : (app, fileContents) => { 294 | return new Promise((resolve,reject) => { 295 | let appInfoFileName = AppInfo.getAppInfoFilename(app); 296 | // Check we don't already have a JSON file! 297 | let appJSONFile = fileContents.find(f=>f.name==appInfoFileName); 298 | if (appJSONFile) reject("App JSON file explicitly specified!"); 299 | // Now actually create the app JSON 300 | let json = { 301 | id : app.id 302 | }; 303 | if (app.shortName) json.name = app.shortName; 304 | else json.name = app.name; 305 | if (app.type && app.type!="app") json.type = app.type; 306 | if (fileContents.find(f=>f.name==app.id+".app.js")) 307 | json.src = app.id+".app.js"; 308 | if (fileContents.find(f=>f.name==app.id+".img")) 309 | json.icon = app.id+".img"; 310 | if (app.sortorder) json.sortorder = app.sortorder; 311 | if (app.version) json.version = app.version; 312 | if (app.tags) json.tags = app.tags; 313 | let fileList = fileContents.filter(storageFile=>!storageFile.dataFile).map(storageFile=>storageFile.name).filter(n=>n!="RAM"); 314 | fileList.unshift(appInfoFileName); // do we want this? makes life easier! 315 | json.files = fileList.join(","); 316 | if ('data' in app) { 317 | let data = {dataFiles: [], storageFiles: []}; 318 | // add "data" files to appropriate list 319 | app.data.forEach(d=>{ 320 | if (d.storageFile) data.storageFiles.push(d.name||d.wildcard) 321 | else data.dataFiles.push(d.name||d.wildcard) 322 | }) 323 | const dataString = AppInfo.makeDataString(data) 324 | if (dataString) json.data = dataString 325 | } 326 | fileContents.push({ 327 | name : appInfoFileName, 328 | content : JSON.stringify(json) 329 | }); 330 | resolve(fileContents); 331 | }); 332 | }, 333 | // (.info).data holds filenames of data: both regular and storageFiles 334 | // These are stored as: (note comma vs semicolons) 335 | // "fil1,file2", "file1,file2;storageFileA,storageFileB" or ";storageFileA" 336 | /** 337 | * Convert appid.info "data" to object with file names/patterns 338 | * Passing in undefined works 339 | * @param data "data" as stored in appid.info 340 | * @returns {{storageFiles:[], dataFiles:[]}} 341 | */ 342 | parseDataString(data) { 343 | data = data || ''; 344 | let [files = [], storage = []] = data.split(';').map(d => d.split(',')); 345 | if (files.length==1 && files[0]=="") files = []; // hack for above code 346 | return {dataFiles: files, storageFiles: storage} 347 | }, 348 | /** 349 | * Convert object with file names/patterns to appid.info "data" string 350 | * Passing in an incomplete object will not work 351 | * @param data {{storageFiles:[], dataFiles:[]}} 352 | * @returns {string} "data" to store in appid.info 353 | */ 354 | makeDataString(data) { 355 | if (!data.dataFiles.length && !data.storageFiles.length) { return '' } 356 | if (!data.storageFiles.length) { return data.dataFiles.join(',') } 357 | return [data.dataFiles.join(','),data.storageFiles.join(',')].join(';') 358 | }, 359 | 360 | /* 361 | uploadOptions : { 362 | apps : appJSON, - list of all apps from JSON 363 | needsApp : function(app, uploadOptions) - returns a promise which resolves with the app object, this installs the given app 364 | checkForClashes : bool - check for existing apps that may get in the way 365 | showQuery : IF checkForClashes=true, showQuery(msg, appToRemove) returns a promise 366 | ... PLUS what can be supplied to Comms.uploadApp 367 | device, language, noReset, noFinish 368 | } 369 | */ 370 | checkDependencies : (app, device, uploadOptions) => { 371 | uploadOptions = uploadOptions || {}; 372 | if (uploadOptions.checkForClashes === undefined) 373 | uploadOptions.checkForClashes = true; 374 | if (uploadOptions.apps === undefined) 375 | uploadOptions.apps = appJSON; 376 | 377 | let promise = Promise.resolve(); 378 | // Look up installed apps in our app JSON to get full info on them 379 | let appJSONInstalled = device.appsInstalled.map(app => uploadOptions.apps.find(a=>a.id==app.id)).filter(app=>app!=undefined); 380 | // Check for existing apps that might cause issues 381 | if (uploadOptions.checkForClashes) { 382 | if (app.provides_modules) { 383 | app.provides_modules.forEach(module => { 384 | let existing = appJSONInstalled.find(app => 385 | app.provides_modules && app.provides_modules.includes(module)); 386 | if (existing) { 387 | let msg = `App "${app.name}" provides module "${module}" which is already provided by "${existing.name}"`; 388 | promise = promise.then(() => uploadOptions.showQuery(msg, existing)); 389 | } 390 | }); 391 | } 392 | if (app.provides_widgets) { 393 | app.provides_widgets.forEach(widget => { 394 | let existing = appJSONInstalled.find(app => 395 | app.provides_widgets && app.provides_widgets.includes(widget)); 396 | if (existing) { 397 | let msg = `App "${app.name}" provides widget type "${widget}" which is already provided by "${existing.name}"`; 398 | promise = promise.then(() => uploadOptions.showQuery(msg, existing)); 399 | } 400 | }); 401 | } 402 | if (app.provides_features) { 403 | app.provides_features.forEach(feature => { 404 | let existing = appJSONInstalled.find(app => 405 | app.provides_features && app.provides_features.includes(feature)); 406 | if (existing) { 407 | let msg = `App "${app.name}" provides feature '"${feature}"' which is already provided by "${existing.name}"`; 408 | promise = promise.then(() => uploadOptions.showQuery(msg, existing)); 409 | } 410 | }); 411 | } 412 | if (app.type=="launch") { 413 | let existing = appJSONInstalled.find(app => app.type=="launch"); 414 | if (existing) { 415 | let msg = `App "${app.name}" is a launcher but you already have "${existing.name}" installed`; 416 | promise = promise.then(() => uploadOptions.showQuery(msg, existing)); 417 | } 418 | } 419 | if (app.type=="textinput") { 420 | let existing = appJSONInstalled.find(app => app.type=="textinput"); 421 | if (existing) { 422 | let msg = `App "${app.name}" handles Text Input but you already have "${existing.name}" installed`; 423 | promise = promise.then(() => uploadOptions.showQuery(msg, existing)); 424 | } 425 | } 426 | if (app.type=="notify") { 427 | let existing = appJSONInstalled.find(app => app.type=="notify"); 428 | if (existing) { 429 | let msg = `App "${app.name}" handles Notifications but you already have "${existing.name}" installed`; 430 | promise = promise.then(() => uploadOptions.showQuery(msg, existing)); 431 | } 432 | } 433 | } 434 | // could check provides_widgets here, but hey, why can't the user have 2 battery widgets if they want? 435 | // Check for apps which we may need to install 436 | if (app.dependencies) { 437 | Object.keys(app.dependencies).forEach(dependency=>{ 438 | let dependencyType = app.dependencies[dependency]; 439 | function handleDependency(dependencyChecker) { 440 | // now see if we can find one matching our dependency 441 | let found = appJSONInstalled.find(dependencyChecker); 442 | if (found) 443 | console.log(`Found dependency in installed app '${found.id}'`); 444 | else { 445 | let foundApps = uploadOptions.apps.filter(dependencyChecker); 446 | if (!foundApps.length) throw new Error(`Dependency of '${dependency}' listed, but nothing satisfies it!`); 447 | console.log(`Apps ${foundApps.map(f=>`'${f.id}'`).join("/")} implements '${dependencyType}:${dependency}'`); 448 | found = foundApps.find(app => app.default); 449 | if (!found) { 450 | console.warn("Looking for dependency, but no default app found - using first in list"); 451 | found = foundApps[0]; // choose first app in list 452 | } 453 | console.log(`Dependency not installed. Installing app id '${found.id}'`); 454 | promise = promise.then(()=>new Promise((resolve,reject)=>{ 455 | console.log(`Install dependency '${dependency}':'${found.id}'`); 456 | return AppInfo.checkDependencies(found, device, uploadOptions) 457 | .then(() => uploadOptions.needsApp(found, uploadOptions)) 458 | .then(appJSON => { 459 | if (appJSON) device.appsInstalled.push(appJSON); 460 | resolve(); 461 | }, reject); 462 | })); 463 | } 464 | } 465 | 466 | if (dependencyType=="type") { 467 | console.log(`Searching for dependency on app TYPE '${dependency}'`); 468 | handleDependency(app=>app.type==dependency); 469 | } else if (dependencyType=="app") { 470 | console.log(`Searching for dependency on app ID '${dependency}'`); 471 | handleDependency(app=>app.id==dependency); 472 | } else if (dependencyType=="module") { 473 | console.log(`Searching for dependency for module '${dependency}'`); 474 | handleDependency(app=>app.provides_modules && app.provides_modules.includes(dependency)); 475 | } else if (dependencyType=="widget") { 476 | console.log(`Searching for dependency for widget '${dependency}'`); 477 | handleDependency(app=>app.provides_widgets && app.provides_widgets.includes(dependency)); 478 | } else 479 | throw new Error(`Dependency type '${dependencyType}' not supported`); 480 | }); 481 | } 482 | return promise; 483 | } 484 | }; 485 | 486 | if ("undefined"!=typeof module) 487 | module.exports = AppInfo; 488 | -------------------------------------------------------------------------------- /js/comms.js: -------------------------------------------------------------------------------- 1 | //Puck.debug=3; 2 | console.log("================================================") 3 | console.log("Type 'Comms.debug()' to enable Comms debug info") 4 | console.log("================================================") 5 | 6 | /// Add progress handler so we get nice upload progress shown 7 | { 8 | let COMMS = (typeof UART != "undefined")?UART:Puck; 9 | COMMS.writeProgress = function(charsSent, charsTotal) { 10 | if (charsSent===undefined || charsTotal<10) { 11 | Progress.hide(); 12 | return; 13 | } 14 | let percent = Math.round(charsSent*100/charsTotal); 15 | Progress.show({percent: percent}); 16 | }; 17 | } 18 | 19 | const Comms = { 20 | // ================================================================================ 21 | // Low Level Comms 22 | /// enable debug print statements 23 | debug : () => { 24 | if (typeof UART !== "undefined") 25 | UART.debug = 3; 26 | else 27 | Puck.debug = 3; 28 | }, 29 | 30 | /** Write the given data, returns a promise containing the data received immediately after sending the command 31 | options = { 32 | waitNewLine : bool // wait for a newline (rather than just 300ms of inactivity) 33 | } 34 | */ 35 | write : (data, options) => { 36 | if (data===undefined) throw new Error("Comms.write(undefined) called!") 37 | options = options||{}; 38 | if (typeof UART !== "undefined") { // New method 39 | return UART.write(data, undefined, !!options.waitNewLine); 40 | } else { // Old method 41 | return new Promise((resolve,reject) => 42 | Puck.write(data, result => { 43 | if (result===null) return reject(""); 44 | resolve(result); 45 | }, !!options.waitNewLine) 46 | ); 47 | } 48 | }, 49 | /// Evaluate the given expression, return the result as a promise 50 | eval : (expr) => { 51 | if (expr===undefined) throw new Error("Comms.eval(undefined) called!") 52 | if (typeof UART !== "undefined") { // New method 53 | return UART.eval(expr); 54 | } else { // Old method 55 | return new Promise((resolve,reject) => 56 | Puck.eval(expr, result => { 57 | if (result===null) return reject(""); 58 | resolve(result); 59 | }) 60 | ); 61 | } 62 | }, 63 | /// Return true if we're connected, false if not 64 | isConnected : () => { 65 | if (typeof UART !== "undefined") { // New method 66 | return UART.isConnected(); 67 | } else { // Old method 68 | return Puck.isConnected(); 69 | } 70 | }, 71 | /// Get the currently active connection object 72 | getConnection : () => { 73 | if (typeof UART !== "undefined") { // New method 74 | return UART.getConnection(); 75 | } else { // Old method 76 | return Puck.getConnection(); 77 | } 78 | }, 79 | supportsPacketUpload : () => (!SETTINGS.noPackets) && Comms.getConnection().espruinoSendFile && device.version && !Utils.versionLess(device.version,"2v25"), 80 | // Faking EventEmitter 81 | handlers : {}, 82 | on : function(id, callback) { // calling with callback=undefined will disable 83 | if (id!="data") throw new Error("Only data callback is supported"); 84 | let connection = Comms.getConnection(); 85 | if (!connection) throw new Error("No active connection"); 86 | if ("undefined"!==typeof Puck) { 87 | /* This is a bit of a mess - the Puck.js lib only supports one callback with `.on`. If you 88 | do Puck.getConnection().on('data') then it blows away the default one which is used for 89 | .write/.eval and you can't get it back unless you reconnect. So rather than trying to fix the 90 | Puck lib we just copy in the default handler here. */ 91 | if (callback===undefined) { 92 | connection.on("data", function(d) { // the default handler 93 | connection.received += d; 94 | connection.hadData = true; 95 | if (connection.cb) connection.cb(d); 96 | }); 97 | } else { 98 | connection.on("data", function(d) { 99 | connection.received += d; 100 | connection.hadData = true; 101 | if (connection.cb) connection.cb(d); 102 | callback(d); 103 | }); 104 | } 105 | } else { // UART 106 | if (callback===undefined) { 107 | if (Comms.dataCallback) connection.removeListener("data",Comms.dataCallback); 108 | delete Comms.dataCallback; 109 | } else { 110 | Comms.dataCallback = callback; 111 | connection.on("data",Comms.dataCallback); 112 | } 113 | } 114 | }, 115 | /* when connected, this is the name of the device we're connected to as far as Espruino is concerned 116 | (eg Bluetooth/USB/Serial1.println("Foo") ) */ 117 | espruinoDevice : undefined, 118 | // ================================================================================ 119 | // Show a message on the screen (if available) 120 | showMessage : (txt) => { 121 | console.log(` showMessage ${JSON.stringify(txt)}`); 122 | if (!Const.HAS_E_SHOWMESSAGE) return Promise.resolve(); 123 | return Comms.write(`\x10E.showMessage(${JSON.stringify(txt)})\n`); 124 | }, 125 | // When upload is finished, show a message (or reload) 126 | showUploadFinished : () => { 127 | if (SETTINGS.autoReload || Const.LOAD_APP_AFTER_UPLOAD || Const.SINGLE_APP_ONLY) return Comms.write("\x10load()\n"); 128 | else return Comms.showMessage(Const.MESSAGE_RELOAD); 129 | }, 130 | // Gets a text command to append to what's being sent to show progress. If progress==undefined, it's the first command, otherwise it's 0..1 131 | getProgressCmd : (progress) => { 132 | console.log(` getProgressCmd ${progress!==undefined?`${Math.round(progress*100)}%`:"START"}`); 133 | if (!Const.HAS_E_SHOWMESSAGE) { 134 | if (progress===undefined) return "p=x=>digitalPulse(LED1,1,10);"; 135 | return "p();"; 136 | } else { 137 | if (progress===undefined) return Const.CODE_PROGRESSBAR; 138 | return `p(${Math.round(progress*100)});` 139 | } 140 | }, 141 | // Reset the device, if opt=="wipe" erase any saved code 142 | reset : (opt) => { 143 | let tries = 8; 144 | if (Const.NO_RESET) return Promise.resolve(); 145 | console.log(" reset"); 146 | 147 | function rstHandler(result) { 148 | console.log(" reset: got "+JSON.stringify(result)); 149 | if (result===null) return Promise.reject("Connection failed"); 150 | if (result=="" && (tries-- > 0)) { 151 | console.log(` reset: no response. waiting ${tries}...`); 152 | return Comms.write("\x03").then(rstHandler); 153 | } else if (result.endsWith("debug>")) { 154 | console.log(` reset: watch in debug mode, interrupting...`); 155 | return Comms.write("\x03").then(rstHandler); 156 | } else { 157 | console.log(` reset: rebooted - sending commands to clear out any boot code`); 158 | // see https://github.com/espruino/BangleApps/issues/1759 159 | return Comms.write("\x10clearInterval();clearWatch();global.Bangle&&Bangle.removeAllListeners();E.removeAllListeners();global.NRF&&NRF.removeAllListeners();\n").then(function() { 160 | console.log(` reset: complete.`); 161 | return new Promise(resolve => setTimeout(resolve, 250)) 162 | }); 163 | } 164 | } 165 | 166 | return Comms.write(`\x03\x10reset(${opt=="wipe"?"1":""});\n`).then(rstHandler); 167 | }, 168 | // Upload a list of newline-separated commands that start with \x10 169 | // You should call Comms.write("\x10"+Comms.getProgressCmd()+"\n")) first 170 | uploadCommandList : (cmds, currentBytes, maxBytes) => { 171 | // Chould check CRC here if needed instead of returning 'OK'... 172 | // E.CRC32(require("Storage").read(${JSON.stringify(app.name)})) 173 | 174 | /* we can't just split on newline, because some commands (like 175 | an upload when evaluate:true) may contain newline in the command. 176 | In the absence of bracket counting/etc we'll just use the \x10 177 | char we use to signify echo(0) for a line */ 178 | cmds = cmds.split("\x10").filter(l=>l!="").map(l=>"\x10"+l.trim()); 179 | 180 | return (Comms.espruinoDevice?Promise.resolve():Comms.getDeviceInfo(true/*noreset*/)) // ensure Comms.espruinoDevice is set 181 | .then(() => new Promise( (resolve, reject) => { 182 | // Function to upload a single line and wait for an 'OK' response 183 | function uploadCmd() { 184 | if (!cmds.length) return resolve(); 185 | let cmd = cmds.shift(); 186 | Progress.show({ 187 | min:currentBytes / maxBytes, 188 | max:(currentBytes+cmd.length) / maxBytes}); 189 | currentBytes += cmd.length; 190 | function responseHandler(result) { 191 | console.log(" Response: ",JSON.stringify(result)); 192 | let ignore = false; 193 | if (result!==undefined) { 194 | result=result.trim(); 195 | if (result=="OK") { 196 | uploadCmd(); // all as expected - send next 197 | return; 198 | } 199 | 200 | if (result.startsWith("{") && result.endsWith("}")) { 201 | console.log(" JSON response received (Gadgetbridge?) - ignoring..."); 202 | ignore = true; 203 | } else if (result=="") { 204 | console.log(" Blank line received - ignoring..."); 205 | ignore = true; 206 | } 207 | } else { // result===undefined 208 | console.log(" No response received - ignoring..."); 209 | ignore = true; 210 | } 211 | if (ignore) { 212 | /* Here we have to poke around inside the Comms library internals. Basically 213 | it just gave us the first line in the input buffer, but there may have been more. 214 | We take the next line (or undefined) and call ourselves again to handle that. 215 | Just in case, delay a little to give our previous command time to finish.*/ 216 | setTimeout(function() { 217 | let connection = Comms.getConnection(); 218 | let newLineIdx = connection.received.indexOf("\n"); 219 | let l = undefined; 220 | if (newLineIdx>=0) { 221 | l = connection.received.substr(0,newLineIdx); 222 | connection.received = connection.received.substr(newLineIdx+1); 223 | } 224 | responseHandler(l); 225 | }, 500); 226 | } else { 227 | // Not a response we expected and we're not ignoring! 228 | Progress.hide({sticky:true}); 229 | return reject("Unexpected response "+(result?JSON.stringify(result):"")); 230 | } 231 | } 232 | // Actually write the command with a 'print OK' at the end, and use responseHandler 233 | // to deal with the response. If OK we call uploadCmd to upload the next block 234 | return Comms.write(`${cmd};${Comms.getProgressCmd(currentBytes / maxBytes)}${Comms.espruinoDevice}.println("OK")\n`,{waitNewLine:true}).then(responseHandler); 235 | } 236 | 237 | uploadCmd() 238 | })); 239 | }, 240 | /** Upload an app 241 | app : an apps.json structure (i.e. with `storage`) 242 | options : { device : { id : ..., version : ... } info about the currently connected device 243 | language : object of translations, eg 'lang/de_DE.json' 244 | noReset : if true, don't reset the device before 245 | noFinish : if true, showUploadFinished isn't called (displaying the reboot message) 246 | } */ 247 | uploadApp : (app,options) => { 248 | options = options||{}; 249 | Progress.show({title:`Uploading ${app.name}`,sticky:true}); 250 | return AppInfo.getFiles(app, { 251 | fileGetter : httpGet, 252 | settings : SETTINGS, 253 | language : options.language, 254 | device : options.device 255 | }).then(fileContents => { 256 | return new Promise((resolve,reject) => { 257 | console.log(" uploadApp:",fileContents.map(f=>f.name).join(", ")); 258 | let maxBytes = fileContents.reduce((b,f)=>b+f.cmd.length, 0)||1; 259 | let currentBytes = 0; 260 | 261 | let appInfoFileName = AppInfo.getAppInfoFilename(app); 262 | let appInfoFile = fileContents.find(f=>f.name==appInfoFileName); 263 | let appInfo = undefined; 264 | if (appInfoFile) 265 | appInfo = JSON.parse(appInfoFile.content); 266 | else if (app.type!="RAM" && app.type!="defaultconfig") 267 | reject(`${appInfoFileName} not found`); 268 | 269 | // Upload each file one at a time 270 | function doUploadFiles() { 271 | // No files left - print 'reboot' message 272 | if (fileContents.length==0) { 273 | (options.noFinish ? Promise.resolve() : Comms.showUploadFinished()).then(() => { 274 | Progress.hide({sticky:true}); 275 | resolve(appInfo); 276 | }).catch(reject); 277 | return; 278 | } 279 | let f = fileContents.shift(); 280 | // Only upload as a packet if it makes sense for the file, connection supports it, as does device firmware 281 | let uploadPacket = (!!f.canUploadPacket) && Comms.supportsPacketUpload(); 282 | 283 | function startUpload() { 284 | console.log(` Upload ${f.name} => ${JSON.stringify(f.content.length>50 ? f.content.substr(0,50)+"..." : f.content)} (${f.content.length}b${uploadPacket?", binary":""})`); 285 | if (uploadPacket) { 286 | Progress.show({ // Ensure that the correct progress is being shown in app loader 287 | percent: 0, 288 | min:currentBytes / maxBytes, 289 | max:(currentBytes+f.content.length) / maxBytes}); 290 | return Comms.write(`\x10${Comms.getProgressCmd(currentBytes / maxBytes)}\n`).then(() => // update percent bar on Bangle.js screen 291 | Comms.getConnection().espruinoSendFile(f.name, f.content, { // send the file 292 | fs: Const.FILES_IN_FS, 293 | chunkSize: Const.PACKET_UPLOAD_CHUNKSIZE, 294 | noACK: Const.PACKET_UPLOAD_NOACK 295 | })); 296 | } else { 297 | return Comms.uploadCommandList(f.cmd, currentBytes, maxBytes); 298 | } 299 | } 300 | 301 | startUpload().then(doUploadFiles, function(err) { 302 | console.warn("First attempt failed:", err); 303 | if (Const.PACKET_UPLOAD_CHUNKSIZE > 256) { 304 | // Espruino 2v25 has a 1 sec packet timeout (which isn't enough for 2kb packets if sending 20b at a time) 305 | // https://github.com/espruino/BangleApps/issues/3792#issuecomment-2804668109 306 | console.warn(`Using lower upload chunk size (${Const.PACKET_UPLOAD_CHUNKSIZE} ==> 256)`); 307 | Const.PACKET_UPLOAD_CHUNKSIZE = 256; 308 | } 309 | startUpload().then(doUploadFiles, function(err) { 310 | console.warn("Second attempt failed - bailing.", err); 311 | reject(err) 312 | }); 313 | }); 314 | 315 | currentBytes += f.cmd.length; 316 | } 317 | 318 | // Start the upload 319 | function doUpload() { 320 | Comms.showMessage(`Uploading\n${app.id}...`). 321 | then(() => Comms.write("\x10"+Comms.getProgressCmd()+"\n")). 322 | then(() => { 323 | doUploadFiles(); 324 | }).catch((err) => { 325 | Progress.hide({sticky:true}); 326 | return reject(err); 327 | }); 328 | } 329 | if (options.noReset) { 330 | doUpload(); 331 | } else { 332 | // reset to ensure we have enough memory to upload what we need to 333 | Comms.reset().then(doUpload, reject) 334 | } 335 | }); 336 | }).catch(err => { 337 | Progress.hide({sticky:true}); // ensure we hide our sticky progress message if there was an error 338 | return Promise.reject(err); // pass the error on 339 | }); 340 | }, 341 | // Get Device ID, version, storage stats, and a JSON list of installed apps 342 | getDeviceInfo : (noReset) => { 343 | Progress.show({title:`Getting device info...`,sticky:true}); 344 | return Comms.write("\x03").then(result => { 345 | if (result===null) { 346 | Progress.hide({sticky:true}); 347 | return Promise.reject("No response"); 348 | } 349 | 350 | let interrupts = 0; 351 | const checkCtrlC = result => { 352 | if (result.endsWith("debug>")) { 353 | if (interrupts > 3) { 354 | console.log(" can't interrupt watch out of debug mode, giving up.", result); 355 | return Promise.reject("Stuck in debug mode"); 356 | } 357 | console.log(" watch was in debug mode, interrupting.", result); 358 | // we got a debug prompt - we interrupted the watch while JS was executing 359 | // so we're in debug mode, issue another ctrl-c to bump the watch out of it 360 | interrupts++; 361 | return Comms.write("\x03").then(checkCtrlC); 362 | } else { 363 | return result; 364 | } 365 | }; 366 | 367 | return checkCtrlC(result); 368 | }). 369 | then((result) => new Promise((resolve, reject) => { 370 | console.log(" Ctrl-C gave",JSON.stringify(result)); 371 | if (result.includes("ERROR") && !noReset) { 372 | console.log(" Got error, resetting to be sure."); 373 | // If the ctrl-c gave an error, just reset totally and 374 | // try again (need to display 'BTN3' message) 375 | Comms.reset(). 376 | then(()=>Comms.showMessage(Const.MESSAGE_RELOAD)). 377 | then(()=>Comms.getDeviceInfo(true)). 378 | then(resolve); 379 | return; 380 | } 381 | 382 | /* We need to figure out the console device name according to Espruino. For some devices 383 | it's easy (eg Bangle.js = Bluetooth) and we can hard code with Const.CONNECTION_DEVICE 384 | but for others we must figure it out */ 385 | let connection = Comms.getConnection(); 386 | if (Comms.espruinoDevice === undefined) { 387 | if (Const.CONNECTION_DEVICE) 388 | Comms.espruinoDevice = Const.CONNECTION_DEVICE; 389 | else { 390 | Comms.eval("process.env.CONSOLE").then(device => { 391 | if (("string"==typeof device) && device.length>0) 392 | Comms.espruinoDevice = device; 393 | else throw new Error("Unable to find Espruino console device"); 394 | console.log(" Set console device to "+device); 395 | }).then(()=>Comms.getDeviceInfo(true)) 396 | .then(resolve); 397 | return; 398 | } 399 | } 400 | if (Comms.getConnection().endpoint && Comms.getConnection().endpoint.name == "Web Serial" && Comms.espruinoDevice=="Bluetooth") { 401 | console.log(" Using Web Serial, forcing Comms.espruinoDevice='USB'", result); 402 | // FIXME: won't work on ESP8266/ESP32! 403 | Comms.espruinoDevice = "USB"; 404 | } 405 | if (Comms.getConnection().endpoint && Comms.getConnection().endpoint.name == "Web Bluetooth" && Comms.espruinoDevice!="Bluetooth") { 406 | console.log(" Using Web Bluetooth, forcing Comms.espruinoDevice='Bluetooth'", result); 407 | Comms.espruinoDevice = "Bluetooth"; 408 | } 409 | 410 | let cmd, finalJS = `JSON.stringify(require("Storage").getStats?require("Storage").getStats():{})+","+E.toJS([process.env.BOARD,process.env.VERSION,process.env.EXPTR,process.env.MODULES,0|getTime(),E.CRC32(getSerial()+(global.NRF?NRF.getAddress():0))]).substr(1)`; 411 | let device = Comms.espruinoDevice; 412 | if (Const.SINGLE_APP_ONLY) // only one app on device, info file is in app.info 413 | cmd = `\x10${device}.println("["+(require("Storage").read("app.info")||"null")+","+${finalJS})\n`; 414 | else if (Const.FILES_IN_FS) // file in a FAT filesystem 415 | cmd = `\x10${device}.print("[");let fs=require("fs");if (!fs.statSync("APPINFO"))fs.mkdir("APPINFO");fs.readdirSync("APPINFO").forEach(f=>{if (!fs.statSync("APPINFO/"+f).dir){var j=JSON.parse(fs.readFileSync("APPINFO/"+f))||"{}";${device}.print(JSON.stringify({id:f.slice(0,-5),version:j.version,files:j.files,data:j.data,type:j.type})+",")}});${device}.println(${finalJS})\n`; 416 | else // the default, files in Storage 417 | cmd = `\x10${device}.print("[");require("Storage").list(/\\.info$/).forEach(f=>{var j=require("Storage").readJSON(f,1)||{};${device}.print(JSON.stringify({id:f.slice(0,-5),version:j.version,files:j.files,data:j.data,type:j.type})+",")});${device}.println(${finalJS})\n`; 418 | Comms.write(cmd, {waitNewLine:true}).then(appListStr => { 419 | Progress.hide({sticky:true}); 420 | if (!appListStr) appListStr=""; 421 | let connection = Comms.getConnection(); 422 | if (connection) { 423 | appListStr = appListStr+"\n"+connection.received; // add *any* information we have received so far, including what was returned 424 | connection.received = ""; // clear received data just in case 425 | } 426 | // we may have received more than one line - we're looking for an array (starting with '[') 427 | let lines = appListStr ? appListStr.split("\n").map(l=>l.trim()) : []; 428 | let appListJSON = lines.find(l => l[0]=="["); 429 | // check to see if we got our data 430 | if (!appListJSON) { 431 | console.log("No JSON, just got: "+JSON.stringify(appListStr)); 432 | return reject("No response from device. Is 'Programmable' set to 'Off'?"); 433 | } 434 | // now try and parse 435 | let err, info = {}; 436 | let appList; 437 | try { 438 | appList = JSON.parse(appListJSON); 439 | // unpack the last 6 elements which are board info (See finalJS above) 440 | info.uid = appList.pop(); // unique ID for watch (hash of internal serial number and MAC) 441 | info.currentTime = appList.pop()*1000; // time in ms 442 | info.modules = appList.pop().split(","); // see what modules we have internally so we don't have to upload them if they exist 443 | info.exptr = appList.pop(); // used for compilation 444 | info.version = appList.pop(); 445 | info.id = appList.pop(); 446 | info.storageStats = appList.pop(); // how much storage has been used 447 | if (info.storageStats.totalBytes && (info.storageStats.freeBytes*10info.storageStats.totalBytes) 450 | suggest = "Try running 'Compact Storage' from Bangle.js 'Settings' -> 'Utils'."; 451 | showToast(`Low Disk Space: ${Math.round(info.storageStats.freeBytes/1000)}k of ${Math.round(info.storageStats.totalBytes/1000)}k remaining on this device.${suggest} See 'More...' -> 'Device Info' for more information.`,"warning"); 452 | } 453 | // if we just have 'null' then it means we have no apps 454 | if (appList.length==1 && appList[0]==null) 455 | appList = []; 456 | } catch (e) { 457 | appList = null; 458 | console.log(" ERROR Parsing JSON",e.toString()); 459 | console.log(" Actual response: ",JSON.stringify(appListStr)); 460 | err = "Invalid JSON"; 461 | } 462 | if (appList===null) return reject(err || ""); 463 | info.apps = appList; 464 | console.log(" getDeviceInfo", info); 465 | resolve(info); 466 | }, true /* callback on newline */); 467 | })); 468 | }, 469 | // Get an app's info file from Bangle.js 470 | getAppInfo : app => { 471 | let cmd; 472 | 473 | return (Comms.espruinoDevice?Promise.resolve():Comms.getDeviceInfo(true/*noreset*/)) // ensure Comms.espruinoDevice is set 474 | .then(() => { 475 | if (Const.FILES_IN_FS) cmd = `\x10${Comms.espruinoDevice}.println(require("fs").readFileSync(${JSON.stringify(AppInfo.getAppInfoFilename(app))})||"null")\n`; 476 | else cmd = `\x10${Comms.espruinoDevice}.println(require("Storage").read(${JSON.stringify(AppInfo.getAppInfoFilename(app))})||"null")\n`; 477 | return Comms.write(cmd). 478 | then(appJSON=>{ 479 | let app; 480 | try { 481 | app = JSON.parse(appJSON); 482 | } catch (e) { 483 | app = null; 484 | console.log(" ERROR Parsing JSON",e.toString()); 485 | console.log(" Actual response: ",JSON.stringify(appJSON)); 486 | throw new Error("Invalid JSON"); 487 | } 488 | return app; 489 | }); 490 | }); 491 | }, 492 | /** Remove an app given an appinfo.id structure as JSON 493 | expects an appid.info structure with minimum app.id 494 | if options.containsFileList is true, don't get data from watch 495 | if options.noReset is true, don't reset the device before 496 | if options.noFinish is true, showUploadFinished isn't called (displaying the reboot message) */ 497 | removeApp : (app, options) => { 498 | options = options||{}; 499 | Progress.show({title:`Removing ${app.id}`,sticky:true}); 500 | /* App Info now doesn't contain .files, so to erase, we need to 501 | read the info file ourselves. */ 502 | return (options.noReset ? Promise.resolve() : Comms.reset()). 503 | then(()=>Comms.showMessage(`Erasing\n${app.id}...`)). 504 | then(()=>options.containsFileList ? app : Comms.getAppInfo(app)). 505 | then(app=>{ 506 | let cmds = ''; 507 | // remove App files: regular files, exact names only 508 | if ("string"!=typeof app.files) { 509 | console.warn("App file "+app.id+".info doesn't have a 'files' field"); 510 | app.files=app.id+".info"; 511 | } 512 | if (Const.FILES_IN_FS) 513 | cmds += app.files.split(',').filter(f=>f!="").map(file => `\x10require("fs").unlinkSync(${Utils.toJSString(file)});\n`).join(""); 514 | else 515 | cmds += app.files.split(',').filter(f=>f!="").map(file => `\x10require("Storage").erase(${Utils.toJSString(file)});\n`).join(""); 516 | // remove app Data: (dataFiles and storageFiles) 517 | const data = AppInfo.parseDataString(app.data) 518 | const isGlob = f => /[?*]/.test(f) 519 | // regular files, can use wildcards 520 | cmds += data.dataFiles.map(file => { 521 | if (!isGlob(file)) return `\x10require("Storage").erase(${Utils.toJSString(file)});\n`; 522 | const regex = new RegExp(globToRegex(file)) 523 | return `\x10require("Storage").list(${regex}).forEach(f=>require("Storage").erase(f));\n`; 524 | }).join(""); 525 | // storageFiles, can use wildcards 526 | cmds += data.storageFiles.map(file => { 527 | if (!isGlob(file)) return `\x10require("Storage").open(${Utils.toJSString(file)},'r').erase();\n`; 528 | // storageFiles have a chunk number appended to their real name 529 | const regex = globToRegex(file+'\u0001') 530 | // open() doesn't want the chunk number though 531 | let cmd = `\x10require("Storage").list(${regex}).forEach(f=>require("Storage").open(f.substring(0,f.length-1),'r').erase());\n` 532 | // using a literal \u0001 char fails (not sure why), so escape it 533 | return cmd.replace('\u0001', '\\x01') 534 | }).join(""); 535 | console.log(" removeApp", cmds); 536 | if (cmds!="") return Comms.write(cmds); 537 | }). 538 | then(()=>options.noFinish ? Promise.resolve() : Comms.showUploadFinished()). 539 | then(()=>Progress.hide({sticky:true})). 540 | catch(function(reason) { 541 | Progress.hide({sticky:true}); 542 | return Promise.reject(reason); 543 | }); 544 | }, 545 | // Remove all apps from the device 546 | removeAllApps : () => { 547 | console.log(" removeAllApps start"); 548 | Progress.show({title:"Removing all apps",percent:"animate",sticky:true}); 549 | 550 | return (Comms.espruinoDevice?Promise.resolve():Comms.getDeviceInfo(true/*noreset*/)) // ensure Comms.espruinoDevice is set 551 | .then(() => new Promise((resolve,reject) => { 552 | let timeout = 5; 553 | function handleResult(result,err) { 554 | console.log(" removeAllApps: received "+JSON.stringify(result)); 555 | if (!Comms.isConnected()) 556 | return reject("Disconnected"); 557 | if (result=="" && (timeout--)) { 558 | console.log(" removeAllApps: no result - waiting some more ("+timeout+")."); 559 | // send space and delete - so it's something, but it should just cancel out 560 | Comms.write(" \u0008", {waitNewLine:true}).then(handleResult); 561 | } else { 562 | Progress.hide({sticky:true}); 563 | if (!result || result.trim()!="OK") { 564 | if (!result) result = "No response"; 565 | else result = "Got "+JSON.stringify(result.trim()); 566 | return reject(err || result); 567 | } else resolve(); 568 | } 569 | } 570 | // Use write with newline here so we wait for it to finish 571 | let cmd = `\x10E.showMessage("Erasing...");require("Storage").eraseAll();${Comms.espruinoDevice}.println("OK");reset()\n`; 572 | Comms.write(cmd,{waitNewLine:true}).then(handleResult); 573 | }).then(() => new Promise(resolve => { 574 | console.log(" removeAllApps: Erase complete, waiting 500ms for 'reset()'"); 575 | setTimeout(resolve, 500); 576 | }))); // now wait a second for the reset to complete 577 | }, 578 | // Set the time on the device 579 | setTime : () => { 580 | /* connect FIRST, then work out the time - otherwise 581 | we end up with a delay dependent on how long it took 582 | to open the device chooser. */ 583 | return Comms.write(" \x08").then(() => { // send space+backspace (eg no-op) 584 | let d = new Date(); 585 | let tz = d.getTimezoneOffset()/-60 586 | let cmd = '\x10setTime('+(d.getTime()/1000)+');'; 587 | // in 1v93 we have timezones too 588 | cmd += 'E.setTimeZone('+tz+');'; 589 | cmd += "(s=>s&&(s.timezone="+tz+",require('Storage').write('setting.json',s)))(require('Storage').readJSON('setting.json',1))\n"; 590 | return Comms.write(cmd); 591 | }); 592 | }, 593 | // Reset the device 594 | resetDevice : () => { 595 | let cmd = "load();\n"; 596 | return Comms.write(cmd); 597 | }, 598 | // Force a disconnect from the device 599 | disconnectDevice: () => { 600 | let connection = Comms.getConnection(); 601 | if (!connection) return; 602 | connection.close(); 603 | }, 604 | // call back when the connection state changes 605 | watchConnectionChange : cb => { 606 | let connected = Comms.isConnected(); 607 | 608 | //TODO Switch to an event listener when Puck will support it 609 | let interval = setInterval(() => { 610 | let newConnected = Comms.isConnected(); 611 | if (connected === newConnected) return; 612 | connected = newConnected; 613 | if (!connected) 614 | Comms.espruinoDevice = undefined; 615 | cb(connected); 616 | }, 1000); 617 | 618 | //stop watching 619 | return () => { 620 | clearInterval(interval); 621 | }; 622 | }, 623 | // List all files on the device. 624 | // options can be undefined, or {sf:true} for only storage files, or {sf:false} for only normal files 625 | listFiles : (options) => { 626 | let args = ""; 627 | if (options && options.sf!==undefined) args=`undefined,{sf:${options.sf}}`; 628 | //use encodeURIComponent to serialize octal sequence of append files 629 | return Comms.eval(`require("Storage").list(${args}).map(encodeURIComponent)`, (files,err) => { 630 | if (files===null) return Promise.reject(err || ""); 631 | files = files.map(decodeURIComponent); 632 | console.log(" listFiles", files); 633 | return files; 634 | }); 635 | }, 636 | // Execute some code, and read back the block of text it outputs (first line is the size in bytes for progress) 637 | readTextBlock : (code) => { 638 | return new Promise((resolve,reject) => { 639 | // Use "\xFF" to signal end of file (can't occur in StorageFiles anyway) 640 | let fileContent = ""; 641 | let fileSize = undefined; 642 | let connection = Comms.getConnection(); 643 | connection.received = ""; 644 | connection.cb = function(d) { 645 | let finished = false; 646 | let eofIndex = d.indexOf("\xFF"); 647 | if (eofIndex>=0) { 648 | finished = true; 649 | d = d.substr(0,eofIndex); 650 | } 651 | fileContent += d; 652 | if (fileSize === undefined) { 653 | let newLineIdx = fileContent.indexOf("\n"); 654 | if (newLineIdx>=0) { 655 | fileSize = parseInt(fileContent.substr(0,newLineIdx)); 656 | console.log(" size is "+fileSize); 657 | fileContent = fileContent.substr(newLineIdx+1); 658 | } 659 | } else { 660 | Progress.show({percent:100*fileContent.length / (fileSize||1000000)}); 661 | } 662 | if (finished) { 663 | Progress.hide(); 664 | connection.received = ""; 665 | connection.cb = undefined; 666 | resolve(fileContent); 667 | } 668 | }; 669 | connection.write(code,() => { 670 | console.log(` readTextBlock read started...`); 671 | }); 672 | }); 673 | }, 674 | // Read a non-storagefile file 675 | readFile : (filename) => { 676 | Progress.show({title:`Reading ${JSON.stringify(filename)}`,percent:0}); 677 | console.log(` readFile ${JSON.stringify(filename)}`); 678 | const CHUNKSIZE = 384; 679 | return (Comms.espruinoDevice?Promise.resolve():Comms.getDeviceInfo(true/*noreset*/)) // ensure Comms.espruinoDevice is set 680 | .then(() => Comms.readTextBlock(`\x10(function() { 681 | var s = require("Storage").read(${JSON.stringify(filename)}); 682 | if (s===undefined) s=""; 683 | ${Comms.espruinoDevice}.println(((s.length+2)/3)<<2); 684 | for (var i=0;i { 687 | return Utils.atobSafe(text); 688 | })); 689 | }, 690 | // Read a storagefile 691 | readStorageFile : (filename) => { // StorageFiles are different to normal storage entries 692 | Progress.show({title:`Reading ${JSON.stringify(filename)}`,percent:0}); 693 | console.log(` readStorageFile ${JSON.stringify(filename)}`); 694 | return (Comms.espruinoDevice?Promise.resolve():Comms.getDeviceInfo(true/*noreset*/)) // ensure Comms.espruinoDevice is set 695 | .then(() => Comms.readTextBlock(`\x10(function() { 696 | var f = require("Storage").open(${JSON.stringify(filename)},"r"); 697 | ${Comms.espruinoDevice}.println(f.getLength()); 698 | var l = f.readLine(); 699 | while (l!==undefined) { ${Comms.espruinoDevice}.print(l); l = f.readLine(); } 700 | ${Comms.espruinoDevice}.print("\\xFF"); 701 | })()\n`)); 702 | }, 703 | // Read a non-storagefile file 704 | writeFile : (filename, data) => { 705 | console.log(` writeFile ${JSON.stringify(filename)} (${data.length}b)`); 706 | Progress.show({title:`Writing ${JSON.stringify(filename)}`,percent:0}); 707 | if (Comms.supportsPacketUpload()) { 708 | return Comms.getConnection().espruinoSendFile(filename, data, { 709 | chunkSize: Const.PACKET_UPLOAD_CHUNKSIZE, 710 | noACK: Const.PACKET_UPLOAD_NOACK 711 | }); 712 | } else { 713 | let cmds = AppInfo.getFileUploadCommands(filename, data); 714 | return Comms.write("\x10"+Comms.getProgressCmd()+"\n").then(() => 715 | Comms.uploadCommandList(cmds, 0, cmds.length) 716 | ); 717 | } 718 | }, 719 | }; 720 | -------------------------------------------------------------------------------- /js/pwa.js: -------------------------------------------------------------------------------- 1 | const divInstall = document.getElementById('installContainer'); 2 | const butInstall = document.getElementById('butInstall'); 3 | 4 | window.addEventListener('beforeinstallprompt', (event) => { 5 | console.log('👍', 'beforeinstallprompt', event); 6 | // Stash the event so it can be triggered later. 7 | window.deferredPrompt = event; 8 | // Remove the 'hidden' class from the install button container 9 | divInstall.classList.toggle('hidden', false); 10 | }); 11 | 12 | butInstall.addEventListener('click', () => { 13 | console.log('👍', 'butInstall-clicked'); 14 | const promptEvent = window.deferredPrompt; 15 | if (!promptEvent) { 16 | // The deferred prompt isn't available. 17 | return; 18 | } 19 | // Show the install prompt. 20 | promptEvent.prompt(); 21 | // Log the result 22 | promptEvent.userChoice.then((result) => { 23 | console.log('👍', 'userChoice', result); 24 | // Reset the deferred prompt variable, since 25 | // prompt() can only be called once. 26 | window.deferredPrompt = null; 27 | // Hide the install button. 28 | divInstall.classList.toggle('hidden', true); 29 | }); 30 | }); 31 | 32 | window.addEventListener('appinstalled', (event) => { 33 | console.log('👍', 'appinstalled', event); 34 | }); 35 | 36 | 37 | /* Only register a service worker if it's supported */ 38 | if ('serviceWorker' in navigator) { 39 | navigator.serviceWorker.register('core/js/service-worker.js'); 40 | } 41 | 42 | /** 43 | * Warn the page must be served over HTTPS 44 | * The `beforeinstallprompt` event won't fire if the page is served over HTTP. 45 | * Installability requires a service worker with a fetch event handler, and 46 | * if the page isn't served over HTTPS, the service worker won't load. 47 | */ 48 | if (window.location.protocol === 'http:' && window.location.hostname!="localhost") { 49 | const requireHTTPS = document.getElementById('requireHTTPS'); 50 | const link = requireHTTPS.querySelector('a'); 51 | link.href = window.location.href.replace('http://', 'https://'); 52 | requireHTTPS.classList.remove('hidden'); 53 | } 54 | -------------------------------------------------------------------------------- /js/service-worker.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('install', (event) => { 2 | console.log('👷', 'install', event); 3 | self.skipWaiting(); 4 | }); 5 | 6 | self.addEventListener('activate', (event) => { 7 | console.log('👷', 'activate', event); 8 | return self.clients.claim(); 9 | }); 10 | 11 | self.addEventListener('fetch', function(event) { 12 | // console.log('👷', 'fetch', event); 13 | event.respondWith(fetch(event.request)); 14 | }); -------------------------------------------------------------------------------- /js/ui.js: -------------------------------------------------------------------------------- 1 | // General UI tools (progress bar, toast, prompt) 2 | 3 | /// Handle progress bars 4 | const Progress = { 5 | domElement : null, // the DOM element 6 | sticky : false, // Progress.show({..., sticky:true}) don't remove until Progress.hide({sticky:true}) 7 | interval : undefined, // the interval used if Progress.show({percent:"animate"}) 8 | percent : undefined, // the current progress percentage 9 | min : 0, // scaling for percentage 10 | max : 1, // scaling for percentage 11 | 12 | /* Show a Progress message 13 | Progress.show({ 14 | sticky : bool // keep showing text even when Progress.hide is called (unless Progress.hide({sticky:true})) 15 | percent : number | "animate" 16 | min : // minimum scale for percentage (default 0) 17 | max : // maximum scale for percentage (default 1) 18 | }) */ 19 | show : function(options) { 20 | options = options||{}; 21 | let text = options.title; 22 | if (options.sticky) Progress.sticky = true; 23 | if (options.min!==undefined) Progress.min = options.min; 24 | if (options.max!==undefined) Progress.max = options.max; 25 | let percent = options.percent; 26 | if (percent!==undefined) 27 | percent = Progress.min*100 + (Progress.max-Progress.min)*percent; 28 | if (Progress.interval) { 29 | clearInterval(Progress.interval); 30 | Progress.interval = undefined; 31 | } 32 | if (options.percent == "animate") { 33 | Progress.interval = setInterval(function() { 34 | Progress.percent += 2; 35 | if (Progress.percent>100) Progress.percent=0; 36 | Progress.show({percent:Progress.percent}); 37 | }, 100); 38 | Progress.percent = percent = 0; 39 | } 40 | 41 | if (!Progress.domElement) { 42 | let toastcontainer = document.getElementById("toastcontainer"); 43 | Progress.domElement = htmlElement(`
44 | ${text ? `
${text}
`:``} 45 |
46 |
47 |
48 |
`); 49 | toastcontainer.append(Progress.domElement); 50 | } else { 51 | let pt=document.getElementById("Progress.domElement"); 52 | pt.setAttribute("aria-valuenow",Math.round(percent)); 53 | pt.style.width = percent+"%"; 54 | } 55 | }, 56 | // Progress.hide({sticky:true}) undoes Progress.show({title:"title", sticky:true}) 57 | hide : function(options) { 58 | options = options||{}; 59 | if (Progress.sticky && !options.sticky) 60 | return; 61 | Progress.sticky = false; 62 | Progress.min = 0; 63 | Progress.max = 1; 64 | if (Progress.interval) { 65 | clearInterval(Progress.interval); 66 | Progress.interval = undefined; 67 | } 68 | if (Progress.domElement) Progress.domElement.remove(); 69 | Progress.domElement = undefined; 70 | } 71 | }; 72 | 73 | /// Show a 'toast' message for status 74 | function showToast(message, type, timeout) { 75 | // toast-primary, toast-success, toast-warning or toast-error 76 | console.log("["+(type||"-")+"] "+message); 77 | let style = "toast-primary"; 78 | if (type=="success") style = "toast-success"; 79 | else if (type=="error") style = "toast-error"; 80 | else if (type=="warning") style = "toast-warning"; 81 | else if (type!==undefined) console.log("showToast: unknown toast "+type); 82 | let toastcontainer = document.getElementById("toastcontainer"); 83 | let msgDiv = htmlElement(`
`); 84 | msgDiv.innerHTML = message; 85 | toastcontainer.append(msgDiv); 86 | setTimeout(function() { 87 | msgDiv.remove(); 88 | }, timeout || 5000); 89 | } 90 | 91 | /// Show a yes/no prompt. resolve for true, reject for false 92 | function showPrompt(title, text, buttons, shouldEscapeHtml) { 93 | if (!buttons) buttons={yes:1,no:1}; 94 | if (typeof(shouldEscapeHtml) === 'undefined' || shouldEscapeHtml === null) shouldEscapeHtml = true; 95 | 96 | return new Promise((resolve,reject) => { 97 | let modal = htmlElement(``); 120 | document.body.append(modal); 121 | modal.querySelector("a[href='#close']").addEventListener("click",event => { 122 | event.preventDefault(); 123 | reject("User cancelled"); 124 | modal.remove(); 125 | }); 126 | htmlToArray(modal.getElementsByTagName("button")).forEach(button => { 127 | button.addEventListener("click",event => { 128 | event.preventDefault(); 129 | let isYes = event.target.getAttribute("isyes")=="1"; 130 | if (isYes) resolve(); 131 | else reject("User cancelled"); 132 | modal.remove(); 133 | }); 134 | }); 135 | }); 136 | } 137 | 138 | /// Remove a model prompt 139 | function hidePrompt() { 140 | let modal = document.querySelector(".modal.active"); 141 | if (modal!==null) modal.remove(); 142 | } 143 | -------------------------------------------------------------------------------- /js/utils.js: -------------------------------------------------------------------------------- 1 | const Const = { 2 | /* Are we only putting a single app on a device? If so 3 | apps should all be saved as .bootcde and we write info 4 | about the current app into app.info */ 5 | SINGLE_APP_ONLY : false, 6 | 7 | /* Should the app loader call 'load' after apps have 8 | been uploaded? On Bangle.js we don't do this because we don't 9 | trust the default clock app not to use too many resources. 10 | Note: SINGLE_APP_ONLY=true enables LOAD_APP_AFTER_UPLOAD regardless */ 11 | LOAD_APP_AFTER_UPLOAD : false, 12 | 13 | /* Does our device have E.showMessage? */ 14 | HAS_E_SHOWMESSAGE : true, 15 | 16 | /* JSON file containing all app metadata */ 17 | APPS_JSON_FILE: 'apps.json', 18 | 19 | /* base URL, eg https://github.com/${username}/BangleApps/tree/master/apps for 20 | links when people click on the GitHub link next to an app. undefined = no link*/ 21 | APP_SOURCECODE_URL : undefined, 22 | 23 | /* Message to display when an app has been loaded */ 24 | MESSAGE_RELOAD : 'Hold BTN3\nto reload', 25 | 26 | /* What device are we connecting to Espruino with as far as Espruino is concerned? 27 | Eg if CONNECTION_DEVICE="Bluetooth" will Bluetooth.println("Hi") send data back to us? 28 | Leave this as undefined to try and work it out. */ 29 | CONNECTION_DEVICE : undefined, 30 | 31 | /* The code to upload to the device show a progress bar on the screen (should define a fn. called 'p') */ 32 | CODE_PROGRESSBAR : "g.drawRect(10,g.getHeight()-16,g.getWidth()-10,g.getHeight()-8).flip();p=x=>g.fillRect(10,g.getHeight()-16,10+(g.getWidth()-20)*x/100,g.getHeight()-8).flip();", 33 | 34 | /* Maximum number of apps shown in the library, then a 'Show more...' entry is added.. */ 35 | MAX_APPS_SHOWN : 30, 36 | 37 | /* If true, store files using 'fs' module which is a FAT filesystem on SD card, not on internal Storage */ 38 | FILES_IN_FS : false, 39 | 40 | /* How many bytes of code to we attempt to upload in one go? */ 41 | UPLOAD_CHUNKSIZE: 1024, 42 | 43 | /* How many bytes of code to we attempt to upload when uploading via packets? */ 44 | PACKET_UPLOAD_CHUNKSIZE: 2048, // 1024 is the default for UART.js 45 | 46 | /* when uploading by packets should we wait for an ack before sending the next packet? Only works if you're fully confident in flow control. */ 47 | PACKET_UPLOAD_NOACK: false, 48 | 49 | /* Don't try and reset the device when we're connecting/sending apps */ 50 | NO_RESET : false, 51 | 52 | // APP_DATES_CSV - If set, the URL of a file to get information on the latest apps from 53 | // APP_USAGE_JSON - If set, the URL of a file containing the most-used/most-favourited apps 54 | }; 55 | 56 | let DEVICEINFO = [ 57 | { 58 | id : "BANGLEJS", 59 | name : "Bangle.js 1", 60 | features : ["BLE","BLEHID","GRAPHICS","ACCEL","MAG"], 61 | g : { width : 240, height : 240, bpp : 16 }, 62 | img : "https://www.espruino.com/img/BANGLEJS_thumb.jpg" 63 | }, { 64 | id : "BANGLEJS2", 65 | name : "Bangle.js 2", 66 | features : ["BLE","BLEHID","GRAPHICS","ACCEL","MAG","PRESSURE","TOUCH"], 67 | g : { width : 176, height : 176, bpp : 3 }, 68 | img : "https://www.espruino.com/img/BANGLEJS2_thumb.jpg" 69 | }, { 70 | id : "PUCKJS", 71 | name : "Puck.js", 72 | features : ["BLE","BLEHID","NFC","GYRO","ACCEL","MAG","RGBLED"], 73 | img : "https://www.espruino.com/img/PUCKJS_thumb.jpg" 74 | }, { 75 | id : "PIXLJS", 76 | name : "Pixl.js", 77 | features : ["BLE","BLEHID","NFC","GRAPHICS"], 78 | g : { width : 128, height : 64, bpp : 1 }, 79 | img : "https://www.espruino.com/img/PIXLJS_thumb.jpg" 80 | }, { 81 | id : "JOLTJS", 82 | name : "Jolt.js", 83 | features : ["BLE","BLEHID","RGBLED"], 84 | img : "https://www.espruino.com/img/JOLTJS_thumb.jpg" 85 | }, { 86 | id : "MDBT42Q", 87 | name : "MDBT42Q", 88 | features : ["BLE","BLEHID"], 89 | img : "https://www.espruino.com/img/MDBT42Q_thumb.jpg" 90 | }, { 91 | id : "PICO_R1_3", 92 | name : "Espruino Pico", 93 | features : [], 94 | img : "https://www.espruino.com/img/PICO_R1_3_thumb.jpg" 95 | }, { 96 | id : "ESPRUINOWIFI", 97 | name : "Espruino Wifi", 98 | features : ["WIFI"], 99 | img : "https://www.espruino.com/img/ESPRUINOWIFI_thumb.jpg" 100 | }, { 101 | id : "ESPRUINOBOARD", 102 | name : "Original Espruino", 103 | features : ["RGBLED"], 104 | img : "https://www.espruino.com/img/ESPRUINOBOARD_thumb.jpg" 105 | }, { 106 | id : "MICROBIT2", 107 | name : "micro:bit 2", 108 | features : ["BLE","BLEHID"], // accel/mag/etc don't use an API apps will know 109 | img : "https://www.espruino.com/img/MICROBIT2_thumb.jpg" 110 | }, { 111 | id : "ESP32", 112 | name : "ESP32", 113 | features : ["WIFI","BLE"], 114 | img : "https://www.espruino.com/img/ESP32_thumb.jpg" 115 | } 116 | ]; 117 | 118 | /* When a char is not in Espruino's iso8859-1 codepage, try and use 119 | these conversions */ 120 | const CODEPAGE_CONVERSIONS = { 121 | // letters 122 | "ą":"a", 123 | "ā":"a", 124 | "č":"c", 125 | "ć":"c", 126 | "ě":"e", 127 | "ę":"e", 128 | "ē":"e", 129 | "ģ":"g", 130 | "ğ":"g", 131 | "ī":"i", 132 | "ķ":"k", 133 | "ļ":"l", 134 | "ł":"l", 135 | "ń":"n", 136 | "ņ":"n", 137 | "ő":"o", 138 | "ř":"r", 139 | "ś":"s", 140 | "š":"s", 141 | "ş":"s", 142 | "ū":"u", 143 | "ż":"z", 144 | "ź":"z", 145 | "ž":"z", 146 | "Ą":"A", 147 | "Ā":"A", 148 | "Č":"C", 149 | "Ć":"C", 150 | "Ě":"E", 151 | "Ę":"E", 152 | "Ē":"E", 153 | "Ğ":"G", 154 | "Ģ":"G", 155 | "ı":"i", 156 | "Ķ":"K", 157 | "Ļ":"L", 158 | "Ł":"L", 159 | "Ń":"N", 160 | "Ņ":"N", 161 | "Ő":"O", 162 | "Ř":"R", 163 | "Ś":"S", 164 | "Š":"S", 165 | "Ş":"S", 166 | "Ū":"U", 167 | "Ż":"Z", 168 | "Ź":"Z", 169 | "Ž":"Z", 170 | 171 | // separators 172 | " ":" ", 173 | " ":" ", 174 | }; 175 | 176 | /// Convert any character that cannot be displayed by Espruino's built in fonts 177 | /// originally https://github.com/espruino/EspruinoAppLoaderCore/pull/11/files 178 | function convertStringToISO8859_1(originalStr) { 179 | let chars = originalStr.split(''); 180 | for (let i = 0; i < chars.length; i++) { 181 | let ch = chars[i]; 182 | if (CODEPAGE_CONVERSIONS[ch]) 183 | chars[i] = CODEPAGE_CONVERSIONS[ch]; 184 | else if (chars[i].charCodeAt() > 255) { 185 | console.log("Skipped conversion of char: '" + chars[i] + "'"); 186 | chars[i] = "?"; 187 | } 188 | } 189 | let translatedStr = chars.join(''); 190 | if (translatedStr != originalStr) 191 | console.log("Remapped text: "+originalStr+" -> "+translatedStr); 192 | return translatedStr; 193 | } 194 | 195 | function escapeHtml(text) { 196 | let map = { 197 | '&': '&', 198 | '<': '<', 199 | '>': '>', 200 | '"': '"', 201 | "'": ''' 202 | }; 203 | return text.replace(/[&<>"']/g, function(m) { return map[m]; }); 204 | } 205 | // simple glob to regex conversion, only supports "*" and "?" wildcards 206 | function globToRegex(pattern) { 207 | const ESCAPE = '.*+-?^${}()|[]\\'; 208 | const regex = pattern.replace(/./g, c => { 209 | switch (c) { 210 | case '?': return '.'; 211 | case '*': return '.*'; 212 | default: return ESCAPE.includes(c) ? ('\\' + c) : c; 213 | } 214 | }); 215 | return new RegExp('^'+regex+'$'); 216 | } 217 | function htmlToArray(collection) { 218 | return [].slice.call(collection); 219 | } 220 | function htmlElement(str) { 221 | let div = document.createElement('div'); 222 | div.innerHTML = str.trim(); 223 | return div.firstChild; 224 | } 225 | function httpGet(url) { 226 | let textExtensions = [".js", ".json", ".csv", ".txt", ".md"]; 227 | let isBinary = !textExtensions.some(ext => url.endsWith(ext)); 228 | return new Promise((resolve,reject) => { 229 | let oReq = new XMLHttpRequest(); 230 | oReq.addEventListener("load", () => { 231 | if (oReq.status!=200) { 232 | reject(oReq.status+" - "+oReq.statusText) 233 | return; 234 | } 235 | if (!isBinary) { 236 | resolve(oReq.responseText) 237 | } else { 238 | // ensure we actually load the data as a raw 8 bit string (not utf-8/etc) 239 | let a = new FileReader(); 240 | a.onloadend = function() { 241 | let bytes = new Uint8Array(a.result); 242 | let str = ""; 243 | for (let i=0;i reject()); 251 | oReq.addEventListener("abort", () => reject()); 252 | oReq.open("GET", url, true); 253 | oReq.onerror = function () { 254 | reject("HTTP Request failed"); 255 | }; 256 | if (isBinary) 257 | oReq.responseType = 'blob'; 258 | oReq.send(); 259 | }); 260 | } 261 | function toJSString(s) { 262 | if ("string"!=typeof s) throw new Error("Expecting argument to be a String") 263 | // Could use JSON.stringify, but this doesn't convert char codes that are in UTF8 range 264 | // This is the same logic that we use in Gadgetbridge 265 | let json = "\""; 266 | for (let i=0;i='0' && nextCh<='7') json += "\\x0" + ch; 274 | else json += "\\" + ch; 275 | } else if (ch==8) json += "\\b"; 276 | else if (ch==9) json += "\\t"; 277 | else if (ch==10) json += "\\n"; 278 | else if (ch==11) json += "\\v"; 279 | else if (ch==12) json += "\\f"; 280 | else if (ch==34) json += "\\\""; // quote 281 | else if (ch==92) json += "\\\\"; // slash 282 | else if (ch<32 || ch==127 || ch==173 || 283 | ((ch>=0xC2) && (ch<=0xF4))) // unicode start char range 284 | json += "\\x"+(ch&255).toString(16).padStart(2,0); 285 | else if (ch>255) 286 | json += "\\u"+(ch&65535).toString(16).padStart(4,0); 287 | else json += s[i]; 288 | } 289 | return json + "\""; 290 | } 291 | // callback for sorting apps 292 | function appSorter(a,b) { 293 | if (a.unknown || b.unknown) 294 | return (a.unknown)? 1 : -1; 295 | let sa = 0|a.sortorder; 296 | let sb = 0|b.sortorder; 297 | if (sasb) return 1; 299 | return (a.name==b.name) ? 0 : ((a.namesb) return 1; 313 | return (a.name==b.name) ? 0 : ((a.namep.length); 340 | searchString.split(/[\s-(),.-]/).forEach(search=>{ 341 | valueParts.forEach(v=>{ 342 | if (v==search) 343 | partRelevance += 20; // if a complete match, +20 344 | else { 345 | if (v.includes(search)) // the less of the string matched, lower relevance 346 | partRelevance += Math.max(0, 10 - (v.length - search.length)); 347 | if (v.startsWith(search)) // add a bit of the string starts with it 348 | partRelevance += 10; 349 | } 350 | }); 351 | }); 352 | return relevance + 0|(50*partRelevance/valueParts.length); 353 | } 354 | 355 | /* Given 2 JSON structures (1st from apps.json, 2nd from an installed app) 356 | work out what to display re: versions and if we can update */ 357 | function getVersionInfo(appListing, appInstalled) { 358 | let versionText = ""; 359 | let canUpdate = false; 360 | function clicky(v) { 361 | if (appInstalled) 362 | return `${v}`; 363 | return `${v}`; 364 | } 365 | 366 | if (!appInstalled) { 367 | if (appListing.version) 368 | versionText = clicky("v"+appListing.version); 369 | } else { 370 | versionText = (appInstalled.version ? (clicky("v"+appInstalled.version)) : "Unknown version"); 371 | if (isAppUpdateable(appInstalled, appListing)) { 372 | if (appListing.version) { 373 | versionText += ", latest "+clicky("v"+appListing.version); 374 | canUpdate = true; 375 | } 376 | } 377 | } 378 | return { 379 | text : versionText, 380 | canUpdate : canUpdate 381 | } 382 | } 383 | 384 | function isAppUpdateable(appInstalled, appListing) { 385 | return appInstalled.version && appListing.version && versionLess(appInstalled.version, appListing.version); 386 | } 387 | 388 | function versionLess(a,b) { 389 | let v = x => x.split(/[v.]/).reduce((a,b,c)=>a+parseInt(b,10)/Math.pow(1000,c),0); 390 | return v(a) < v(b); 391 | } 392 | 393 | /* Ensure actualFunction is called after delayInMs, 394 | but don't call it more often than needed if 'debounce' 395 | is called multiple times. */ 396 | function debounce(actualFunction, delayInMs) { 397 | let timeout; 398 | 399 | return function debounced(...args) { 400 | const later = function() { 401 | clearTimeout(timeout); 402 | actualFunction(...args); 403 | }; 404 | 405 | clearTimeout(timeout); 406 | timeout = setTimeout(later, delayInMs); 407 | }; 408 | } 409 | 410 | // version of 'window.atob' that doesn't fail on 'not correctly encoded' strings 411 | function atobSafe(input) { 412 | if (input===undefined) return undefined; 413 | // Copied from https://github.com/strophe/strophejs/blob/e06d027/src/polyfills.js#L149 414 | // This code was written by Tyler Akins and has been placed in the 415 | // public domain. It would be nice if you left this header intact. 416 | // Base64 code from Tyler Akins -- http://rumkin.com 417 | const keyStr = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; 418 | 419 | let output = ''; 420 | let chr1, chr2, chr3; 421 | let enc1, enc2, enc3, enc4; 422 | let i = 0; 423 | // remove all characters that are not A-Z, a-z, 0-9, +, /, or = 424 | input = input.replace(/[^A-Za-z0-9+/=]/g, ''); 425 | while (i < input.length) { 426 | enc1 = keyStr.indexOf(input.charAt(i++)); 427 | enc2 = keyStr.indexOf(input.charAt(i++)); 428 | enc3 = keyStr.indexOf(input.charAt(i++)); 429 | enc4 = keyStr.indexOf(input.charAt(i++)); 430 | 431 | chr1 = (enc1 << 2) | (enc2 >> 4); 432 | chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); 433 | chr3 = ((enc3 & 3) << 6) | enc4; 434 | 435 | output = output + String.fromCharCode(chr1); 436 | 437 | if (enc3 !== 64) { 438 | output = output + String.fromCharCode(chr2); 439 | } 440 | if (enc4 !== 64) { 441 | output = output + String.fromCharCode(chr3); 442 | } 443 | } 444 | return output; 445 | } 446 | 447 | 448 | // parse relaxed JSON which Espruino's writeJSON uses for settings/etc (returns undefined on failure) 449 | function parseRJSON(str) { 450 | let lex = Espruino.Core.Utils.getLexer(str); 451 | let tok = lex.next(); 452 | function match(s) { 453 | if (tok.str!=s) throw new Error("Expecting "+s+" got "+JSON.stringify(tok.str)); 454 | tok = lex.next(); 455 | } 456 | 457 | function recurse() { 458 | let final = ""; 459 | while (tok!==undefined) { 460 | if (tok.type == "NUMBER") { 461 | let v = parseFloat(tok.str); 462 | tok = lex.next(); 463 | return v; 464 | } 465 | if (tok.str == "-") { 466 | tok = lex.next(); 467 | let v = -parseFloat(tok.str); 468 | tok = lex.next(); 469 | return v; 470 | } 471 | if (tok.type == "STRING") { 472 | let v = tok.value; 473 | tok = lex.next(); 474 | return v; 475 | } 476 | if (tok.type == "ID") switch (tok.str) { 477 | case "true" : tok = lex.next(); return true; 478 | case "false" : tok = lex.next(); return false; 479 | case "null" : tok = lex.next(); return null; 480 | } 481 | if (tok.str == "[") { 482 | tok = lex.next(); 483 | let arr = []; 484 | while (tok.str != ']') { 485 | arr.push(recurse()); 486 | if (tok.str != ']') match(","); 487 | } 488 | match("]"); 489 | return arr; 490 | } 491 | if (tok.str == "{") { 492 | tok = lex.next(); 493 | let obj = {}; 494 | while (tok.str != '}') { 495 | let key = tok.type=="STRING" ? tok.value : tok.str; 496 | tok = lex.next(); 497 | match(":"); 498 | obj[key] = recurse(); 499 | if (tok.str != '}') match(","); 500 | } 501 | match("}"); 502 | return obj; 503 | } 504 | match("EOF"); 505 | } 506 | } 507 | 508 | let json = undefined; 509 | try { 510 | json = recurse(); 511 | } catch (e) { 512 | console.log("RJSON parse error", e); 513 | } 514 | return json; 515 | } 516 | 517 | let Utils = { 518 | Const : Const, 519 | DEVICEINFO : DEVICEINFO, 520 | CODEPAGE_CONVERSIONS : CODEPAGE_CONVERSIONS, 521 | convertStringToISO8859_1 : convertStringToISO8859_1, 522 | escapeHtml : escapeHtml, 523 | globToRegex : globToRegex, 524 | htmlToArray : htmlToArray, 525 | htmlElement : htmlElement, 526 | httpGet : httpGet, 527 | toJSString : toJSString, 528 | appSorter : appSorter, 529 | appSorterUpdatesFirst : appSorterUpdatesFirst, 530 | searchRelevance : searchRelevance, 531 | getVersionInfo : getVersionInfo, 532 | isAppUpdateable : isAppUpdateable, 533 | versionLess : versionLess, 534 | debounce : debounce, 535 | atobSafe : atobSafe, // version of 'window.atob' that doesn't fail on 'not correctly encoded' strings 536 | parseRJSON : parseRJSON // parse relaxed JSON which Espruino's writeJSON uses for settings/etc (returns undefined on failure) 537 | }; 538 | 539 | if ("undefined"!=typeof module) 540 | module.exports = Utils; 541 | 542 | -------------------------------------------------------------------------------- /lib/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6, 4 | "sourceType": "script" 5 | }, 6 | "rules": { 7 | "indent": [ 8 | "warn", 9 | 2, 10 | { 11 | "SwitchCase": 1 12 | } 13 | ], 14 | "no-undef": "warn", 15 | "no-redeclare": "warn", 16 | "no-var": "warn", 17 | "no-unused-vars":"off" // we define stuff to use in other scripts 18 | }, 19 | "env": { 20 | "browser": true 21 | }, 22 | "extends": "eslint:recommended", 23 | "globals": { 24 | "onInit" : "readonly" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/apploader.js: -------------------------------------------------------------------------------- 1 | /* Node.js library with utilities to handle using the app loader from node.js */ 2 | /*global exports,global,__dirname,require,Promise */ 3 | 4 | let DEVICEID = "BANGLEJS2"; 5 | let VERSION = "2v11"; 6 | let MINIFY = true; // minify JSON? 7 | let BASE_DIR = __dirname + "/../.."; 8 | let APPSDIR = BASE_DIR+"/apps/"; 9 | 10 | //eval(require("fs").readFileSync(__dirname+"../core/js/utils.js")); 11 | let Espruino = require(__dirname + "/../../core/lib/espruinotools.js"); 12 | //eval(require("fs").readFileSync(__dirname + "/../../core/lib/espruinotools.js").toString()); 13 | //eval(require("fs").readFileSync(__dirname + "/../../core/js/utils.js").toString()); 14 | let AppInfo = require(__dirname+"/../../core/js/appinfo.js"); 15 | 16 | let SETTINGS = { 17 | pretokenise : true 18 | }; 19 | global.Const = { 20 | /* Are we only putting a single app on a device? If so 21 | apps should all be saved as .bootcde and we write info 22 | about the current app into app.info */ 23 | SINGLE_APP_ONLY : false, 24 | }; 25 | 26 | let apps = []; 27 | // eslint-disable-next-line no-redeclare 28 | let device = { id : DEVICEID, appsInstalled : [] }; 29 | let language; // Object of translations 30 | 31 | /* This resets the list of installed apps to an empty list. 32 | It can be used in case the device behind the apploader has changed 33 | after init (i.e. emulator factory reset) so the dependency 34 | resolution does not skip no longer installed apps. 35 | */ 36 | exports.reset = function(){ 37 | device.appsInstalled = []; 38 | } 39 | 40 | /* call with { 41 | DEVICEID:"BANGLEJS/BANGLEJS2" 42 | VERSION:"2v20" 43 | language: undefined / "lang/de_DE.json" 44 | } */ 45 | exports.init = function(options) { 46 | if (options.DEVICEID) { 47 | DEVICEID = options.DEVICEID; 48 | device.id = options.DEVICEID; 49 | } 50 | if (options.VERSION) 51 | VERSION = options.VERSION; 52 | if (options.language) { 53 | language = JSON.parse(require("fs").readFileSync(BASE_DIR+"/"+options.language)); 54 | } 55 | // Try loading from apps.json 56 | apps.length=0; 57 | try { 58 | let appsStr = require("fs").readFileSync(BASE_DIR+"/apps.json"); 59 | let appList = JSON.parse(appsStr); 60 | appList.forEach(a => apps.push(a)); 61 | } catch (e) { 62 | console.log("Couldn't load apps.json", e.toString()); 63 | } 64 | // Load app metadata from each app 65 | if (!apps.length) { 66 | console.log("Loading apps/.../metadata.json"); 67 | let dirs = require("fs").readdirSync(APPSDIR, {withFileTypes: true}); 68 | dirs.forEach(dir => { 69 | let appsFile; 70 | if (dir.name.startsWith("_example") || !dir.isDirectory()) 71 | return; 72 | try { 73 | appsFile = require("fs").readFileSync(APPSDIR+dir.name+"/metadata.json").toString(); 74 | } catch (e) { 75 | console.error(dir.name+"/metadata.json does not exist"); 76 | return; 77 | } 78 | apps.push(JSON.parse(appsFile)); 79 | }); 80 | } 81 | }; 82 | 83 | exports.AppInfo = AppInfo; 84 | exports.apps = apps; 85 | 86 | // used by getAppFiles 87 | function fileGetter(url) { 88 | url = BASE_DIR+"/"+url; 89 | console.log("Loading "+url) 90 | let data; 91 | if (MINIFY && url.endsWith(".json")) { 92 | let f = url.slice(0,-5); 93 | console.log("MINIFYING JSON "+f); 94 | let j = eval("("+require("fs").readFileSync(url).toString("binary")+")"); 95 | data = JSON.stringify(j); // FIXME we can do better for Espruino 96 | } else { 97 | let blob = require("fs").readFileSync(url); 98 | if (url.endsWith(".js") || url.endsWith(".json")) 99 | data = blob.toString(); // allow JS/etc to be written in UTF-8 100 | else 101 | data = blob.toString("binary") 102 | } 103 | return Promise.resolve(data); 104 | } 105 | 106 | exports.getAppFiles = function(app) { 107 | let allFiles = []; 108 | let getFileOptions = { 109 | fileGetter : fileGetter, 110 | settings : SETTINGS, 111 | device : { id : DEVICEID, version : VERSION }, 112 | language : language 113 | }; 114 | let uploadOptions = { 115 | apps : apps, 116 | needsApp : app => { 117 | if (app.provides_modules) { 118 | if (!app.files) app.files=""; 119 | app.files = app.files.split(",").concat(app.provides_modules).join(","); 120 | } 121 | return AppInfo.getFiles(app, getFileOptions).then(files => { allFiles = allFiles.concat(files); return app; }); 122 | }, 123 | showQuery : () => Promise.resolve() 124 | }; 125 | return AppInfo.checkDependencies(app, device, uploadOptions). 126 | then(() => AppInfo.getFiles(app, getFileOptions)). 127 | then(files => { 128 | allFiles = allFiles.concat(files); 129 | return allFiles; 130 | }); 131 | }; 132 | 133 | // Get all the files for this app as a string of Storage.write commands 134 | exports.getAppFilesString = function(app) { 135 | return exports.getAppFiles(app).then(files => { 136 | return files.map(f=>f.cmd).join("\n")+"\n" 137 | }) 138 | }; 139 | -------------------------------------------------------------------------------- /lib/customize.js: -------------------------------------------------------------------------------- 1 | /* Library for 'custom' HTML files that are to 2 | be used from within BangleApps 3 | 4 | See: README.md / `apps.json`: `custom` element 5 | 6 | Call sendCustomizedApp with a JS object when the app is read to be sent: 7 | 8 | sendCustomizedApp({ 9 | id : "7chname", 10 | storage:[ 11 | {name:"-7chname", content:app_source_code}, 12 | {name:"+7chname", content:JSON.stringify({ 13 | name:"My app's name", 14 | icon:"*7chname", 15 | src:"-7chname" 16 | })}, 17 | {name:"*7chname", content:'require("heatshrink").decompress(atob("mEwg...4"))', evaluate:true}, 18 | ] 19 | }); 20 | 21 | If you define an `onInit` function, this is called 22 | with information about the currently connected device, 23 | for instance: 24 | 25 | ``` 26 | onInit({ 27 | id : "BANGLEJS", 28 | version : "2v10", 29 | appsInstalled : [ 30 | {id: "boot", version: "0.28"}, 31 | ... 32 | ] 33 | }); 34 | ``` 35 | 36 | Pass `{ noFinish: true }` as the second argument to skip reloading 37 | the connected device. 38 | 39 | If no device is connected, some fields may not be populated. 40 | 41 | This exposes a 'Puck' object (a simple version of 42 | https://github.com/espruino/EspruinoWebTools/blob/master/puck.js) 43 | and calls `onInit` when it's ready. `Puck` can be used for 44 | sending/receiving data to the correctly connected 45 | device with Puck.eval/write. 46 | 47 | Puck.write(data,callback) 48 | Puck.eval(data,callback) 49 | 50 | There is also: 51 | 52 | Util.close() // close this window 53 | Util.readStorage(filename,callback) // read a file from the Bangle, callback with string 54 | Util.readStorageJSON(filename,callback) // read a file from the Bangle and parse JSON, callback with parsed object 55 | Util.writeStorage(filename,data, callback) // write a file to the Bangle, callback when done 56 | Util.eraseStorage(filename,callback) // erase a file on the Bangle 57 | Util.readStorageFile(filename,callback) // read a StorageFile (not just a normal file) 58 | Util.eraseStorageFile(filename,callback) // erase a StorageFile 59 | saveFile(filename, mimeType, dataAsString) // pop up a dialog to save a file (needs it a mimeType like "application/json") 60 | Util.saveCSV(filename, csvData) // pop up a dialog to save a CSV file of data 61 | Util.showModal(title) // show a modal screen over everything in this window 62 | Util.hideModal() // hide the modal from showModal 63 | */ 64 | function sendCustomizedApp(app, options) { 65 | console.log(" sending app"); 66 | window.postMessage({ 67 | type : "app", 68 | data : app, 69 | options 70 | }); 71 | } 72 | 73 | let __id = 0, __idlookup = []; 74 | // eslint-disable-next-line no-redeclare 75 | const Puck = { 76 | eval : function(data,callback) { 77 | __id++; 78 | __idlookup[__id] = callback; 79 | window.postMessage({type:"eval",data:data,id:__id}); 80 | }, 81 | write : function(data,callback) { 82 | __id++; 83 | __idlookup[__id] = callback; 84 | window.postMessage({type:"write",data:data,id:__id}); 85 | }, 86 | // fake EventEmitter 87 | handlers : {}, 88 | on : function(id, callback) { 89 | if (this.handlers[id]===undefined) 90 | this.handlers[id] = []; 91 | this.handlers[id].push(callback); 92 | }, 93 | emit : function(id, data) { 94 | if (this.handlers[id]!==undefined) 95 | this.handlers[id].forEach(cb => cb(data)); 96 | } 97 | }; 98 | // eslint-disable-next-line no-redeclare 99 | const UART = Puck; 100 | 101 | const Util = { 102 | close : function() { // request a close of this window 103 | __id++; 104 | window.postMessage({type:"close",id:__id}); 105 | }, 106 | readStorageFile : function(filename,callback) { 107 | __id++; 108 | __idlookup[__id] = callback; 109 | window.postMessage({type:"readstoragefile",filename:filename,id:__id}); 110 | }, 111 | readStorage : function(filename,callback) { 112 | __id++; 113 | __idlookup[__id] = callback; 114 | window.postMessage({type:"readstorage",filename:filename,id:__id}); 115 | }, 116 | readStorageJSON : function(filename,callback) { 117 | __id++; 118 | __idlookup[__id] = callback; 119 | window.postMessage({type:"readstoragejson",filename:filename,id:__id}); 120 | }, 121 | writeStorage : function(filename,data,callback) { 122 | __id++; 123 | __idlookup[__id] = callback; 124 | window.postMessage({type:"writestorage",filename:filename,data:data,id:__id}); 125 | }, 126 | eraseStorageFile : function(filename,callback) { 127 | Puck.write(`\x10require("Storage").open(${JSON.stringify(filename)},"r").erase()\n`,callback); 128 | }, 129 | eraseStorage : function(filename,callback) { 130 | Puck.write(`\x10require("Storage").erase(${JSON.stringify(filename)})\n`,callback); 131 | }, 132 | showModal : function(title) { 133 | if (!Util.domModal) { 134 | Util.domModal = document.createElement('div'); 135 | Util.domModal.id = "status-modal"; 136 | Util.domModal.classList.add("modal"); 137 | Util.domModal.classList.add("active"); 138 | Util.domModal.innerHTML = ` 139 | `; 149 | document.body.appendChild(Util.domModal); 150 | } 151 | Util.domModal.querySelector(".content").innerHTML = title; 152 | Util.domModal.classList.add("active"); 153 | }, 154 | hideModal : function() { 155 | if (!Util.domModal) return; 156 | Util.domModal.classList.remove("active"); 157 | }, 158 | saveFile : function saveFile(filename, mimeType, dataAsString) { 159 | /*global Android*/ 160 | if (typeof Android !== "undefined" && typeof Android.saveFile === 'function') { 161 | // Recent Gadgetbridge version that provides the saveFile interface 162 | Android.saveFile(filename, mimeType, btoa(dataAsString)); 163 | return; 164 | } 165 | 166 | let a = document.createElement("a"); 167 | // Blob downloads don't work under Gadgetbridge 168 | //let file = new Blob([dataAsString], {type: mimeType}); 169 | //let url = URL.createObjectURL(file); 170 | let url = 'data:' + mimeType + ';base64,' + btoa(dataAsString); 171 | a.href = url; 172 | a.download = filename; 173 | document.body.appendChild(a); 174 | a.click(); 175 | setTimeout(function() { 176 | document.body.removeChild(a); 177 | window.URL.revokeObjectURL(url); 178 | }, 0); 179 | }, 180 | saveCSV : function(filename, csvData) { 181 | this.saveFile(filename+".csv", 'text/csv', csvData); 182 | } 183 | }; 184 | window.addEventListener("message", function(event) { 185 | let msg = event.data; 186 | if (msg.type=="init") { 187 | console.log(" init message received", msg.data); 188 | if (msg.expectedInterface != "customize.js") 189 | console.error(" WRONG FILE IS INCLUDED, use "+msg.expectedInterface+" instead"); 190 | if ("undefined"!==typeof onInit) 191 | onInit(msg.data); 192 | } else if (msg.type=="evalrsp" || 193 | msg.type=="writersp" || 194 | msg.type=="readstoragefilersp" || 195 | msg.type=="readstoragersp" || 196 | msg.type=="readstoragejsonrsp" || 197 | msg.type=="writestoragersp") { 198 | let cb = __idlookup[msg.id]; 199 | delete __idlookup[msg.id]; 200 | if (cb) cb(msg.data); 201 | } else if (msg.type=="recvdata") { 202 | Puck.emit("data", msg.data); 203 | } 204 | }, false); 205 | 206 | // version of 'window.atob' that doesn't fail on 'not correctly encoded' strings 207 | function atobSafe(input) { 208 | // Copied from https://github.com/strophe/strophejs/blob/e06d027/src/polyfills.js#L149 209 | // This code was written by Tyler Akins and has been placed in the 210 | // public domain. It would be nice if you left this header intact. 211 | // Base64 code from Tyler Akins -- http://rumkin.com 212 | let keyStr = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; 213 | let output = ''; 214 | let chr1, chr2, chr3, enc1, enc2, enc3, enc4; 215 | let i = 0; 216 | // remove all characters that are not A-Z, a-z, 0-9, +, /, or = 217 | input = input.replace(/[^A-Za-z0-9+/=]/g, ''); 218 | do { 219 | enc1 = keyStr.indexOf(input.charAt(i++)); 220 | enc2 = keyStr.indexOf(input.charAt(i++)); 221 | enc3 = keyStr.indexOf(input.charAt(i++)); 222 | enc4 = keyStr.indexOf(input.charAt(i++)); 223 | 224 | chr1 = (enc1 << 2) | (enc2 >> 4); 225 | chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); 226 | chr3 = ((enc3 & 3) << 6) | enc4; 227 | 228 | output = output + String.fromCharCode(chr1); 229 | 230 | if (enc3 !== 64) { 231 | output = output + String.fromCharCode(chr2); 232 | } 233 | if (enc4 !== 64) { 234 | output = output + String.fromCharCode(chr3); 235 | } 236 | } while (i < input.length); 237 | return output; 238 | } 239 | -------------------------------------------------------------------------------- /lib/emulator.js: -------------------------------------------------------------------------------- 1 | /* Node.js library with utilities to handle using the emulator from node.js */ 2 | /*global exports,__dirname,Promise,require,Uint8Array,Uint32Array */ 3 | /*global jsRXCallback:writable,jsUpdateGfx:writable,jsTransmitString,jsInit,jsIdle,jsStopIdle,jsGetGfxContents,flashMemory */ 4 | /*global FLASH_SIZE,GFX_WIDTH,GFX_HEIGHT */ 5 | 6 | let EMULATOR = "banglejs2"; 7 | let DEVICEID = "BANGLEJS2"; 8 | 9 | let BASE_DIR = __dirname + "/../.."; 10 | let DIR_IDE = BASE_DIR + "/../EspruinoWebIDE"; 11 | 12 | /* we factory reset ONCE, get this, then we can use it to reset 13 | state quickly for each new app */ 14 | let factoryFlashMemory; 15 | 16 | // Log of messages from app 17 | let appLog = ""; 18 | let lastOutputLine = ""; 19 | let consoleOutputCallback; 20 | 21 | function onConsoleOutput(txt) { 22 | appLog += txt + "\n"; 23 | lastOutputLine = txt; 24 | if (consoleOutputCallback) 25 | consoleOutputCallback(txt); 26 | else 27 | console.log("EMSCRIPTEN:", txt); 28 | } 29 | 30 | /* Initialise the emulator, 31 | 32 | options = { 33 | EMULATOR : "banglejs"/"banglejs2" 34 | DEVICEID : "BANGLEJS"/"BANGLEJS2" 35 | rxCallback : function(int) - called every time a character received 36 | consoleOutputCallback : function(str) - called when a while line is received 37 | } 38 | */ 39 | exports.init = function(options) { 40 | if (options.EMULATOR) 41 | EMULATOR = options.EMULATOR; 42 | if (options.DEVICEID) 43 | DEVICEID = options.DEVICEID; 44 | 45 | eval(require("fs").readFileSync(DIR_IDE + "/emu/emulator_"+EMULATOR+".js").toString()); 46 | eval(require("fs").readFileSync(DIR_IDE + "/emu/emu_"+EMULATOR+".js").toString()); 47 | eval(require("fs").readFileSync(DIR_IDE + "/emu/common.js").toString()/*.replace('console.log("EMSCRIPTEN:"', '//console.log("EMSCRIPTEN:"')*/); 48 | 49 | jsRXCallback = options.rxCallback ? options.rxCallback : function() {}; 50 | jsUpdateGfx = function() {}; 51 | if (options.consoleOutputCallback) 52 | consoleOutputCallback = options.consoleOutputCallback; 53 | 54 | factoryFlashMemory = new Uint8Array(FLASH_SIZE); 55 | factoryFlashMemory.fill(255); 56 | 57 | exports.flashMemory = flashMemory; 58 | exports.GFX_WIDTH = GFX_WIDTH; 59 | exports.GFX_HEIGHT = GFX_HEIGHT; 60 | exports.tx = jsTransmitString; 61 | exports.idle = jsIdle; 62 | exports.stopIdle = jsStopIdle; 63 | exports.getGfxContents = jsGetGfxContents; 64 | 65 | return new Promise(resolve => { 66 | setTimeout(function() { 67 | console.log("Emulator Loaded..."); 68 | jsInit(); 69 | jsIdle(); 70 | console.log("Emulator Factory reset"); 71 | exports.tx("Bangle.factoryReset()\n"); 72 | factoryFlashMemory.set(flashMemory); 73 | console.log("Emulator Ready!"); 74 | 75 | resolve(); 76 | },0); 77 | }); 78 | }; 79 | 80 | // Factory reset 81 | exports.factoryReset = function() { 82 | exports.flashMemory.set(factoryFlashMemory); 83 | exports.tx("reset()\n"); 84 | appLog=""; 85 | }; 86 | 87 | // Transmit a string 88 | exports.tx = function() {}; // placeholder 89 | exports.idle = function() {}; // placeholder 90 | exports.stopIdle = function() {}; // placeholder 91 | exports.getGfxContents = function() {}; // placeholder 92 | 93 | exports.flashMemory = undefined; // placeholder 94 | exports.GFX_WIDTH = undefined; // placeholder 95 | exports.GFX_HEIGHT = undefined; // placeholder 96 | 97 | // Get last line sent to console 98 | exports.getLastLine = function() { 99 | return lastOutputLine; 100 | }; 101 | 102 | // Gets the screenshot as RGBA Uint32Array 103 | exports.getScreenshot = function() { 104 | let rgba = new Uint8Array(exports.GFX_WIDTH*exports.GFX_HEIGHT*4); 105 | exports.getGfxContents(rgba); 106 | let rgba32 = new Uint32Array(rgba.buffer); 107 | return rgba32; 108 | } 109 | 110 | // Write the screenshot to a file options={errorIfBlank} 111 | exports.writeScreenshot = function(imageFn, options) { 112 | options = options||{}; 113 | return new Promise((resolve,reject) => { 114 | let rgba32 = exports.getScreenshot(); 115 | 116 | if (options.errorIfBlank) { 117 | let firstPixel = rgba32[0]; 118 | let blankImage = rgba32.every(col=>col==firstPixel); 119 | if (blankImage) reject("Image is blank"); 120 | } 121 | 122 | let Jimp = require("jimp"); 123 | let image = new Jimp(exports.GFX_WIDTH, exports.GFX_HEIGHT, function (err, image) { 124 | if (err) throw err; 125 | let buffer = image.bitmap.data; 126 | buffer.set(new Uint8Array(rgba32.buffer)); 127 | image.write(imageFn, (err) => { 128 | if (err) return reject(err); 129 | console.log("Image written as "+imageFn); 130 | resolve(); 131 | }); 132 | }); 133 | }); 134 | } 135 | -------------------------------------------------------------------------------- /lib/interface.js: -------------------------------------------------------------------------------- 1 | /* Library for 'interface' HTML files that are to 2 | be used from within BangleApps 3 | 4 | See: README.md / `apps.json`: `interface` element 5 | 6 | If you define an `onInit` function, this is called 7 | with information about the currently connected device, 8 | for instance: 9 | 10 | ``` 11 | onInit({ 12 | id : "BANGLEJS", 13 | version : "2v10", 14 | appsInstalled : [ 15 | {id: "boot", version: "0.28"}, 16 | ... 17 | ] 18 | }); 19 | ``` 20 | 21 | If no device is connected, some fields may not be populated. 22 | 23 | This exposes a 'Puck' object (a simple version of 24 | https://github.com/espruino/EspruinoWebTools/blob/master/puck.js) 25 | and calls `onInit` when it's ready. `Puck` can be used for 26 | sending/receiving data to the correctly connected 27 | device with Puck.eval/write. 28 | 29 | Puck.write(data,callback) 30 | Puck.eval(data,callback) 31 | 32 | There is also: 33 | 34 | Util.close() // close this window 35 | Util.readStorage(filename,callback) // read a file from the Bangle, callback with string 36 | Util.readStorageJSON(filename,callback) // read a file from the Bangle and parse JSON, callback with parsed object 37 | Util.writeStorage(filename,data, callback) // write a file to the Bangle, callback when done 38 | Util.eraseStorage(filename,callback) // erase a file on the Bangle 39 | Util.readStorageFile(filename,callback) // read a StorageFile (not just a normal file) 40 | Util.eraseStorageFile(filename,callback) // erase a StorageFile 41 | saveFile(filename, mimeType, dataAsString) // pop up a dialog to save a file (needs it a mimeType like "application/json") 42 | Util.saveCSV(filename, csvData) // pop up a dialog to save a CSV file of data 43 | Util.showModal(title) // show a modal screen over everything in this window 44 | Util.hideModal() // hide the modal from showModal 45 | */ 46 | let __id = 0, __idlookup = []; 47 | // eslint-disable-next-line no-redeclare 48 | const Puck = { 49 | eval : function(data,callback) { 50 | __id++; 51 | __idlookup[__id] = callback; 52 | window.postMessage({type:"eval",data:data,id:__id}); 53 | }, 54 | write : function(data,callback) { 55 | __id++; 56 | __idlookup[__id] = callback; 57 | window.postMessage({type:"write",data:data,id:__id}); 58 | }, 59 | // fake EventEmitter 60 | handlers : {}, 61 | on : function(id, callback) { 62 | if (this.handlers[id]===undefined) 63 | this.handlers[id] = []; 64 | this.handlers[id].push(callback); 65 | }, 66 | emit : function(id, data) { 67 | if (this.handlers[id]!==undefined) 68 | this.handlers[id].forEach(cb => cb(data)); 69 | } 70 | }; 71 | // eslint-disable-next-line no-redeclare 72 | const UART = Puck; 73 | 74 | const Util = { 75 | close : function() { // request a close of this window 76 | __id++; 77 | window.postMessage({type:"close",id:__id}); 78 | }, 79 | readStorageFile : function(filename,callback) { 80 | __id++; 81 | __idlookup[__id] = callback; 82 | window.postMessage({type:"readstoragefile",filename:filename,id:__id}); 83 | }, 84 | readStorage : function(filename,callback) { 85 | __id++; 86 | __idlookup[__id] = callback; 87 | window.postMessage({type:"readstorage",filename:filename,id:__id}); 88 | }, 89 | readStorageJSON : function(filename,callback) { 90 | __id++; 91 | __idlookup[__id] = callback; 92 | window.postMessage({type:"readstoragejson",filename:filename,id:__id}); 93 | }, 94 | writeStorage : function(filename,data,callback) { 95 | __id++; 96 | __idlookup[__id] = callback; 97 | window.postMessage({type:"writestorage",filename:filename,data:data,id:__id}); 98 | }, 99 | eraseStorageFile : function(filename,callback) { 100 | Puck.write(`\x10require("Storage").open(${JSON.stringify(filename)},"r").erase()\n`,callback); 101 | }, 102 | eraseStorage : function(filename,callback) { 103 | Puck.write(`\x10require("Storage").erase(${JSON.stringify(filename)})\n`,callback); 104 | }, 105 | showModal : function(title) { 106 | if (!Util.domModal) { 107 | Util.domModal = document.createElement('div'); 108 | Util.domModal.id = "status-modal"; 109 | Util.domModal.classList.add("modal"); 110 | Util.domModal.classList.add("active"); 111 | Util.domModal.innerHTML = ` 112 | `; 122 | document.body.appendChild(Util.domModal); 123 | } 124 | Util.domModal.querySelector(".content").innerHTML = title; 125 | Util.domModal.classList.add("active"); 126 | }, 127 | hideModal : function() { 128 | if (!Util.domModal) return; 129 | Util.domModal.classList.remove("active"); 130 | }, 131 | saveFile : function saveFile(filename, mimeType, dataAsString) { 132 | /*global Android*/ 133 | if (typeof Android !== "undefined" && typeof Android.saveFile === 'function') { 134 | // Recent Gadgetbridge version that provides the saveFile interface 135 | Android.saveFile(filename, mimeType, btoa(dataAsString)); 136 | return; 137 | } 138 | 139 | let a = document.createElement("a"); 140 | // Blob downloads don't work under Gadgetbridge 141 | //let file = new Blob([dataAsString], {type: mimeType}); 142 | //let url = URL.createObjectURL(file); 143 | let url = 'data:' + mimeType + ';base64,' + btoa(dataAsString); 144 | a.href = url; 145 | a.download = filename; 146 | document.body.appendChild(a); 147 | a.click(); 148 | setTimeout(function() { 149 | document.body.removeChild(a); 150 | window.URL.revokeObjectURL(url); 151 | }, 0); 152 | }, 153 | saveCSV : function(filename, csvData) { 154 | this.saveFile(filename+".csv", 'text/csv', csvData); 155 | } 156 | }; 157 | window.addEventListener("message", function(event) { 158 | let msg = event.data; 159 | if (msg.type=="init") { 160 | console.log(" init message received", msg.data); 161 | if (msg.expectedInterface != "interface.js") 162 | console.error(" WRONG FILE IS INCLUDED, use "+msg.expectedInterface+" instead"); 163 | if ("undefined"!==typeof onInit) 164 | onInit(msg.data); 165 | } else if (msg.type=="evalrsp" || 166 | msg.type=="writersp" || 167 | msg.type=="readstoragefilersp" || 168 | msg.type=="readstoragersp" || 169 | msg.type=="readstoragejsonrsp" || 170 | msg.type=="writestoragersp") { 171 | let cb = __idlookup[msg.id]; 172 | delete __idlookup[msg.id]; 173 | if (cb) cb(msg.data); 174 | } else if (msg.type=="recvdata") { 175 | Puck.emit("data", msg.data); 176 | } 177 | }, false); 178 | 179 | // version of 'window.atob' that doesn't fail on 'not correctly encoded' strings 180 | function atobSafe(input) { 181 | // Copied from https://github.com/strophe/strophejs/blob/e06d027/src/polyfills.js#L149 182 | // This code was written by Tyler Akins and has been placed in the 183 | // public domain. It would be nice if you left this header intact. 184 | // Base64 code from Tyler Akins -- http://rumkin.com 185 | let keyStr = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; 186 | let output = ''; 187 | let chr1, chr2, chr3, enc1, enc2, enc3, enc4; 188 | let i = 0; 189 | // remove all characters that are not A-Z, a-z, 0-9, +, /, or = 190 | input = input.replace(/[^A-Za-z0-9+/=]/g, ''); 191 | do { 192 | enc1 = keyStr.indexOf(input.charAt(i++)); 193 | enc2 = keyStr.indexOf(input.charAt(i++)); 194 | enc3 = keyStr.indexOf(input.charAt(i++)); 195 | enc4 = keyStr.indexOf(input.charAt(i++)); 196 | 197 | chr1 = (enc1 << 2) | (enc2 >> 4); 198 | chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); 199 | chr3 = ((enc3 & 3) << 6) | enc4; 200 | 201 | output = output + String.fromCharCode(chr1); 202 | 203 | if (enc3 !== 64) { 204 | output = output + String.fromCharCode(chr2); 205 | } 206 | if (enc4 !== 64) { 207 | output = output + String.fromCharCode(chr3); 208 | } 209 | } while (i < input.length); 210 | return output; 211 | } 212 | -------------------------------------------------------------------------------- /lib/marked.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * marked - a markdown parser 3 | * Copyright (c) 2011-2020, Christopher Jeffrey. (MIT Licensed) 4 | * https://github.com/markedjs/marked 5 | */ 6 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).marked=t()}(this,function(){"use strict";function s(e,t){for(var n=0;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[t++]}};throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function n(e){return c[e]}var e,t=(function(t){function e(){return{baseUrl:null,breaks:!1,gfm:!0,headerIds:!0,headerPrefix:"",highlight:null,langPrefix:"language-",mangle:!0,pedantic:!1,renderer:null,sanitize:!1,sanitizer:null,silent:!1,smartLists:!1,smartypants:!1,tokenizer:null,walkTokens:null,xhtml:!1}}t.exports={defaults:e(),getDefaults:e,changeDefaults:function(e){t.exports.defaults=e}}}(e={exports:{}}),e.exports),i=(t.defaults,t.getDefaults,t.changeDefaults,/[&<>"']/),a=/[&<>"']/g,l=/[<>"']|&(?!#?\w+;)/,o=/[<>"']|&(?!#?\w+;)/g,c={"&":"&","<":"<",">":">",'"':""","'":"'"};var h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function u(e){return e.replace(h,function(e,t){return"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""})}var p=/(^|[^\[])\^/g;var f=/[^\w:]/g,d=/^$|^[a-z][a-z0-9+.-]*:|^[?#]/i;var k={},b=/^[^:]+:\/*[^/]*$/,m=/^([^:]+:)[\s\S]*$/,x=/^([^:]+:\/*[^/]*)[\s\S]*$/;function w(e,t){k[" "+e]||(b.test(e)?k[" "+e]=e+"/":k[" "+e]=v(e,"/",!0));var n=-1===(e=k[" "+e]).indexOf(":");return"//"===t.substring(0,2)?n?t:e.replace(m,"$1")+t:"/"===t.charAt(0)?n?t:e.replace(x,"$1")+t:e+t}function v(e,t,n){var r=e.length;if(0===r)return"";for(var i=0;it)n.splice(t);else for(;n.length=r.length?e.slice(r.length):e}).join("\n")}(n,t[3]||"");return{type:"code",raw:n,lang:t[2]?t[2].trim():t[2],text:r}}},t.heading=function(e){var t=this.rules.block.heading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[1].length,text:t[2]}},t.nptable=function(e){var t=this.rules.block.nptable.exec(e);if(t){var n={type:"table",header:O(t[1].replace(/^ *| *\| *$/g,"")),align:t[2].replace(/^ *|\| *$/g,"").split(/ *\| */),cells:t[3]?t[3].replace(/\n$/,"").split("\n"):[],raw:t[0]};if(n.header.length===n.align.length){for(var r=n.align.length,i=0;i ?/gm,"");return{type:"blockquote",raw:t[0],text:n}}},t.list=function(e){var t=this.rules.block.list.exec(e);if(t){for(var n,r,i,s,a,l,o,c=t[0],h=t[2],u=1/i.test(r[0])&&(t=!1),!n&&/^<(pre|code|kbd|script)(\s|>)/i.test(r[0])?n=!0:n&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(r[0])&&(n=!1),{type:this.options.sanitize?"text":"html",raw:r[0],inLink:t,inRawBlock:n,text:this.options.sanitize?this.options.sanitizer?this.options.sanitizer(r[0]):C(r[0]):r[0]}},t.link=function(e){var t=this.rules.inline.link.exec(e);if(t){var n,r=j(t[2],"()");-1$/,"$1"))?s.replace(this.rules.inline._escapes,"$1"):s,title:a?a.replace(this.rules.inline._escapes,"$1"):a},t[0])}},t.reflink=function(e,t){var n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){var r=(n[2]||n[1]).replace(/\s+/g," ");if((r=t[r.toLowerCase()])&&r.href)return E(n,r,n[0]);var i=n[0].charAt(0);return{type:"text",raw:i,text:i}}},t.strong=function(e){var t=this.rules.inline.strong.exec(e);if(t)return{type:"strong",raw:t[0],text:t[4]||t[3]||t[2]||t[1]}},t.em=function(e){var t=this.rules.inline.em.exec(e);if(t)return{type:"em",raw:t[0],text:t[6]||t[5]||t[4]||t[3]||t[2]||t[1]}},t.codespan=function(e){var t=this.rules.inline.code.exec(e);if(t){var n=t[2].replace(/\n/g," "),r=/[^ ]/.test(n),i=n.startsWith(" ")&&n.endsWith(" ");return r&&i&&(n=n.substring(1,n.length-1)),n=C(n,!0),{type:"codespan",raw:t[0],text:n}}},t.br=function(e){var t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}},t.del=function(e){var t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[1]}},t.autolink=function(e,t){var n=this.rules.inline.autolink.exec(e);if(n){var r,i="@"===n[2]?"mailto:"+(r=C(this.options.mangle?t(n[1]):n[1])):r=C(n[1]);return{type:"link",raw:n[0],text:r,href:i,tokens:[{type:"text",raw:r,text:r}]}}},t.url=function(e,t){var n,r,i,s;if(n=this.rules.inline.url.exec(e)){if("@"===n[2])i="mailto:"+(r=C(this.options.mangle?t(n[0]):n[0]));else{for(;s=n[0],n[0]=this.rules.inline._backpedal.exec(n[0])[0],s!==n[0];);r=C(n[0]),i="www."===n[1]?"http://"+r:r}return{type:"link",raw:n[0],text:r,href:i,tokens:[{type:"text",raw:r,text:r}]}}},t.inlineText=function(e,t,n){var r=this.rules.inline.text.exec(e);if(r){var i=t?this.options.sanitize?this.options.sanitizer?this.options.sanitizer(r[0]):C(r[0]):r[0]:C(this.options.smartypants?n(r[0]):r[0]);return{type:"text",raw:r[0],text:i}}},e}(),L=S,P=z,U=A,B={newline:/^\n+/,code:/^( {4}[^\n]+\n*)+/,fences:/^ {0,3}(`{3,}(?=[^`\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~`]* *(?:\n+|$)|$)/,hr:/^ {0,3}((?:- *){3,}|(?:_ *){3,}|(?:\* *){3,})(?:\n+|$)/,heading:/^ {0,3}(#{1,6}) +([^\n]*?)(?: +#+)? *(?:\n+|$)/,blockquote:/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/,list:/^( {0,3})(bull) [\s\S]+?(?:hr|def|\n{2,}(?! )(?!\1bull )\n*|\s*$)/,html:"^ {0,3}(?:<(script|pre|style)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?\\?>\\n*|\\n*|\\n*|)[\\s\\S]*?(?:\\n{2,}|$)|<(?!script|pre|style)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:\\n{2,}|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:\\n{2,}|$))",def:/^ {0,3}\[(label)\]: *\n? *]+)>?(?:(?: +\n? *| *\n *)(title))? *(?:\n+|$)/,nptable:L,table:L,lheading:/^([^\n]+)\n {0,3}(=+|-+) *(?:\n+|$)/,_paragraph:/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html)[^\n]+)*)/,text:/^[^\n]+/,_label:/(?!\s*\])(?:\\[\[\]]|[^\[\]])+/,_title:/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/};B.def=P(B.def).replace("label",B._label).replace("title",B._title).getRegex(),B.bullet=/(?:[*+-]|\d{1,9}\.)/,B.item=/^( *)(bull) ?[^\n]*(?:\n(?!\1bull ?)[^\n]*)*/,B.item=P(B.item,"gm").replace(/bull/g,B.bullet).getRegex(),B.list=P(B.list).replace(/bull/g,B.bullet).replace("hr","\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))").replace("def","\\n+(?="+B.def.source+")").getRegex(),B._tag="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",B._comment=//,B.html=P(B.html,"i").replace("comment",B._comment).replace("tag",B._tag).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),B.paragraph=P(B._paragraph).replace("hr",B.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|!--)").replace("tag",B._tag).getRegex(),B.blockquote=P(B.blockquote).replace("paragraph",B.paragraph).getRegex(),B.normal=U({},B),B.gfm=U({},B.normal,{nptable:"^ *([^|\\n ].*\\|.*)\\n *([-:]+ *\\|[-| :]*)(?:\\n((?:(?!\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)",table:"^ *\\|(.+)\\n *\\|?( *[-:]+[-| :]*)(?:\\n *((?:(?!\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)"}),B.gfm.nptable=P(B.gfm.nptable).replace("hr",B.hr).replace("heading"," {0,3}#{1,6} ").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|!--)").replace("tag",B._tag).getRegex(),B.gfm.table=P(B.gfm.table).replace("hr",B.hr).replace("heading"," {0,3}#{1,6} ").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|!--)").replace("tag",B._tag).getRegex(),B.pedantic=U({},B.normal,{html:P("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",B._comment).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^ *(#{1,6}) *([^\n]+?) *(?:#+ *)?(?:\n+|$)/,fences:L,paragraph:P(B.normal._paragraph).replace("hr",B.hr).replace("heading"," *#{1,6} *[^\n]").replace("lheading",B.lheading).replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").getRegex()});var F={escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,autolink:/^<(scheme:[^\s\x00-\x1f<>]*|email)>/,url:L,tag:"^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^",link:/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,reflink:/^!?\[(label)\]\[(?!\s*\])((?:\\[\[\]]?|[^\[\]\\])+)\]/,nolink:/^!?\[(?!\s*\])((?:\[[^\[\]]*\]|\\[\[\]]|[^\[\]])*)\](?:\[\])?/,strong:/^__([^\s_])__(?!_)|^\*\*([^\s*])\*\*(?!\*)|^__([^\s][\s\S]*?[^\s])__(?!_)|^\*\*([^\s][\s\S]*?[^\s])\*\*(?!\*)/,em:/^_([^\s_])_(?!_)|^_([^\s_<][\s\S]*?[^\s_])_(?!_|[^\s,punctuation])|^_([^\s_<][\s\S]*?[^\s])_(?!_|[^\s,punctuation])|^\*([^\s*<\[])\*(?!\*)|^\*([^\s<"][\s\S]*?[^\s\[\*])\*(?![\]`punctuation])|^\*([^\s*"<\[][\s\S]*[^\s])\*(?!\*)/,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,br:/^( {2,}|\\)\n(?!\s*$)/,del:L,text:/^(`+|[^`])(?:[\s\S]*?(?:(?=[\\?@\\[^_{|}~"};F.em=P(F.em).replace(/punctuation/g,F._punctuation).getRegex(),F._escapes=/\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g,F._scheme=/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/,F._email=/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/,F.autolink=P(F.autolink).replace("scheme",F._scheme).replace("email",F._email).getRegex(),F._attribute=/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/,F.tag=P(F.tag).replace("comment",B._comment).replace("attribute",F._attribute).getRegex(),F._label=/(?:\[[^\[\]]*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,F._href=/<(?:\\[<>]?|[^\s<>\\])*>|[^\s\x00-\x1f]*/,F._title=/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/,F.link=P(F.link).replace("label",F._label).replace("href",F._href).replace("title",F._title).getRegex(),F.reflink=P(F.reflink).replace("label",F._label).getRegex(),F.normal=U({},F),F.pedantic=U({},F.normal,{strong:/^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,em:/^_(?=\S)([\s\S]*?\S)_(?!_)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/,link:P(/^!?\[(label)\]\((.*?)\)/).replace("label",F._label).getRegex(),reflink:P(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",F._label).getRegex()}),F.gfm=U({},F.normal,{escape:P(F.escape).replace("])","~|])").getRegex(),_extended_email:/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,url:/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,_backpedal:/(?:[^?!.,:;*_~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_~)]+(?!$))+/,del:/^~+(?=\S)([\s\S]*?\S)~+/,text:/^(`+|[^`])(?:[\s\S]*?(?:(?=[\\'+(n?e:Q(e,!0))+"\n":"
"+(n?e:Q(e,!0))+"
\n"},t.blockquote=function(e){return"
\n"+e+"
\n"},t.html=function(e){return e},t.heading=function(e,t,n,r){return this.options.headerIds?"'+e+"\n":""+e+"\n"},t.hr=function(){return this.options.xhtml?"
\n":"
\n"},t.list=function(e,t,n){var r=t?"ol":"ul";return"<"+r+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"},t.listitem=function(e){return"
  • "+e+"
  • \n"},t.checkbox=function(e){return" "},t.paragraph=function(e){return"

    "+e+"

    \n"},t.table=function(e,t){return"\n\n"+e+"\n"+(t=t&&""+t+"")+"
    \n"},t.tablerow=function(e){return"\n"+e+"\n"},t.tablecell=function(e,t){var n=t.header?"th":"td";return(t.align?"<"+n+' align="'+t.align+'">':"<"+n+">")+e+"\n"},t.strong=function(e){return""+e+""},t.em=function(e){return""+e+""},t.codespan=function(e){return""+e+""},t.br=function(){return this.options.xhtml?"
    ":"
    "},t.del=function(e){return""+e+""},t.link=function(e,t,n){if(null===(e=K(this.options.sanitize,this.options.baseUrl,e)))return n;var r='"},t.image=function(e,t,n){if(null===(e=K(this.options.sanitize,this.options.baseUrl,e)))return n;var r=''+n+'":">"},t.text=function(e){return e},e}(),ee=function(){function e(){}var t=e.prototype;return t.strong=function(e){return e},t.em=function(e){return e},t.codespan=function(e){return e},t.del=function(e){return e},t.html=function(e){return e},t.text=function(e){return e},t.link=function(e,t,n){return""+n},t.image=function(e,t,n){return""+n},t.br=function(){return""},e}(),te=function(){function e(){this.seen={}}return e.prototype.slug=function(e){var t=e.toLowerCase().trim().replace(/<[!\/a-z].*?>/gi,"").replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g,"").replace(/\s/g,"-");if(this.seen.hasOwnProperty(t))for(var n=t;this.seen[n]++,t=n+"-"+this.seen[n],this.seen.hasOwnProperty(t););return this.seen[t]=0,t},e}(),ne=t.defaults,re=_,ie=function(){function n(e){this.options=e||ne,this.options.renderer=this.options.renderer||new Y,this.renderer=this.options.renderer,this.renderer.options=this.options,this.textRenderer=new ee,this.slugger=new te}n.parse=function(e,t){return new n(t).parse(e)};var e=n.prototype;return e.parse=function(e,t){void 0===t&&(t=!0);for(var n,r,i,s,a,l,o,c,h,u,p,g,f,d,k,b,m,x="",w=e.length,v=0;vAn error occurred:

    "+le(e.message+"",!0)+"
    ";throw e}}return ue.options=ue.setOptions=function(e){return se(ue.defaults,e),ce(ue.defaults),ue},ue.getDefaults=oe,ue.defaults=he,ue.use=function(l){var t,n=se({},l);l.renderer&&function(){var a=ue.defaults.renderer||new Y;for(var e in l.renderer)!function(i){var s=a[i];a[i]=function(){for(var e=arguments.length,t=new Array(e),n=0;nd;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
    "),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "EspruinoAppLoaderCore", 3 | "description": "Source files for Bangle.js and Espruino App Loader", 4 | "author": "Gordon Williams (http://espruino.com)", 5 | "version": "0.0.1", 6 | "devDependencies": { 7 | "eslint": "7.1.0" 8 | }, 9 | "scripts": { 10 | "test": "eslint ./lib ./js" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tools/apploader.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* Simple Command-line app loader for Node.js 3 | =============================================== 4 | 5 | NOTE: This needs the '@abandonware/noble' library to be installed. 6 | However we don't want this in package.json (at least 7 | as a normal dependency) because we want `sanitycheck.js` 8 | to be able to run *quickly* in travis for every commit, 9 | and we don't want NPM pulling in (and compiling native modules) 10 | for Noble. 11 | 12 | */ 13 | 14 | var SETTINGS = { 15 | pretokenise : true 16 | }; 17 | var noble; 18 | ["@abandonware/noble", "noble"].forEach(module => { 19 | if (!noble) try { 20 | noble = require(module); 21 | } catch(e) { 22 | if (e.code !== 'MODULE_NOT_FOUND') { 23 | throw e; 24 | } 25 | } 26 | }); 27 | if (!noble) { 28 | console.log("You need to:") 29 | console.log(" npm install @abandonware/noble") 30 | console.log("or:") 31 | console.log(" npm install noble") 32 | process.exit(1); 33 | } 34 | function ERROR(msg) { 35 | console.error(msg); 36 | process.exit(1); 37 | } 38 | 39 | var deviceId = "BANGLEJS2"; 40 | 41 | var apploader = require("../lib/apploader.js"); 42 | var args = process.argv; 43 | 44 | var bangleParam = args.findIndex(arg => /-b\d/.test(arg)); 45 | if (bangleParam!==-1) { 46 | deviceId = "BANGLEJS"+args.splice(bangleParam, 1)[0][2]; 47 | } 48 | apploader.init({ 49 | DEVICEID : deviceId 50 | }); 51 | if (args.length==3 && args[2]=="list") cmdListApps(); 52 | else if (args.length==3 && args[2]=="devices") cmdListDevices(); 53 | else if (args.length==4 && args[2]=="install") cmdInstallApp(args[3]); 54 | else if (args.length==5 && args[2]=="install") cmdInstallApp(args[3], args[4]); 55 | else { 56 | console.log(`apploader.js 57 | ------------- 58 | 59 | USAGE: 60 | 61 | apploader.js list 62 | - list available apps 63 | apploader.js devices 64 | - list available device addresses 65 | apploader.js install [-b1] appname [de:vi:ce:ad:dr:es] 66 | 67 | NOTE: By default this App Loader expects the device it uploads to 68 | (deviceId) to be BANGLEJS2, pass '-b1' for it to work with Bangle.js 1 69 | `); 70 | process.exit(0); 71 | } 72 | 73 | function cmdListApps() { 74 | console.log(apploader.apps.map(a=>a.id).join("\n")); 75 | } 76 | function cmdListDevices() { 77 | var foundDevices = []; 78 | noble.on('discover', function(dev) { 79 | if (!dev.advertisement) return; 80 | if (!dev.advertisement.localName) return; 81 | var a = dev.address.toString(); 82 | if (foundDevices.indexOf(a)>=0) return; 83 | foundDevices.push(a); 84 | console.log(a,dev.advertisement.localName); 85 | }); 86 | noble.startScanning([], true); 87 | setTimeout(function() { 88 | console.log("Stopping scan"); 89 | noble.stopScanning(); 90 | setTimeout(function() { 91 | process.exit(0); 92 | }, 500); 93 | }, 4000); 94 | } 95 | 96 | function cmdInstallApp(appId, deviceAddress) { 97 | var app = apploader.apps.find(a=>a.id==appId); 98 | if (!app) ERROR(`App ${JSON.stringify(appId)} not found`); 99 | if (app.custom) ERROR(`App ${JSON.stringify(appId)} requires HTML customisation`); 100 | return apploader.getAppFilesString(app).then(command => { 101 | bangleSend(command, deviceAddress).then(() => process.exit(0)); 102 | }); 103 | } 104 | 105 | function bangleSend(command, deviceAddress) { 106 | var log = function() { 107 | var args = [].slice.call(arguments); 108 | console.log("UART: "+args.join(" ")); 109 | } 110 | //console.log("Sending",JSON.stringify(command)); 111 | 112 | var RESET = true; 113 | var DEVICEADDRESS = ""; 114 | if (deviceAddress!==undefined) 115 | DEVICEADDRESS = deviceAddress; 116 | 117 | var complete = false; 118 | var foundDevices = []; 119 | var flowControlPaused = false; 120 | var btDevice; 121 | var txCharacteristic; 122 | var rxCharacteristic; 123 | 124 | return new Promise((resolve,reject) => { 125 | function foundDevice(dev) { 126 | if (btDevice!==undefined) return; 127 | log("Connecting to "+dev.address); 128 | noble.stopScanning(); 129 | connect(dev, function() { 130 | // Connected! 131 | function writeCode() { 132 | log("Writing code..."); 133 | write(command, function() { 134 | complete = true; 135 | btDevice.disconnect(); 136 | }); 137 | } 138 | if (RESET) { 139 | setTimeout(function() { 140 | log("Resetting..."); 141 | write("\x03\x10reset()\n", function() { 142 | setTimeout(writeCode, 1000); 143 | }); 144 | }, 500); 145 | } else 146 | setTimeout(writeCode, 1000); 147 | }); 148 | } 149 | 150 | function connect(dev, callback) { 151 | btDevice = dev; 152 | log("BT> Connecting"); 153 | btDevice.on('disconnect', function() { 154 | log("Disconnected"); 155 | setTimeout(function() { 156 | if (complete) resolve(); 157 | else reject("Disconnected but not complete"); 158 | }, 500); 159 | }); 160 | btDevice.connect(function (error) { 161 | if (error) { 162 | log("BT> ERROR Connecting",error); 163 | btDevice = undefined; 164 | return; 165 | } 166 | log("BT> Connected"); 167 | btDevice.discoverAllServicesAndCharacteristics(function(error, services, characteristics) { 168 | function findByUUID(list, uuid) { 169 | for (var i=0;i ERROR getting services/characteristics"); 179 | log("Service "+btUARTService); 180 | log("TX "+txCharacteristic); 181 | log("RX "+rxCharacteristic); 182 | btDevice.disconnect(); 183 | txCharacteristic = undefined; 184 | rxCharacteristic = undefined; 185 | btDevice = undefined; 186 | return openCallback(); 187 | } 188 | 189 | rxCharacteristic.on('data', function (data) { 190 | var s = ""; 191 | for (var i=0;i=10) { 230 | log("Writing "+amt+"/"+total); 231 | progress=0; 232 | } 233 | //log("Writing ",JSON.stringify(d)); 234 | amt += d.length; 235 | for (var i = 0; i < buf.length; i++) 236 | buf.writeUInt8(d.charCodeAt(i), i); 237 | txCharacteristic.write(buf, false, writeAgain); 238 | } 239 | writeAgain(); 240 | } 241 | 242 | function disconnect() { 243 | btDevice.disconnect(); 244 | } 245 | 246 | log("Discovering..."); 247 | noble.on('discover', function(dev) { 248 | if (!dev.advertisement) return; 249 | if (!dev.advertisement.localName) return; 250 | var a = dev.address.toString(); 251 | if (foundDevices.indexOf(a)>=0) return; 252 | foundDevices.push(a); 253 | log("Found device: ",a,dev.advertisement.localName); 254 | if (a == DEVICEADDRESS) 255 | return foundDevice(dev); 256 | else if (DEVICEADDRESS=="" && dev.advertisement.localName.indexOf("Bangle.js")==0) { 257 | return foundDevice(dev); 258 | } 259 | }); 260 | noble.startScanning([], true); 261 | }); 262 | } 263 | -------------------------------------------------------------------------------- /tools/language_render.js: -------------------------------------------------------------------------------- 1 | #!/bin/node 2 | /* 3 | Takes language files that have been written with unicode chars that Bangle.js cannot render 4 | with its built-in fonts, and pre-render them. 5 | */ 6 | 7 | //const FONT_SIZE = 18; 8 | //const FONT_NAME = 'Sans'; 9 | const FONT_SIZE = 16; // 12pt 10 | const FONT_NAME = '"Unifont Regular"'; // or just 'Sans' 11 | 12 | var createCanvas, registerFont; 13 | try { 14 | createCanvas = require("canvas").createCanvas; 15 | registerFont = require("canvas").registerFont; 16 | } catch(e) { 17 | console.log("ERROR: needc canvas library"); 18 | console.log("Try: npm install canvas"); 19 | process.exit(1); 20 | } 21 | // Use font from https://unifoundry.com/unifont/ as it scales well at 16px high 22 | registerFont(__dirname+'/unifont-15.0.01.ttf', { family: 'Unifont Regular' }) 23 | 24 | var imageconverter = require(__dirname+"/../webtools/imageconverter.js"); 25 | 26 | const canvas = createCanvas(200, 20) 27 | const ctx = canvas.getContext('2d') 28 | 29 | function renderText(txt) { 30 | ctx.clearRect(0, 0, canvas.width, canvas.height); 31 | ctx.font = FONT_SIZE+'px '+FONT_NAME; 32 | ctx.fillStyle = "white"; 33 | ctx.fillText(txt, 0, FONT_SIZE); 34 | var str = imageconverter.canvastoString(canvas, { autoCrop:true, output:"raw", mode:"1bit", transparent:true } ); 35 | // for testing: 36 | // console.log(txt); 37 | // console.log("g.drawImage(",imageconverter.canvastoString(canvas, { autoCrop:true, output:"string", mode:"1bit" } ),");"); 38 | // process.exit(1); 39 | return "\0"+str; 40 | } 41 | 42 | function renderLangFile(file) { 43 | var fileIn = __dirname + "/../lang/unicode-based/"+file; 44 | var fileOut = __dirname + "/../lang/"+file; 45 | console.log("Reading",fileIn); 46 | var inJSON = JSON.parse(require("fs").readFileSync(fileIn)); 47 | var outJSON = { "// created with bin/language_render.js" : ""}; 48 | for (var categoryName in inJSON) { 49 | if (categoryName.includes("//")) continue; 50 | var category = inJSON[categoryName]; 51 | outJSON[categoryName] = {}; 52 | for (var english in category) { 53 | if (english.includes("//")) continue; 54 | var translated = category[english]; 55 | //console.log(english,"=>",translated); 56 | outJSON[categoryName][english] = renderText(translated); 57 | } 58 | } 59 | require("fs").writeFileSync(fileOut, JSON.stringify(outJSON,null,2)); 60 | console.log("Written",fileOut); 61 | } 62 | 63 | 64 | renderLangFile("ja_JA.json"); 65 | -------------------------------------------------------------------------------- /tools/language_scan.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* Scans for strings that may be in English in each app, and 3 | outputs a list of strings that have been found. 4 | 5 | See https://github.com/espruino/BangleApps/issues/1311 6 | 7 | Needs old 'translate': 8 | 9 | npm install translate@1.4.1 10 | 11 | For actual translation you need to sign up for a free Deepl API at https://www.deepl.com/ 12 | 13 | ``` 14 | # show status 15 | bin/language_scan.js -r 16 | 17 | # add missing keys for all languages (in english) 18 | bin/language_scan.js -r 19 | 20 | # for translation 21 | bin/language_scan.js --deepl YOUR_API_KEY --turl https://api-free.deepl.com 22 | 23 | */ 24 | 25 | var childProcess = require('child_process'); 26 | 27 | let refresh = false; 28 | 29 | function handleCliParameters () 30 | { 31 | let usage = "USAGE: language_scan.js [options]"; 32 | let die = function (message) { 33 | console.log(usage); 34 | console.log(message); 35 | process.exit(3); 36 | }; 37 | let hadTURL = false, 38 | hadDEEPL = false; 39 | for(let i = 2; i < process.argv.length; i++) 40 | { 41 | const param = process.argv[i]; 42 | switch(param) 43 | { 44 | case '-r': 45 | case '--refresh': 46 | refresh = true; 47 | break; 48 | case '--deepl': 49 | i++; 50 | let KEY = process.argv[i]; 51 | if(KEY === '' || KEY === null || KEY === undefined) 52 | { 53 | die('--deepl requires a parameter: the API key to use'); 54 | } 55 | process.env.DEEPL = KEY; 56 | hadDEEPL = true; 57 | break; 58 | case '--turl': 59 | i++; 60 | let URL = process.argv[i]; 61 | if(URL === '' || URL === null || URL === undefined) 62 | { 63 | die('--turl requires a parameter: the URL to use'); 64 | } 65 | process.env.TURL = URL; 66 | hadTURL = true; 67 | break; 68 | case '-h': 69 | case '--help': 70 | console.log(usage+"\n"); 71 | console.log("Parameters:"); 72 | console.log(" -h, --help Output this help text and exit"); 73 | console.log(" -r, --refresh Auto-add new strings into lang/*.json"); 74 | console.log(' --deepl KEY Enable DEEPL as auto-translation engine and'); 75 | console.log(' use KEY as its API key. You also need to provide --turl'); 76 | console.log(' --turl URL In combination with --deepl, use URL as the API base URL'); 77 | process.exit(0); 78 | default: 79 | die("Unknown parameter: "+param+", use --help for options"); 80 | } 81 | } 82 | if((hadTURL !== false || hadDEEPL !== false) && hadTURL !== hadDEEPL) 83 | { 84 | die("Use of deepl requires both a --deepl API key and --turl URL"); 85 | } 86 | } 87 | handleCliParameters(); 88 | 89 | let translate = false; 90 | if (process.env.DEEPL) { 91 | // Requires translate 92 | // npm i translate 93 | translate = require("translate"); 94 | translate.engine = "deepl"; // Or "yandex", "libre", "deepl" 95 | translate.key = process.env.DEEPL; // Requires API key (which are free) 96 | translate.url = process.env.TURL; 97 | } 98 | 99 | var IGNORE_STRINGS = [ 100 | "5x5","6x8","6x8:2","4x6","12x20","6x15","5x9Numeric7Seg", "Vector", // fonts 101 | "---","...","*","##","00","GPS","ram", 102 | "12hour","rising","falling","title", 103 | "sortorder","tl","tr", 104 | "function","object", // typeof=== 105 | "txt", // layout styles 106 | "play","stop","pause", "volumeup", "volumedown", // music state 107 | "${hours}:${minutes}:${seconds}", "${hours}:${minutes}", 108 | "BANGLEJS", 109 | "fgH", "bgH", "m/s", 110 | "undefined", "kbmedia", "NONE", 111 | ]; 112 | 113 | var IGNORE_FUNCTION_PARAMS = [ 114 | "read", 115 | "readJSON", 116 | "require", 117 | "setFont","setUI","setLCDMode", 118 | "on", 119 | "RegExp","sendCommand", 120 | "print","log" 121 | ]; 122 | var IGNORE_ARRAY_ACCESS = [ 123 | "WIDGETS" 124 | ]; 125 | 126 | var BASEDIR = __dirname+"/../../"; 127 | Espruino = require("../lib/espruinotools.js"); 128 | var fs = require("fs"); 129 | var APPSDIR = BASEDIR+"apps/"; 130 | 131 | function ERROR(s) { 132 | console.error("ERROR: "+s); 133 | process.exit(1); 134 | } 135 | function WARN(s) { 136 | console.log("Warning: "+s); 137 | } 138 | function log(s) { 139 | console.log(s); 140 | } 141 | 142 | var apploader = require("../lib/apploader.js"); 143 | apploader.init({ 144 | DEVICEID : "BANGLEJS2" 145 | }); 146 | var apps = apploader.apps; 147 | 148 | // Given a string value, work out if it's obviously not a text string 149 | function isNotString(s, wasFnCall, wasArrayAccess) { 150 | if (s=="") return true; 151 | // wasFnCall is set to the function name if 's' is the first argument to a function 152 | if (wasFnCall && IGNORE_FUNCTION_PARAMS.includes(wasFnCall)) return true; 153 | if (wasArrayAccess && IGNORE_ARRAY_ACCESS.includes(wasArrayAccess)) return true; 154 | if (s=="Storage") console.log("isNotString",s,wasFnCall); 155 | 156 | if (s.length<3) return true; // too short 157 | if (s.length>40) return true; // too long 158 | if (s[0]=="#") return true; // a color 159 | if (s.endsWith('.log') || s.endsWith('.js') || s.endsWith(".info") || s.endsWith(".csv") || s.endsWith(".json") || s.endsWith(".img") || s.endsWith(".txt")) return true; // a filename 160 | if (s.endsWith("=")) return true; // probably base64 161 | if (s.startsWith("BTN")) return true; // button name 162 | if (IGNORE_STRINGS.includes(s)) return true; // one we know to ignore 163 | if (!isNaN(parseFloat(s)) && isFinite(s)) return true; //is number 164 | if (s.match(/^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/)) return true; //roman number 165 | if (!s.match(/.*[A-Z].*/i)) return true; // No letters 166 | if (s.match(/.*[0-9].*/i)) return true; // No letters 167 | if (s.match(/.*\(.*\).*/)) return true; // is function 168 | if (s.match(/[A-Za-z]+[A-Z]([A-Z]|[a-z])*/)) return true; // is camel case 169 | if (s.includes('_')) return true; 170 | return false; 171 | } 172 | 173 | function getTextFromString(s) { 174 | return s.replace(/^([.<>\-\n ]*)([^<>\!\?]*?)([.<>\!\?\-\n ]*)$/,"$2"); 175 | } 176 | 177 | // A string that *could* be translated? 178 | var untranslatedStrings = []; 179 | // Strings that are marked with 'LANG' 180 | var translatedStrings = []; 181 | 182 | function addString(list, str, file) { 183 | str = getTextFromString(str); 184 | var entry = list.find(e => e.str==str); 185 | if (!entry) { 186 | entry = { str:str, uses:0, files : [] }; 187 | list.push(entry); 188 | } 189 | entry.uses++; 190 | if (!entry.files.includes(file)) 191 | entry.files.push(file) 192 | } 193 | 194 | function scanJS(js, shortFilePath) { 195 | var lex = Espruino.Core.Utils.getLexer(js); 196 | var lastIdx = 0; 197 | var wasFnCall = undefined; // set to 'setFont' if we're at something like setFont(".." 198 | var wasArrayAccess = undefined; // set to 'WIDGETS' if we're at something like WIDGETS[".." 199 | var tok = lex.next(); 200 | while (tok!==undefined) { 201 | var previousString = js.substring(lastIdx, tok.startIdx); 202 | if (tok.type=="STRING") { 203 | if (previousString.includes("/*LANG*/")) { // translated! 204 | addString(translatedStrings, tok.value, shortFilePath); 205 | } else if (tok.str.startsWith("`")) { // it's a tempated String! 206 | var matches = tok.str.match(/\$\{[^\}]*\}/g); 207 | if (matches!=null) 208 | matches.forEach(match => scanJS(match.slice(2,-1), shortFilePath)); 209 | } else { // untranslated - potential to translate? 210 | // filter out numbers 211 | if (!isNotString(tok.value, wasFnCall, wasArrayAccess)) { 212 | addString(untranslatedStrings, tok.value, shortFilePath); 213 | } 214 | } 215 | } else { 216 | if (tok.value!="(") wasFnCall=undefined; 217 | if (tok.value!="[") wasArrayAccess=undefined; 218 | } 219 | //console.log(wasFnCall,tok.type,tok.value); 220 | if (tok.type=="ID") { 221 | wasFnCall = tok.value; 222 | wasArrayAccess = tok.value; 223 | } 224 | lastIdx = tok.endIdx; 225 | tok = lex.next(); 226 | } 227 | } 228 | 229 | console.log("Scanning apps..."); 230 | //apps = apps.filter(a=>a.id=="wid_edit"); 231 | apps.forEach((app,appIdx) => { 232 | var appDir = APPSDIR+app.id+"/"; 233 | app.storage.forEach((file) => { 234 | if (!file.url || !file.name.endsWith(".js")) return; 235 | var filePath = appDir+file.url; 236 | var shortFilePath = "apps/"+app.id+"/"+file.url; 237 | var fileContents = fs.readFileSync(filePath).toString(); 238 | scanJS(fileContents, shortFilePath); 239 | }); 240 | var shortFilePath = "apps/"+app.id+"/metadata.json"; 241 | if (app.shortName) addString(translatedStrings, app.shortName, shortFilePath); 242 | addString(translatedStrings, app.name, shortFilePath); 243 | }); 244 | untranslatedStrings.sort((a,b)=>a.uses - b.uses); 245 | translatedStrings.sort((a,b)=>a.uses - b.uses); 246 | 247 | 248 | /* 249 | * @description Add lang to start of string 250 | * @param str string to add LANG to 251 | * @param file file that string is found 252 | * @returns void 253 | */ 254 | //TODO fix settings bug 255 | function applyLANG(str, file) { 256 | fs.readFile(file, 'utf8', function (err,data) { 257 | if (err) { 258 | return console.log(err); 259 | } 260 | const regex = new RegExp(`(.*)((? translatedStrings.find(t=>t.str==e.str)); 277 | 278 | // Uncomment to add LANG to all strings 279 | // THIS IS EXPERIMENTAL 280 | //wordsToAdd.forEach(e => e.files.forEach(a => applyLANG(e.str, a))); 281 | 282 | log(wordsToAdd.map(e=>`${JSON.stringify(e.str)} (${e.uses} uses)`).join("\n")); 283 | log(""); 284 | 285 | //process.exit(1); 286 | log("Possible English Strings that could be translated"); 287 | log("================================================================="); 288 | log(""); 289 | log("Add these to IGNORE_STRINGS if they don't make sense..."); 290 | log(""); 291 | // ignore ones only used once or twice 292 | log(untranslatedStrings.filter(e => e.uses>2).filter(e => !translatedStrings.find(t=>t.str==e.str)).map(e=>`${JSON.stringify(e.str)} (${e.uses} uses)`).join("\n")); 293 | log(""); 294 | //process.exit(1); 295 | 296 | let languages = JSON.parse(fs.readFileSync(`${BASEDIR}/lang/index.json`).toString()); 297 | for (let language of languages) { 298 | if (language.code == "en_GB") { 299 | console.log(`Ignoring ${language.code}`); 300 | continue; 301 | } 302 | console.log(`Scanning ${language.code}`); 303 | log(language.code); 304 | log("=========="); 305 | let translations = JSON.parse(fs.readFileSync(`${BASEDIR}/lang/${language.url}`).toString()); 306 | let translationPromises = []; 307 | translatedStrings.forEach(translationItem => { 308 | if (!translations.GLOBAL[translationItem.str]) { 309 | console.log(`Missing GLOBAL translation for ${JSON.stringify(translationItem)}`); 310 | translationItem.files.forEach(file => { 311 | let m = file.match(/\/([a-zA-Z0-9_-]*)\//g); 312 | if (m && m[0]) { 313 | let appName = m[0].replaceAll("/", ""); 314 | if (translations[appName] && translations[appName][translationItem.str]) { 315 | console.log(` but LOCAL translation found in \"${appName}\"`); 316 | } else if (translate && language.code !== "tr_TR") { // Auto Translate 317 | translationPromises.push(new Promise(async (resolve) => { 318 | const translation = await translate(translationItem.str, language.code.split("_")[0]); 319 | console.log("Translating:", translationItem.str, "==>", translation); 320 | translations.GLOBAL[translationItem.str] = translation; 321 | resolve() 322 | })) 323 | } else if(refresh && !translate) { 324 | translationPromises.push(new Promise(async (resolve) => { 325 | translations.GLOBAL[translationItem.str] = translationItem.str; 326 | resolve() 327 | })) 328 | } 329 | } 330 | }); 331 | } 332 | }); 333 | Promise.all(translationPromises).then(() => { 334 | fs.writeFileSync(`${BASEDIR}/lang/${language.url}`, JSON.stringify(translations, null, 4)) 335 | }); 336 | log(""); 337 | } 338 | console.log("Done."); 339 | -------------------------------------------------------------------------------- /tools/unifont-15.0.01.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espruino/EspruinoAppLoaderCore/7e7475ba3ab253099481a81e487aaacb9384f974/tools/unifont-15.0.01.ttf --------------------------------------------------------------------------------