├── .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 | [](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 |
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(`
98 |
99 |
100 |
101 |
105 |
106 |
107 | ${(shouldEscapeHtml) ? escapeHtml(text).replace(/\n/g,'
') : text}
108 |
109 |
110 | ${Object.keys(buttons).length ? `
111 |
117 | `:``}
118 |
119 |
`);
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 |
140 |
143 |
144 |
145 | Loading...
146 |
147 |
148 |
`;
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 |
113 |
116 |
117 |
118 | Loading...
119 |
120 |
121 |
`;
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]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?\\?>\\n*|\\n*|\\n*|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:\\n{2,}|$)|<(?!script|pre|style)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:\\n{2,}|$)|(?!script|pre|style)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:\\n{2,}|$))",def:/^ {0,3}\[(label)\]: *\n? *([^\s>]+)>?(?:(?: +\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","?(?:tag)(?: +|\\n|/?>)|<(?: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","?(?:tag)(?: +|\\n|/?>)|<(?: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","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|!--)").replace("tag",B._tag).getRegex(),B.pedantic=U({},B.normal,{html:P("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\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:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\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:-]*\\s*>|^<[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?"\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+""+r+">\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+">\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='"+n+""},t.image=function(e,t,n){if(null===(e=K(this.options.sanitize,this.options.baseUrl,e)))return n;var r='
":">"},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
--------------------------------------------------------------------------------