├── .gitignore ├── LICENSE.md ├── README.md ├── config.xml ├── package.json ├── res └── icon.png ├── state_machine.svg └── www ├── css └── index.css ├── img └── logo.png ├── index.html └── js ├── index.js ├── lodash.custom.js ├── machina.js ├── polyfill.js └── util.js /.gitignore: -------------------------------------------------------------------------------- 1 | # system and tools 2 | npm-debug.log 3 | /node_modules 4 | .DS_Store 5 | .gradle 6 | .metadata 7 | Thumbs.db 8 | Desktop.ini 9 | *.tmp 10 | *.bak 11 | *.sw? 12 | *.class 13 | *.jar 14 | default.properties 15 | local.properties 16 | gen 17 | 18 | # cordova 19 | /platforms 20 | /plugins 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | ============== 3 | 4 | _Version 2.0, January 2004_ 5 | _<>_ 6 | 7 | ### Terms and Conditions for use, reproduction, and distribution 8 | 9 | #### 1. Definitions 10 | 11 | “License” shall mean the terms and conditions for use, reproduction, and 12 | distribution as defined by Sections 1 through 9 of this document. 13 | 14 | “Licensor” shall mean the copyright owner or entity authorized by the copyright 15 | owner that is granting the License. 16 | 17 | “Legal Entity” shall mean the union of the acting entity and all other entities 18 | that control, are controlled by, or are under common control with that entity. 19 | For the purposes of this definition, “control” means **(i)** the power, direct or 20 | indirect, to cause the direction or management of such entity, whether by 21 | contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the 22 | outstanding shares, or **(iii)** beneficial ownership of such entity. 23 | 24 | “You” (or “Your”) shall mean an individual or Legal Entity exercising 25 | permissions granted by this License. 26 | 27 | “Source” form shall mean the preferred form for making modifications, including 28 | but not limited to software source code, documentation source, and configuration 29 | files. 30 | 31 | “Object” form shall mean any form resulting from mechanical transformation or 32 | translation of a Source form, including but not limited to compiled object code, 33 | generated documentation, and conversions to other media types. 34 | 35 | “Work” shall mean the work of authorship, whether in Source or Object form, made 36 | available under the License, as indicated by a copyright notice that is included 37 | in or attached to the work (an example is provided in the Appendix below). 38 | 39 | “Derivative Works” shall mean any work, whether in Source or Object form, that 40 | is based on (or derived from) the Work and for which the editorial revisions, 41 | annotations, elaborations, or other modifications represent, as a whole, an 42 | original work of authorship. For the purposes of this License, Derivative Works 43 | shall not include works that remain separable from, or merely link (or bind by 44 | name) to the interfaces of, the Work and Derivative Works thereof. 45 | 46 | “Contribution” shall mean any work of authorship, including the original version 47 | of the Work and any modifications or additions to that Work or Derivative Works 48 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 49 | by the copyright owner or by an individual or Legal Entity authorized to submit 50 | on behalf of the copyright owner. For the purposes of this definition, 51 | “submitted” means any form of electronic, verbal, or written communication sent 52 | to the Licensor or its representatives, including but not limited to 53 | communication on electronic mailing lists, source code control systems, and 54 | issue tracking systems that are managed by, or on behalf of, the Licensor for 55 | the purpose of discussing and improving the Work, but excluding communication 56 | that is conspicuously marked or otherwise designated in writing by the copyright 57 | owner as “Not a Contribution.” 58 | 59 | “Contributor” shall mean Licensor and any individual or Legal Entity on behalf 60 | of whom a Contribution has been received by Licensor and subsequently 61 | incorporated within the Work. 62 | 63 | #### 2. Grant of Copyright License 64 | 65 | Subject to the terms and conditions of this License, each Contributor hereby 66 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 67 | irrevocable copyright license to reproduce, prepare Derivative Works of, 68 | publicly display, publicly perform, sublicense, and distribute the Work and such 69 | Derivative Works in Source or Object form. 70 | 71 | #### 3. Grant of Patent License 72 | 73 | Subject to the terms and conditions of this License, each Contributor hereby 74 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 75 | irrevocable (except as stated in this section) patent license to make, have 76 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 77 | such license applies only to those patent claims licensable by such Contributor 78 | that are necessarily infringed by their Contribution(s) alone or by combination 79 | of their Contribution(s) with the Work to which such Contribution(s) was 80 | submitted. If You institute patent litigation against any entity (including a 81 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 82 | Contribution incorporated within the Work constitutes direct or contributory 83 | patent infringement, then any patent licenses granted to You under this License 84 | for that Work shall terminate as of the date such litigation is filed. 85 | 86 | #### 4. Redistribution 87 | 88 | You may reproduce and distribute copies of the Work or Derivative Works thereof 89 | in any medium, with or without modifications, and in Source or Object form, 90 | provided that You meet the following conditions: 91 | 92 | * **(a)** You must give any other recipients of the Work or Derivative Works a copy of 93 | this License; and 94 | * **(b)** You must cause any modified files to carry prominent notices stating that You 95 | changed the files; and 96 | * **(c)** You must retain, in the Source form of any Derivative Works that You distribute, 97 | all copyright, patent, trademark, and attribution notices from the Source form 98 | of the Work, excluding those notices that do not pertain to any part of the 99 | Derivative Works; and 100 | * **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any 101 | Derivative Works that You distribute must include a readable copy of the 102 | attribution notices contained within such NOTICE file, excluding those notices 103 | that do not pertain to any part of the Derivative Works, in at least one of the 104 | following places: within a NOTICE text file distributed as part of the 105 | Derivative Works; within the Source form or documentation, if provided along 106 | with the Derivative Works; or, within a display generated by the Derivative 107 | Works, if and wherever such third-party notices normally appear. The contents of 108 | the NOTICE file are for informational purposes only and do not modify the 109 | License. You may add Your own attribution notices within Derivative Works that 110 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 111 | provided that such additional attribution notices cannot be construed as 112 | modifying the License. 113 | 114 | You may add Your own copyright statement to Your modifications and may provide 115 | additional or different license terms and conditions for use, reproduction, or 116 | distribution of Your modifications, or for any such Derivative Works as a whole, 117 | provided Your use, reproduction, and distribution of the Work otherwise complies 118 | with the conditions stated in this License. 119 | 120 | #### 5. Submission of Contributions 121 | 122 | Unless You explicitly state otherwise, any Contribution intentionally submitted 123 | for inclusion in the Work by You to the Licensor shall be under the terms and 124 | conditions of this License, without any additional terms or conditions. 125 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 126 | any separate license agreement you may have executed with Licensor regarding 127 | such Contributions. 128 | 129 | #### 6. Trademarks 130 | 131 | This License does not grant permission to use the trade names, trademarks, 132 | service marks, or product names of the Licensor, except as required for 133 | reasonable and customary use in describing the origin of the Work and 134 | reproducing the content of the NOTICE file. 135 | 136 | #### 7. Disclaimer of Warranty 137 | 138 | Unless required by applicable law or agreed to in writing, Licensor provides the 139 | Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, 140 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 141 | including, without limitation, any warranties or conditions of TITLE, 142 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 143 | solely responsible for determining the appropriateness of using or 144 | redistributing the Work and assume any risks associated with Your exercise of 145 | permissions under this License. 146 | 147 | #### 8. Limitation of Liability 148 | 149 | In no event and under no legal theory, whether in tort (including negligence), 150 | contract, or otherwise, unless required by applicable law (such as deliberate 151 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 152 | liable to You for damages, including any direct, indirect, special, incidental, 153 | or consequential damages of any character arising as a result of this License or 154 | out of the use or inability to use the Work (including but not limited to 155 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 156 | any and all other commercial damages or losses), even if such Contributor has 157 | been advised of the possibility of such damages. 158 | 159 | #### 9. Accepting Warranty or Additional Liability 160 | 161 | While redistributing the Work or Derivative Works thereof, You may choose to 162 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 163 | other liability obligations and/or rights consistent with this License. However, 164 | in accepting such obligations, You may act only on Your own behalf and on Your 165 | sole responsibility, not on behalf of any other Contributor, and only if You 166 | agree to indemnify, defend, and hold each Contributor harmless for any liability 167 | incurred by, or claims asserted against, such Contributor by reason of your 168 | accepting any such warranty or additional liability. 169 | 170 | _END OF TERMS AND CONDITIONS_ 171 | 172 | ### APPENDIX: How to apply the Apache License to your work 173 | 174 | To apply the Apache License to your work, attach the following boilerplate 175 | notice, with the fields enclosed by brackets `[]` replaced with your own 176 | identifying information. (Don't include the brackets!) The text should be 177 | enclosed in the appropriate comment syntax for the file format. We also 178 | recommend that a file or class name and description of purpose be included on 179 | the same “printed page” as the copyright notice for easier identification within 180 | third-party archives. 181 | 182 | Copyright [yyyy] [name of copyright owner] 183 | 184 | Licensed under the Apache License, Version 2.0 (the "License"); 185 | you may not use this file except in compliance with the License. 186 | You may obtain a copy of the License at 187 | 188 | http://www.apache.org/licenses/LICENSE-2.0 189 | 190 | Unless required by applicable law or agreed to in writing, software 191 | distributed under the License is distributed on an "AS IS" BASIS, 192 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 193 | See the License for the specific language governing permissions and 194 | limitations under the License. 195 | 196 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cordova Web Wrap 2 | 3 | This app is a light-weight [Cordova](http://cordova.apache.org/) wrapper around a 4 | mobile website. It can be useful to add functionality that only works in apps, like 5 | barcode scanning, to a mobile website. While Cordova does a lot to make this possible, 6 | a polished, well-working app is not yet trivial. This project simplifies that. 7 | 8 | Note that it's best to create a [single page application](https://en.wikipedia.org/wiki/Single-page_application), 9 | so that the website code only needs to be loaded once. This allows the user to have 10 | fast interaction with the app. 11 | 12 | Features: 13 | 14 | - Loads the website in an app, safely (inAppBrowser). 15 | - Meant for [single page applications](https://en.wikipedia.org/wiki/Single-page_application). 16 | - Works when online, a notice is shown when offline (with a state-machine to track state). 17 | - Opens external links in the system web browser, internal links in the app. 18 | - Website can indicate which links to open in the app. 19 | - Allows scanning a barcode, initiated from the website. 20 | - Works on Android and iOS. 21 | 22 | ## Build 23 | 24 | 1. [Install Cordova](http://cordova.apache.org/docs/en/latest/guide/cli/index.html): `npm install -g cordova` 25 | 2. [Add platforms](https://cordova.apache.org/docs/en/latest/guide/cli/index.html#add-platforms): `cordova platform add android` and/or `cordova platform add ios`. 26 | 3. [Check (and install) requirements](https://cordova.apache.org/docs/en/latest/guide/cli/index.html#install-pre-requisites-for-building): `cordova requirements` 27 | 4. [Build](https://cordova.apache.org/docs/en/latest/guide/cli/index.html#build-the-app): `cordova build` 28 | 29 | ### iOS 30 | 31 | On iOS `cordova build ios` may not be enough. After running this, you can open the folder `platforms/ios` in Xcode. 32 | In the warnings shown there are two items about updating build settings. Accept the modifications (ignoring the warning 33 | about uncommited changes), and build it from Xcode. 34 | 35 | ## Configure 36 | 37 | - Change `LANDING_URL` in [`www/js/index.js`](www/js/index.js#L21) to point to your mobile website. 38 | - Update `id`, `name`, `description` and `author` in [`config.xml`](config.xml) ([reference](https://cordova.apache.org/docs/en/latest/config_ref/)). 39 | - Modify `name`, `displayName`, `description` and `author` in [`package.json`](package.json). 40 | - Edit `title`, `h1` and other texts in [`www/index.html`](www/index.html). 41 | 42 | Then build and install. 43 | If you want more beautiful icons (recommended), run 44 | 45 | npm install cordova-icon 46 | node_modules/.bin/cordova-icon --icon=res/icon.png 47 | 48 | To use your own icons, update [`res/icon.png`](res/icon.png) and [`www/img/logo.png`](www/img/logo.png) before doing so. 49 | 50 | ## State machine 51 | 52 | This app uses a state machine ([machina](https://github.com/ifandelse/machina.js)) to keep track of 53 | connection and loading state. The state diagram is as follows: 54 | 55 | ![state machine diagram](./state_machine.svg) 56 | 57 | ## Opening external links 58 | 59 | External links are opened in the system web browser, internal links in the app. 60 | Which links are internal is determined by `LOCAL_URLS` in [index.js](www/js/index.js#L26), 61 | which is a space-separated list of URLs. All listed links are considered to be internal. 62 | Full URLs are allowed (to allow multiple hosts), as are URLs relative to the 63 | host (and protocol) of `LANDING_URL`, with leading slash. Query string and hash are 64 | not part of the comparison. 65 | 66 | For example, if you have a mobile website where you want to open the index, an about 67 | page, and product pages, you could use `/ /about /products/*`. All other 68 | links on your website would be opened in the system web browser. 69 | 70 | This attribute can also be set by the website by any element with a `data-app-local-urls` 71 | attribute (so the website can evolve without having to update this setting in the app). 72 | 73 | ## Barcode scanner 74 | 75 | The website can initiate a barcode scan by pointing to the custom url `app://mobile-scan`. 76 | When this link is followed, the barcode scanner is opened. On successful scan, it will return 77 | to the page indicated by the `ret` query string parameter passed that triggered opening the 78 | scanner. This is a [URL template](https://en.wikipedia.org/wiki/URL_Template) where `{CODE}` is 79 | replaced by the scanned barcode. Links can be relative or absolute. 80 | 81 | An example. When following the link in the HTML shown below, a barcode scanner will 82 | be opened, and when barcode `12345` is scanned, the link `http://x.test/scan/12345` 83 | will be opened in the app. 84 | 85 | ```html 86 | 87 | Scan 88 | 89 | ``` 90 | 91 | ## Adapating a website for the app 92 | 93 | This app shows a mobile website. Most of it would also work in a regular web browser, but 94 | certain features may only make sense when embedded in the app. The barcode-scan feature 95 | comes to mind, and it may be desirable to hide large documentation pages from navigation. 96 | 97 | This can easily be done in two ways: 98 | 1. _User-Agent_ - the `AppendUserAgent` preference in `config.xml` can be used to modify 99 | the user-agent. The website can show and hide certain elements based on this. 100 | 2. `LANDING_URL` - the app's landing page could include a query parameter or be a specific 101 | page for the app. It can be useful to pass through the query parameter to links, so that 102 | any modified navigation remains so in the app. 103 | 104 | ## Testing on the emulator 105 | 106 | To test the barcode scanner with the Android emulator, you can use the following 107 | procedure on Linux (based on [this](https://stackoverflow.com/a/35526103/2866660)). 108 | 109 | ```sh 110 | # Check which video devices are available. Use the next number for 'video_nr' and in 'device'. 111 | $ ls /dev/video* 112 | /dev/video0 113 | # Load the loopback video device 114 | $ sudo modprobe v4l2loopback video_nr=1 card_label="mockcam" 115 | # Create virtual webcam out of the image, substitute 'image.png' with your picture. 116 | $ gst-launch-1.0 filesrc location=image.png ! pngdec ! imagefreeze ! v4l2sink device=/dev/video1 117 | ``` 118 | 119 | Then launch the emulator with the additional argument `-camera-back webcam1` (use same number 120 | as above). You may want to scale the image to 800x600 if you have a large one. One online 121 | barcode generator is [this one](https://floms.github.io/Open-Barcode/). 122 | 123 | ## Lodash 124 | 125 | The state machine requires [lodash](https://lodash.com/), and to reduce the footprint we 126 | use a custom build (527K to 132K for version 4.17.5). The methods included are just those used 127 | in the [state machine code](www/js/machina.js) plus `debounce` (so if you get missing functions 128 | there after a machina upgrade, check if a missing method is used): 129 | 130 | npm install -g lodash-cli 131 | lodash -d -o www/js/lodash.custom.js include=apply,defaults,cloneDeep,debounce,difference,each,extend,filter,isPlainObject,merge,transform,without 132 | 133 | ## Notes 134 | 135 | - If the inAppBrowser would support opening external links without messing up the internal inAppBrowser, the 136 | app-launcher plugin could be removed (see also [CB-13198](https://issues.apache.org/jira/browse/CB-13198)). 137 | - On iOS, opening the barcode scanner briefly shows a _opening barcode scanner_ screen because of 138 | [this issue](https://github.com/phonegap/phonegap-plugin-barcodescanner/issues/570) (marked _wontfix_). 139 | 140 | ## Links 141 | 142 | * [Going Mobile: Wrapping an existing web application in Cordova/Phonegap](https://medium.com/code-divoire/going-mobile-wrapping-an-existing-web-application-in-cordova-phonegap-106d8b60bb9a) 143 | * [HTML-5 Cordova webapp](https://github.com/krisrak/html5-cordova-webapp/) 144 | * [Is This Thing On?](https://www.telerik.com/blogs/is-this-thing-on-(part-1)), on state management for connection status 145 | * [Cordova documentation](https://cordova.apache.org/docs/en/latest/) 146 | 147 | ## License 148 | 149 | This project is licensed under the [Apache 2.0](LICENSE.md) license. 150 | -------------------------------------------------------------------------------- /config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Example 4 | An example Cordova app wrapping a mobile website. 5 | wvengen 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | To scan barcodes 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "com.example.webwrap", 3 | "displayName": "Example", 4 | "version": "1.0.0", 5 | "description": "An example Cordova app wrapping a mobile website.", 6 | "homepage": "https://github.com/q-m/cordova-web-wrap", 7 | "license": "Apache-2.0", 8 | "main": "index.js", 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "wvengen", 13 | "dependencies": { 14 | "cordova-plugin-app-launcher": "^0.4.0", 15 | "cordova-plugin-inappbrowser": "git+https://github.com/apache/cordova-plugin-inappbrowser.git#94fec84d5c81e64b89b4c216d02938d58ba61dbc", 16 | "cordova-plugin-network-information": "^2.0.1", 17 | "cordova-plugin-whitelist": "^1.3.3", 18 | "phonegap-plugin-barcodescanner": "^7.1.2" 19 | }, 20 | "cordova": { 21 | "plugins": { 22 | "cordova-plugin-whitelist": {}, 23 | "cordova-plugin-inappbrowser": {}, 24 | "cordova-plugin-network-information": {}, 25 | "phonegap-plugin-barcodescanner": { 26 | "ANDROID_SUPPORT_V4_VERSION": "27.+" 27 | }, 28 | "cordova-plugin-app-launcher": {} 29 | }, 30 | "platforms": [] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /res/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/q-m/cordova-web-wrap/08cdf404d7d3ecdd4710da24251f5aacd2eadaac/res/icon.png -------------------------------------------------------------------------------- /state_machine.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 32 | 38 | 39 | 47 | 53 | 54 | 62 | 68 | 69 | 77 | 83 | 84 | 92 | 98 | 99 | 108 | 114 | 115 | 123 | 129 | 130 | 138 | 144 | 145 | 153 | 159 | 160 | 169 | 175 | 176 | 185 | 191 | 192 | 201 | 207 | 208 | 216 | 222 | 223 | 232 | 238 | 239 | 248 | 254 | 255 | 264 | 270 | 271 | 279 | 285 | 286 | 295 | 301 | 302 | 311 | 317 | 318 | 327 | 333 | 334 | 343 | 349 | 350 | 359 | 365 | 366 | 375 | 381 | 382 | 383 | 408 | 416 | 417 | 419 | 420 | 422 | image/svg+xml 423 | 425 | 426 | 427 | 428 | 429 | 434 | 440 | 446 | 451 | 456 | 463 | 470 | 476 | 483 | 485 | 491 | starting 502 | 503 | 506 | 515 | started 526 | 527 | deviceready 538 | (auto) load 551 | app.loadstop 562 | app.loadstart 574 | (internal) 586 | (external)load 605 | 611 | 614 | 623 | failed 634 | 635 | 638 | 647 | 650 | offline.blank 661 | 662 | 663 | conn.offline 674 | retry, load 692 | 697 | conn.online 709 | 714 | conn.offline 726 | 729 | 738 | offline.loaded 749 | 750 | 757 | app.exit 769 | 772 | 778 | exited 789 | 790 | app.loaderror 802 | conn.offline 814 | resume, load 830 | 843 | 846 | action 857 | event 868 | (condition) 881 | 882 | 885 | state 896 | virtual state 907 | 908 | 917 | 918 | 924 | 930 | 936 | conn.offline 948 | 954 | conn.online, load 970 | pause 982 | 988 | conn.offline 999 | 1006 | 1012 | app.loadstop 1024 | 1031 | 1034 | 1043 | paused 1054 | 1055 | 1058 | 1067 | loading 1079 | 1080 | 1083 | 1092 | loaded 1103 | 1104 | 1105 | 1106 | -------------------------------------------------------------------------------- /www/css/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | * { 20 | -webkit-tap-highlight-color: rgba(0,0,0,0); /* make transparent link selection, adjust last value opacity 0 to 1.0 */ 21 | } 22 | 23 | body { 24 | -webkit-touch-callout: none; /* prevent callout to copy image, etc when tap to hold */ 25 | -webkit-text-size-adjust: none; /* prevent webkit from resizing text to fit */ 26 | -webkit-user-select: none; /* prevent copy paste, to allow, change 'none' to 'text' */ 27 | background-color: white; 28 | font-family: 'HelveticaNeue-Light', 'HelveticaNeue', Helvetica, Arial, sans-serif; 29 | font-size: 14px; 30 | margin: 0; 31 | padding: 0; 32 | width: 100%; 33 | height: 100%; 34 | } 35 | 36 | /* Portrait layout (default) */ 37 | .app { 38 | background:url(../img/logo.png) no-repeat center top; /* 128px x 128px */ 39 | position: absolute; /* position in the center of the screen */ 40 | left: 50%; 41 | top: 50%; 42 | height: 180px; /* text area height */ 43 | width: 250px; /* text area width */ 44 | text-align: center; 45 | padding: 128px 0 0 0; /* image height is 128px */ 46 | margin: -154px 0 0 -124px; /* offset vertical: half of image height and text area height */ 47 | /* offset horizontal: half of text area width */ 48 | } 49 | 50 | /* Landscape layout (with min-width) */ 51 | @media screen and (min-aspect-ratio: 1/1) and (min-width: 400px) { 52 | .app { 53 | background-position: left center; 54 | padding: 0px 0px 0px 128px; /* give space to image on the left */ 55 | margin:-90px 0px 0px -189px; /* offset vertical: half of text height */ 56 | /* offset horizontal: half of image width and text area width */ 57 | } 58 | } 59 | 60 | .btn { 61 | border: 1px solid #888; 62 | border-radius: 5px; 63 | padding: 6px 8px; 64 | background: #fff; 65 | color: #000; 66 | transition: all .4s ease; 67 | } 68 | 69 | .btn:hover, .btn:active { 70 | background: #ddd; 71 | color: #000; 72 | } 73 | 74 | .event { 75 | border-radius: 4px; 76 | -webkit-border-radius: 4px; 77 | font-size: 14px; 78 | margin: 0px 30px; 79 | padding: 2px 0px; 80 | display: none; /* hide by default */ 81 | } 82 | .event-default { 83 | display: block; 84 | } 85 | 86 | @keyframes fade { 87 | from { opacity: 1.0; } 88 | 50% { opacity: 0.25; } 89 | to { opacity: 1.0; } 90 | } 91 | 92 | @-webkit-keyframes fade { 93 | from { opacity: 1.0; } 94 | 50% { opacity: 0.25; } 95 | to { opacity: 1.0; } 96 | } 97 | 98 | .blink { 99 | animation:fade 3000ms infinite; 100 | -webkit-animation:fade 3000ms infinite; 101 | } 102 | -------------------------------------------------------------------------------- /www/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/q-m/cordova-web-wrap/08cdf404d7d3ecdd4710da24251f5aacd2eadaac/www/img/logo.png -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | 22 | 31 | 32 | 33 | 34 | 35 | 36 | Example 37 | 38 | 39 |
40 |

Example

41 |

A stable and complete solution
for a mobile-website-based app.

42 | 43 | 44 | 45 | 46 |
Device is offline.
47 |
48 |
Sorry, loading failed. Retry
49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /www/js/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | // Pages starting with this URL are opened within the app, others in the system web browser (include any trailing slash!) 21 | var LANDING_URL= "https://thequestionmark.github.io/cordova-web-wrap/"; 22 | // URLs listed here open in the app, others in the system web browser. 23 | // Both absolute and host-relative URLs (with respect to LANDING_URL) are allowed. 24 | // The URL on test includes a leading slash, but does not include query string or hash. 25 | // Asterisks '*' act as a wildcard. Multiple entries are separated by a space. 26 | // Note that it can be overridden by the document using the `data-app-local-urls` attribute. 27 | var LOCAL_URLS = "/*"; 28 | 29 | 30 | // Regular expression for parsing full URLs, returning: base, path, query, hash. 31 | var SPLIT_URL_RE = /^([^:/]+:\/\/[^/]+)(\/[^?]*)(?:\?([^#]*))?(?:#(.*))?$/i; 32 | // Base URL for matching, derived from LANDING_URL (without trailing slash). 33 | var BASE_URL = LANDING_URL.match(SPLIT_URL_RE)[1]; 34 | 35 | // Main functionality using a state machine. 36 | var Fsm = machina.Fsm.extend({ 37 | 38 | initialState: "starting", 39 | 40 | appLastUrl: null, 41 | 42 | states: { 43 | 44 | // We're starting up (Cordova may not be ready yet). 45 | "starting": { 46 | _onEnter : function() { this.onStarting(); }, 47 | "deviceready" : "started", 48 | }, 49 | 50 | // Setup everything and start loading the website. 51 | "started": { 52 | _onEnter : function() { this.onStarted(); }, 53 | "conn.offline" : "offline.blank", 54 | }, 55 | 56 | // Load the page we want to show. 57 | "loading": { 58 | _onEnter : function(e) { this.onLoading(e); }, 59 | "app.beforeload" : function(e, cb) { this.onNavigate(e, cb); }, 60 | "app.loadstop" : "loaded", 61 | "app.loaderror" : "failed", 62 | "pause" : "paused", 63 | "conn.offline" : "offline.blank", 64 | }, 65 | 66 | // Paused during page load. 67 | // Sometimes loading continues at this stage, so we need handlers for these events. 68 | "paused": { 69 | "resume" : function() { this.onResume(); }, 70 | "app.loadstop" : "loaded", 71 | "app.loaderror" : "failed", 72 | "conn.offline" : "offline.blank", 73 | }, 74 | 75 | // Page was succesfully loaded in the inAppBrowser. 76 | "loaded": { 77 | _onEnter : function() { this.onLoaded(); }, 78 | "app.beforeload" : function(e, cb) { this.onNavigate(e, cb); }, 79 | "app.exit" : function() { this.onBrowserBack(); }, // top of navigation and back pressed 80 | "conn.offline" : "offline.loaded", 81 | }, 82 | 83 | // Page load failed. 84 | "failed": { 85 | _onEnter : function() { this.onFailed(); }, 86 | "retry" : function() { this.load(null, "loading"); }, 87 | "conn.offline" : "offline.blank", 88 | }, 89 | 90 | // Offline without a page loaded. 91 | "offline.blank": { 92 | _onEnter : function() { this.onOfflineBlank(); }, 93 | "conn.online" : function() { this.load(); }, 94 | }, 95 | 96 | // Offline and a page is loaded. 97 | "offline.loaded": { 98 | _onEnter : function() { this.onOfflineLoaded(); }, 99 | "conn.online" : "loaded", 100 | } 101 | }, 102 | 103 | initialize: function() { 104 | this.setLocalUrls(LOCAL_URLS); 105 | // Log state transitions. 106 | this.on("*", function(event, data) { 107 | if (event === "transition" || event === "transitioned") { 108 | var action = data.action ? data.action.split(".").slice(1).join(".") : "(none)"; 109 | debug(event + " from " + data.fromState + " to " + data.toState + " by " + action); 110 | } else if (event === "nohandler") { 111 | var transition = data.args[1].inputType; // may be a bit brittle 112 | debug("transition " + transition + " not handled"); 113 | } 114 | }); 115 | }, 116 | 117 | onStarting: function() { 118 | document.addEventListener("deviceready", this.handle.bind(this, "deviceready"), false); 119 | }, 120 | 121 | onStarted: function() { 122 | document.addEventListener("online", wrapEventListener(this.handle.bind(this, "conn.online")), false); 123 | document.addEventListener("offline", wrapEventListener(this.handle.bind(this, "conn.offline")), false); 124 | // retry button (debounce 0.5s) 125 | document.getElementById("retry").addEventListener("click", _.debounce(this.handle.bind(this, "retry"), 500), false); 126 | // run handler on main thread (iOS) - https://cordova.apache.org/docs/en/latest/cordova/events/events.html#ios-quirks 127 | document.addEventListener("pause", wrapEventListener(this.handle.bind(this, "pause")), false); 128 | document.addEventListener("resume", wrapEventListener(this.handle.bind(this, "resume")), false); 129 | 130 | // allow state transition to happen after this one 131 | setTimeout(function() { 132 | if (navigator.splashscreen) navigator.splashscreen.hide(); 133 | this.load(LANDING_URL, "loading"); 134 | }.bind(this), 0); 135 | }, 136 | 137 | load: function(url, messageCode) { 138 | var _url = url || this.appLastUrl || LANDING_URL; 139 | 140 | this.appLastUrl = _url; 141 | 142 | this._load(_url); 143 | this.transition("loading", messageCode); 144 | }, 145 | 146 | _load: function(url) { 147 | if (!this.app) { 148 | // When there is no inAppBrowser yet, open it. 149 | debug("load new: " + url); 150 | this.openBrowser(url); 151 | } else { 152 | // Otherwise keep the browser open and navigate to the new URL. 153 | debug("load existing: " + url); 154 | this.app.executeScript({ code: "window.location.assign(" + JSON.stringify(url) + ");" }); 155 | } 156 | }, 157 | 158 | onLoading: function(messageCode) { 159 | // if no code is given, it means: keep the same message as before (relevant for e.g. redirects) 160 | if (messageCode) this.showMessage(messageCode); 161 | }, 162 | 163 | onLoaded: function(e) { 164 | // Allow LOCAL_URLS to be set by the page. 165 | this.app.executeScript({ code: 166 | '(function () {\n' + 167 | ' var el = document.querySelector("[data-app-local-urls]");\n' + 168 | ' if (el) return el.getAttribute("data-app-local-urls");\n' + 169 | '})();' 170 | }, function(localUrls) { 171 | if (localUrls && localUrls[0]) this.setLocalUrls(localUrls[0]); 172 | }.bind(this)); 173 | // Show the page. 174 | this.showMessage(null); 175 | this.app.show(); 176 | }, 177 | 178 | onNavigate: function(e, cb) { 179 | if (e.url.match(/^app:\/\/mobile-scan\b/)) { 180 | // barcode scanner opened 181 | var params = parseQueryString(e.url) || {}; 182 | this.openScan(params.ret, !!params.redirect); 183 | } else if (this.isLocalUrl(e.url)) { 184 | // don't interfere with local urls 185 | debug("internal link: " + e.url); 186 | this.appLastUrl = e.url; 187 | cb(e.url); 188 | } else { 189 | // all other links are opened in the system web browser 190 | this.openSystemBrowser(e.url); 191 | } 192 | }, 193 | 194 | onBrowserBack: function() { 195 | debug("final back pressed, closing app"); 196 | navigator.app.exitApp(); 197 | }, 198 | 199 | onResume: function() { 200 | this.load(); 201 | }, 202 | 203 | onFailed: function() { 204 | this.showMessage("failed"); 205 | }, 206 | 207 | onOfflineBlank: function() { 208 | this.showMessage("offline"); 209 | }, 210 | 211 | onOfflineLoaded: function() { 212 | this.showMessage("offline"); 213 | }, 214 | 215 | openBrowser: function(url) { 216 | var _url = url || this.appLastUrl || LANDING_URL; 217 | this.app = cordova.InAppBrowser.open(_url, "_blank", "location=no,zoom=no,shouldPauseOnSuspend=yes,toolbar=no,hidden=yes,beforeload=yes"); 218 | // Connect state-machine to inAppBrowser events. 219 | this.app.addEventListener("loadstart", wrapEventListener(this.handle.bind(this, "app.loadstart")), false); 220 | this.app.addEventListener("loadstop", wrapEventListener(this.handle.bind(this, "app.loadstop")), false); 221 | this.app.addEventListener("loaderror", wrapEventListener(function(e) { 222 | if (window.cordova.platformId === 'ios' && e.code === -999) { 223 | debug("ignoring cancelled load on iOS: " + e.url + ": " + e.message); 224 | } else { 225 | debug("page load failed for " + e.url + ": " + e.message); 226 | this.handle("app.loaderror", e); 227 | } 228 | }.bind(this)), false); 229 | this.app.addEventListener("beforeload", wrapEventListener(this.handle.bind(this, "app.beforeload")), false); 230 | this.app.addEventListener("exit", wrapEventListener(this.handle.bind(this, "app.exit")), false); 231 | }, 232 | 233 | openSystemBrowser: function(url) { 234 | var launcher = window.plugins.launcher; 235 | // Need FLAG_ACTIVITY_NEW_TASK on Android 6 to make it clear that the page is 236 | // opened in another app. Also, the back button doesn't bring you back from 237 | // the system web browser to this app on Android 6, with this flag it does. 238 | launcher.launch({uri: url, flags: launcher.FLAG_ACTIVITY_NEW_TASK}, function(data) { 239 | if (data.isLaunched) { 240 | debug("successfully opened external link: " + url); 241 | } else if (data.isActivityDone) { 242 | debug("returned from opening external link: " + url); 243 | } else { 244 | debug("unknown response when opening external link: " + JSON.stringify(data)); 245 | } 246 | }, function(errMsg) { 247 | debug("could not open external link: " + errMsg); 248 | }); 249 | }, 250 | 251 | openScan: function(returnUrlTemplate) { 252 | debug("openScan: " + returnUrlTemplate); 253 | cordova.plugins.barcodeScanner.scan( 254 | function(result) { 255 | if (result.cancelled) { 256 | debug("scan cancelled"); 257 | // necessary on iOS, see below 258 | if (window.cordova.platformId === 'ios') this.showMessage(null); 259 | } else { 260 | debug("scan result: " + result.text); 261 | this.openScanUrl(returnUrlTemplate, result.text); 262 | } 263 | }.bind(this), 264 | function(error) { 265 | debug("scan failed: " + error); 266 | alert("Scan failed: " + error); 267 | // necessary on iOS, see below 268 | if (window.cordova.platformId === 'ios') this.showMessage(null); 269 | }.bind(this), 270 | { 271 | saveHistory: true, 272 | resultDisplayDuration: 500, 273 | formats: "UPC_A,UPC_E,EAN_8,EAN_13", 274 | disableSuccessBeep: true 275 | } 276 | ); 277 | // necessary on iOS, see https://github.com/phonegap/phonegap-plugin-barcodescanner/issues/570 278 | if (window.cordova.platformId === 'ios') this.showMessage("scanning"); 279 | }, 280 | 281 | openScanUrl: function(returnUrlTemplate, barcode) { 282 | if (!returnUrlTemplate) { 283 | debug("scan: missing query parameter for the return url, please in include 'ret'"); 284 | alert("Scan failed (return url missing)"); 285 | return false; 286 | } 287 | if (!returnUrlTemplate.includes('{CODE}')) { 288 | debug("scan: {CODE} missing in the return parameter") 289 | alert("Scan failed (code missing in return url)"); 290 | return false; 291 | } 292 | // Be a bit safer and only keep numbers (XSS risk). 293 | var safeBarcode = barcode.replace(/[^\d]/g, ''); 294 | var url = returnUrlTemplate.replace('{CODE}', safeBarcode); 295 | this.load(url, "finding"); 296 | return true; 297 | }, 298 | 299 | // Show message. 300 | showMessage: function(code) { 301 | debug("showMessage: " + code); 302 | if (this.app && !code) this.app.show(); 303 | document.getElementById("event-starting").style.display = code === "starting" ? "block" : "none"; 304 | document.getElementById("event-offline" ).style.display = code === "offline" ? "block" : "none"; 305 | document.getElementById("event-loading" ).style.display = code === "loading" ? "block" : "none"; 306 | document.getElementById("event-scanning").style.display = code === "scanning" ? "block" : "none"; 307 | document.getElementById("event-finding" ).style.display = code === "finding" ? "block" : "none"; 308 | document.getElementById("event-failure" ).style.display = code === "failed" ? "block" : "none"; 309 | if (this.app && code) this.app.hide(); 310 | }, 311 | 312 | // Update local URLs and the regular expression for testing them. 313 | setLocalUrls: function(urls) { 314 | debug("setLocalUrls: " + urls); 315 | this.localUrls = urls; 316 | this.localUrlRe = new RegExp('^(' + urls.trim().split(/\s+/).map(function(s) { 317 | return s.replace(/([.*+?^${}()|\[\]\/\\])/g, '\\$1').replace('\\*', '.*'); 318 | }).join('|') + ')$'); 319 | }, 320 | 321 | isLocalUrl: function(url) { 322 | var parts = url.match(SPLIT_URL_RE); 323 | if (parts) { 324 | var base = parts[1], path = parts[2]; 325 | return (base + path).match(this.localUrlRe) || (base === BASE_URL && path.match(this.localUrlRe)); 326 | } 327 | } 328 | }); 329 | var fsm = new Fsm(); 330 | -------------------------------------------------------------------------------- /www/js/machina.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * * machina - A library for creating powerful and flexible finite state machines. Loosely inspired by Erlang/OTP's gen_fsm behavior. 3 | * * Author: Jim Cowart (http://ifandelse.com) 4 | * * Version: v2.0.2 5 | * * Url: http://machina-js.org/ 6 | * * License(s): 7 | */ 8 | (function webpackUniversalModuleDefinition(root, factory) { 9 | if(typeof exports === 'object' && typeof module === 'object') 10 | module.exports = factory(require("lodash")); 11 | else if(typeof define === 'function' && define.amd) 12 | define(["lodash"], factory); 13 | else if(typeof exports === 'object') 14 | exports["machina"] = factory(require("lodash")); 15 | else 16 | root["machina"] = factory(root["_"]); 17 | })(this, function(__WEBPACK_EXTERNAL_MODULE_1__) { 18 | return /******/ (function(modules) { // webpackBootstrap 19 | /******/ // The module cache 20 | /******/ var installedModules = {}; 21 | /******/ 22 | /******/ // The require function 23 | /******/ function __webpack_require__(moduleId) { 24 | /******/ 25 | /******/ // Check if module is in cache 26 | /******/ if(installedModules[moduleId]) 27 | /******/ return installedModules[moduleId].exports; 28 | /******/ 29 | /******/ // Create a new module (and put it into the cache) 30 | /******/ var module = installedModules[moduleId] = { 31 | /******/ exports: {}, 32 | /******/ id: moduleId, 33 | /******/ loaded: false 34 | /******/ }; 35 | /******/ 36 | /******/ // Execute the module function 37 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 38 | /******/ 39 | /******/ // Flag the module as loaded 40 | /******/ module.loaded = true; 41 | /******/ 42 | /******/ // Return the exports of the module 43 | /******/ return module.exports; 44 | /******/ } 45 | /******/ 46 | /******/ 47 | /******/ // expose the modules object (__webpack_modules__) 48 | /******/ __webpack_require__.m = modules; 49 | /******/ 50 | /******/ // expose the module cache 51 | /******/ __webpack_require__.c = installedModules; 52 | /******/ 53 | /******/ // __webpack_public_path__ 54 | /******/ __webpack_require__.p = ""; 55 | /******/ 56 | /******/ // Load entry module and return exports 57 | /******/ return __webpack_require__(0); 58 | /******/ }) 59 | /************************************************************************/ 60 | /******/ ([ 61 | /* 0 */ 62 | /***/ (function(module, exports, __webpack_require__) { 63 | 64 | var _ = __webpack_require__( 1 ); 65 | var emitter = __webpack_require__( 2 ); 66 | 67 | module.exports = _.merge( emitter.instance, { 68 | Fsm: __webpack_require__( 5 ), 69 | BehavioralFsm: __webpack_require__( 6 ), 70 | utils: __webpack_require__( 3 ), 71 | eventListeners: { 72 | newFsm: [] 73 | } 74 | } ); 75 | 76 | 77 | /***/ }), 78 | /* 1 */ 79 | /***/ (function(module, exports) { 80 | 81 | module.exports = __WEBPACK_EXTERNAL_MODULE_1__; 82 | 83 | /***/ }), 84 | /* 2 */ 85 | /***/ (function(module, exports, __webpack_require__) { 86 | 87 | var utils = __webpack_require__( 3 ); 88 | var _ = __webpack_require__( 1 ); 89 | 90 | function getInstance() { 91 | return { 92 | emit: function( eventName ) { 93 | var args = utils.getLeaklessArgs( arguments ); 94 | if ( this.eventListeners[ "*" ] ) { 95 | _.each( this.eventListeners[ "*" ], function( callback ) { 96 | if ( !this.useSafeEmit ) { 97 | callback.apply( this, args ); 98 | } else { 99 | try { 100 | callback.apply( this, args ); 101 | } catch ( exception ) { 102 | /* istanbul ignore else */ 103 | if ( console && typeof console.log !== "undefined" ) { 104 | console.log( exception.stack ); 105 | } 106 | } 107 | } 108 | }, this ); 109 | } 110 | if ( this.eventListeners[ eventName ] ) { 111 | _.each( this.eventListeners[ eventName ], function( callback ) { 112 | if ( !this.useSafeEmit ) { 113 | callback.apply( this, args.slice( 1 ) ); 114 | } else { 115 | try { 116 | callback.apply( this, args.slice( 1 ) ); 117 | } catch ( exception ) { 118 | /* istanbul ignore else */ 119 | if ( console && typeof console.log !== "undefined" ) { 120 | console.log( exception.stack ); 121 | } 122 | } 123 | } 124 | }, this ); 125 | } 126 | }, 127 | 128 | on: function( eventName, callback ) { 129 | var self = this; 130 | self.eventListeners = self.eventListeners || { "*": [] }; 131 | if ( !self.eventListeners[ eventName ] ) { 132 | self.eventListeners[ eventName ] = []; 133 | } 134 | self.eventListeners[ eventName ].push( callback ); 135 | return { 136 | eventName: eventName, 137 | callback: callback, 138 | off: function() { 139 | self.off( eventName, callback ); 140 | } 141 | }; 142 | }, 143 | 144 | off: function( eventName, callback ) { 145 | this.eventListeners = this.eventListeners || { "*": [] }; 146 | if ( !eventName ) { 147 | this.eventListeners = {}; 148 | } else { 149 | if ( callback ) { 150 | this.eventListeners[ eventName ] = _.without( this.eventListeners[ eventName ], callback ); 151 | } else { 152 | this.eventListeners[ eventName ] = []; 153 | } 154 | } 155 | } 156 | }; 157 | } 158 | 159 | module.exports = { 160 | getInstance: getInstance, 161 | instance: getInstance() 162 | }; 163 | 164 | 165 | /***/ }), 166 | /* 3 */ 167 | /***/ (function(module, exports, __webpack_require__) { 168 | 169 | var slice = [].slice; 170 | var events = __webpack_require__( 4 ); 171 | var _ = __webpack_require__( 1 ); 172 | 173 | var makeFsmNamespace = ( function() { 174 | var machinaCount = 0; 175 | return function() { 176 | return "fsm." + machinaCount++; 177 | }; 178 | } )(); 179 | 180 | function getDefaultBehavioralOptions() { 181 | return { 182 | initialState: "uninitialized", 183 | eventListeners: { 184 | "*": [] 185 | }, 186 | states: {}, 187 | namespace: makeFsmNamespace(), 188 | useSafeEmit: false, 189 | hierarchy: {}, 190 | pendingDelegations: {} 191 | }; 192 | } 193 | 194 | function getDefaultClientMeta() { 195 | return { 196 | inputQueue: [], 197 | targetReplayState: "", 198 | state: undefined, 199 | priorState: undefined, 200 | priorAction: "", 201 | currentAction: "", 202 | currentActionArgs: undefined, 203 | inExitHandler: false 204 | }; 205 | } 206 | 207 | function getLeaklessArgs( args, startIdx ) { 208 | var result = []; 209 | for ( var i = ( startIdx || 0 ); i < args.length; i++ ) { 210 | result[ i ] = args[ i ]; 211 | } 212 | return result; 213 | } 214 | /* 215 | handle -> 216 | child = stateObj._child && stateObj._child.instance; 217 | 218 | transition -> 219 | newStateObj._child = getChildFsmInstance( newStateObj._child ); 220 | child = newStateObj._child && newStateObj._child.instance; 221 | */ 222 | function getChildFsmInstance( config ) { 223 | if ( !config ) { 224 | return; 225 | } 226 | var childFsmDefinition = {}; 227 | if ( typeof config === "object" ) { 228 | // is this a config object with a factory? 229 | if ( config.factory ) { 230 | childFsmDefinition = config; 231 | childFsmDefinition.instance = childFsmDefinition.factory(); 232 | } else { 233 | // assuming this is a machina instance 234 | childFsmDefinition.factory = function() { 235 | return config; 236 | }; 237 | } 238 | } else if ( typeof config === "function" ) { 239 | childFsmDefinition.factory = config; 240 | } 241 | childFsmDefinition.instance = childFsmDefinition.factory(); 242 | return childFsmDefinition; 243 | } 244 | 245 | function listenToChild( fsm, child ) { 246 | // Need to investigate potential for discarded event 247 | // listener memory leak in long-running, deeply-nested hierarchies. 248 | return child.on( "*", function( eventName, data ) { 249 | switch ( eventName ) { 250 | case events.NO_HANDLER: 251 | if ( !data.ticket && !data.delegated && data.namespace !== fsm.namespace ) { 252 | // Ok - we're dealing w/ a child handling input that should bubble up 253 | data.args[ 1 ].bubbling = true; 254 | } 255 | // we do NOT bubble _reset inputs up to the parent 256 | if ( data.inputType !== "_reset" ) { 257 | fsm.handle.apply( fsm, data.args ); 258 | } 259 | break; 260 | case events.HANDLING : 261 | var ticket = data.ticket; 262 | if ( ticket && fsm.pendingDelegations[ ticket ] ) { 263 | delete fsm.pendingDelegations[ ticket ]; 264 | } 265 | fsm.emit( eventName, data ); // possibly transform payload? 266 | break; 267 | default: 268 | fsm.emit( eventName, data ); // possibly transform payload? 269 | break; 270 | } 271 | } ); 272 | } 273 | 274 | // _machKeys are members we want to track across the prototype chain of an extended FSM constructor 275 | // Since we want to eventually merge the aggregate of those values onto the instance so that FSMs 276 | // that share the same extended prototype won't share state *on* those prototypes. 277 | var _machKeys = [ "states", "initialState" ]; 278 | var extend = function( protoProps, staticProps ) { 279 | var parent = this; 280 | var fsm; // placeholder for instance constructor 281 | var machObj = {}; // object used to hold initialState & states from prototype for instance-level merging 282 | var Ctor = function() {}; // placeholder ctor function used to insert level in prototype chain 283 | 284 | // The constructor function for the new subclass is either defined by you 285 | // (the "constructor" property in your `extend` definition), or defaulted 286 | // by us to simply call the parent's constructor. 287 | if ( protoProps && protoProps.hasOwnProperty( "constructor" ) ) { 288 | fsm = protoProps.constructor; 289 | } else { 290 | // The default machina constructor (when using inheritance) creates a 291 | // deep copy of the states/initialState values from the prototype and 292 | // extends them over the instance so that they'll be instance-level. 293 | // If an options arg (args[0]) is passed in, a states or intialState 294 | // value will be preferred over any data pulled up from the prototype. 295 | fsm = function() { 296 | var args = slice.call( arguments, 0 ); 297 | args[ 0 ] = args[ 0 ] || {}; 298 | var blendedState; 299 | var instanceStates = args[ 0 ].states || {}; 300 | blendedState = _.merge( _.cloneDeep( machObj ), { states: instanceStates } ); 301 | blendedState.initialState = args[ 0 ].initialState || this.initialState; 302 | _.extend( args[ 0 ], blendedState ); 303 | parent.apply( this, args ); 304 | }; 305 | } 306 | 307 | // Inherit class (static) properties from parent. 308 | _.merge( fsm, parent ); 309 | 310 | // Set the prototype chain to inherit from `parent`, without calling 311 | // `parent`'s constructor function. 312 | Ctor.prototype = parent.prototype; 313 | fsm.prototype = new Ctor(); 314 | 315 | // Add prototype properties (instance properties) to the subclass, 316 | // if supplied. 317 | if ( protoProps ) { 318 | _.extend( fsm.prototype, protoProps ); 319 | _.merge( machObj, _.transform( protoProps, function( accum, val, key ) { 320 | if ( _machKeys.indexOf( key ) !== -1 ) { 321 | accum[ key ] = val; 322 | } 323 | } ) ); 324 | } 325 | 326 | // Add static properties to the constructor function, if supplied. 327 | if ( staticProps ) { 328 | _.merge( fsm, staticProps ); 329 | } 330 | 331 | // Correctly set child's `prototype.constructor`. 332 | fsm.prototype.constructor = fsm; 333 | 334 | // Set a convenience property in case the parent's prototype is needed later. 335 | fsm.__super__ = parent.prototype; 336 | return fsm; 337 | }; 338 | 339 | function createUUID() { 340 | var s = []; 341 | var hexDigits = "0123456789abcdef"; 342 | for ( var i = 0; i < 36; i++ ) { 343 | s[ i ] = hexDigits.substr( Math.floor( Math.random() * 0x10 ), 1 ); 344 | } 345 | s[ 14 ] = "4"; // bits 12-15 of the time_hi_and_version field to 0010 346 | /* jshint ignore:start */ 347 | s[ 19 ] = hexDigits.substr( ( s[ 19 ] & 0x3 ) | 0x8, 1 ); // bits 6-7 of the clock_seq_hi_and_reserved to 01 348 | /* jshint ignore:end */ 349 | s[ 8 ] = s[ 13 ] = s[ 18 ] = s[ 23 ] = "-"; 350 | return s.join( "" ); 351 | } 352 | 353 | module.exports = { 354 | createUUID: createUUID, 355 | extend: extend, 356 | getDefaultBehavioralOptions: getDefaultBehavioralOptions, 357 | getDefaultOptions: getDefaultBehavioralOptions, 358 | getDefaultClientMeta: getDefaultClientMeta, 359 | getChildFsmInstance: getChildFsmInstance, 360 | getLeaklessArgs: getLeaklessArgs, 361 | listenToChild: listenToChild, 362 | makeFsmNamespace: makeFsmNamespace 363 | }; 364 | 365 | 366 | /***/ }), 367 | /* 4 */ 368 | /***/ (function(module, exports) { 369 | 370 | module.exports = { 371 | NEXT_TRANSITION: "transition", 372 | HANDLING: "handling", 373 | HANDLED: "handled", 374 | NO_HANDLER: "nohandler", 375 | TRANSITION: "transition", 376 | TRANSITIONED: "transitioned", 377 | INVALID_STATE: "invalidstate", 378 | DEFERRED: "deferred", 379 | NEW_FSM: "newfsm" 380 | }; 381 | 382 | 383 | /***/ }), 384 | /* 5 */ 385 | /***/ (function(module, exports, __webpack_require__) { 386 | 387 | var BehavioralFsm = __webpack_require__( 6 ); 388 | var utils = __webpack_require__( 3 ); 389 | var _ = __webpack_require__( 1 ); 390 | 391 | var Fsm = { 392 | constructor: function() { 393 | BehavioralFsm.apply( this, arguments ); 394 | this.ensureClientMeta(); 395 | }, 396 | initClient: function initClient() { 397 | var initialState = this.initialState; 398 | if ( !initialState ) { 399 | throw new Error( "You must specify an initial state for this FSM" ); 400 | } 401 | if ( !this.states[ initialState ] ) { 402 | throw new Error( "The initial state specified does not exist in the states object." ); 403 | } 404 | this.transition( initialState ); 405 | }, 406 | ensureClientMeta: function ensureClientMeta() { 407 | if ( !this._stamped ) { 408 | this._stamped = true; 409 | _.defaults( this, _.cloneDeep( utils.getDefaultClientMeta() ) ); 410 | this.initClient(); 411 | } 412 | return this; 413 | }, 414 | 415 | ensureClientArg: function( args ) { 416 | var _args = args; 417 | // we need to test the args and verify that if a client arg has 418 | // been passed, it must be this FSM instance (this isn't a behavioral FSM) 419 | if ( typeof _args[ 0 ] === "object" && !( "inputType" in _args[ 0 ] ) && _args[ 0 ] !== this ) { 420 | _args.splice( 0, 1, this ); 421 | } else if ( typeof _args[ 0 ] !== "object" || ( typeof _args[ 0 ] === "object" && ( "inputType" in _args[ 0 ] ) ) ) { 422 | _args.unshift( this ); 423 | } 424 | return _args; 425 | }, 426 | 427 | getHandlerArgs: function( args, isCatchAll ) { 428 | // index 0 is the client, index 1 is inputType 429 | // if we're in a catch-all handler, input type needs to be included in the args 430 | // inputType might be an object, so we need to just get the inputType string if so 431 | var _args = args; 432 | var input = _args[ 1 ]; 433 | if ( typeof inputType === "object" ) { 434 | _args.splice( 1, 1, input.inputType ); 435 | } 436 | return isCatchAll ? 437 | _args.slice( 1 ) : 438 | _args.slice( 2 ); 439 | }, 440 | 441 | getSystemHandlerArgs: function( args, client ) { 442 | return args; 443 | }, 444 | 445 | // "classic" machina FSM do not emit the client property on events (which would be the FSM itself) 446 | buildEventPayload: function() { 447 | var args = this.ensureClientArg( utils.getLeaklessArgs( arguments ) ); 448 | var data = args[ 1 ]; 449 | if ( _.isPlainObject( data ) ) { 450 | return _.extend( data, { namespace: this.namespace } ); 451 | } else { 452 | return { data: data || null, namespace: this.namespace }; 453 | } 454 | } 455 | }; 456 | 457 | _.each( [ 458 | "handle", 459 | "transition", 460 | "deferUntilTransition", 461 | "processQueue", 462 | "clearQueue" 463 | ], function( methodWithClientInjected ) { 464 | Fsm[ methodWithClientInjected ] = function() { 465 | var args = this.ensureClientArg( utils.getLeaklessArgs( arguments ) ); 466 | return BehavioralFsm.prototype[ methodWithClientInjected ].apply( this, args ); 467 | }; 468 | } ); 469 | 470 | Fsm = BehavioralFsm.extend( Fsm ); 471 | 472 | module.exports = Fsm; 473 | 474 | 475 | /***/ }), 476 | /* 6 */ 477 | /***/ (function(module, exports, __webpack_require__) { 478 | 479 | var _ = __webpack_require__( 1 ); 480 | var utils = __webpack_require__( 3 ); 481 | var emitter = __webpack_require__( 2 ); 482 | var topLevelEmitter = emitter.instance; 483 | var events = __webpack_require__( 4 ); 484 | 485 | var MACHINA_PROP = "__machina__"; 486 | 487 | function BehavioralFsm( options ) { 488 | _.extend( this, options ); 489 | _.defaults( this, utils.getDefaultBehavioralOptions() ); 490 | this.initialize.apply( this, arguments ); 491 | topLevelEmitter.emit( events.NEW_FSM, this ); 492 | } 493 | 494 | _.extend( BehavioralFsm.prototype, { 495 | initialize: function() {}, 496 | 497 | initClient: function initClient( client ) { 498 | var initialState = this.initialState; 499 | if ( !initialState ) { 500 | throw new Error( "You must specify an initial state for this FSM" ); 501 | } 502 | if ( !this.states[ initialState ] ) { 503 | throw new Error( "The initial state specified does not exist in the states object." ); 504 | } 505 | this.transition( client, initialState ); 506 | }, 507 | 508 | configForState: function configForState( newState ) { 509 | var newStateObj = this.states[ newState ]; 510 | var child; 511 | _.each( this.hierarchy, function( childListener, key ) { 512 | if ( childListener && typeof childListener.off === "function" ) { 513 | childListener.off(); 514 | } 515 | } ); 516 | 517 | if ( newStateObj._child ) { 518 | newStateObj._child = utils.getChildFsmInstance( newStateObj._child ); 519 | child = newStateObj._child && newStateObj._child.instance; 520 | this.hierarchy[ child.namespace ] = utils.listenToChild( this, child ); 521 | } 522 | 523 | return child; 524 | }, 525 | 526 | ensureClientMeta: function ensureClientMeta( client ) { 527 | if ( typeof client !== "object" ) { 528 | throw new Error( "An FSM client must be an object." ); 529 | } 530 | client[ MACHINA_PROP ] = client[ MACHINA_PROP ] || {}; 531 | if ( !client[ MACHINA_PROP ][ this.namespace ] ) { 532 | client[ MACHINA_PROP ][ this.namespace ] = _.cloneDeep( utils.getDefaultClientMeta() ); 533 | this.initClient( client ); 534 | } 535 | return client[ MACHINA_PROP ][ this.namespace ]; 536 | }, 537 | 538 | buildEventPayload: function( client, data ) { 539 | if ( _.isPlainObject( data ) ) { 540 | return _.extend( data, { client: client, namespace: this.namespace } ); 541 | } else { 542 | return { client: client, data: data || null, namespace: this.namespace }; 543 | } 544 | }, 545 | 546 | getHandlerArgs: function( args, isCatchAll ) { 547 | // index 0 is the client, index 1 is inputType 548 | // if we're in a catch-all handler, input type needs to be included in the args 549 | // inputType might be an object, so we need to just get the inputType string if so 550 | var _args = args.slice( 0 ); 551 | var input = _args[ 1 ]; 552 | if ( typeof input === "object" ) { 553 | _args.splice( 1, 1, input.inputType ); 554 | } 555 | return isCatchAll ? 556 | _args : 557 | [ _args[ 0 ] ].concat( _args.slice( 2 ) ); 558 | }, 559 | 560 | getSystemHandlerArgs: function( args, client ) { 561 | return [ client ].concat( args ); 562 | }, 563 | 564 | handle: function( client, input ) { 565 | var inputDef = input; 566 | if ( typeof input === "undefined" ) { 567 | throw new Error( "The input argument passed to the FSM's handle method is undefined. Did you forget to pass the input name?" ); 568 | } 569 | if ( typeof input === "string" ) { 570 | inputDef = { inputType: input, delegated: false, ticket: undefined }; 571 | } 572 | var clientMeta = this.ensureClientMeta( client ); 573 | var args = utils.getLeaklessArgs( arguments ); 574 | if ( typeof input !== "object" ) { 575 | args.splice( 1, 1, inputDef ); 576 | } 577 | clientMeta.currentActionArgs = args.slice( 1 ); 578 | var currentState = clientMeta.state; 579 | var stateObj = this.states[ currentState ]; 580 | var handlerName; 581 | var handler; 582 | var isCatchAll = false; 583 | var child; 584 | var result; 585 | var action; 586 | if ( !clientMeta.inExitHandler ) { 587 | child = this.configForState( currentState ); 588 | if ( child && !this.pendingDelegations[ inputDef.ticket ] && !inputDef.bubbling ) { 589 | inputDef.ticket = ( inputDef.ticket || utils.createUUID() ); 590 | inputDef.delegated = true; 591 | this.pendingDelegations[ inputDef.ticket ] = { delegatedTo: child.namespace }; 592 | // WARNING - returning a value from `handle` on child FSMs is not really supported. 593 | // If you need to return values from child FSM input handlers, use events instead. 594 | result = child.handle.apply( child, args ); 595 | } else { 596 | if ( inputDef.ticket && this.pendingDelegations[ inputDef.ticket ] ) { 597 | delete this.pendingDelegations[ inputDef.ticket ]; 598 | } 599 | handlerName = stateObj[ inputDef.inputType ] ? inputDef.inputType : "*"; 600 | isCatchAll = ( handlerName === "*" ); 601 | handler = ( stateObj[ handlerName ] || this[ handlerName ] ) || this[ "*" ]; 602 | action = clientMeta.state + "." + handlerName; 603 | clientMeta.currentAction = action; 604 | var eventPayload = this.buildEventPayload( 605 | client, 606 | { inputType: inputDef.inputType, delegated: inputDef.delegated, ticket: inputDef.ticket } 607 | ); 608 | if ( !handler ) { 609 | this.emit( events.NO_HANDLER, _.extend( { args: args }, eventPayload ) ); 610 | } else { 611 | this.emit( events.HANDLING, eventPayload ); 612 | if ( typeof handler === "function" ) { 613 | result = handler.apply( this, this.getHandlerArgs( args, isCatchAll ) ); 614 | } else { 615 | result = handler; 616 | this.transition( client, handler ); 617 | } 618 | this.emit( events.HANDLED, eventPayload ); 619 | } 620 | clientMeta.priorAction = clientMeta.currentAction; 621 | clientMeta.currentAction = ""; 622 | clientMeta.currentActionArgs = undefined; 623 | } 624 | } 625 | return result; 626 | }, 627 | 628 | transition: function( client, newState ) { 629 | var clientMeta = this.ensureClientMeta( client ); 630 | var curState = clientMeta.state; 631 | var curStateObj = this.states[ curState ]; 632 | var newStateObj = this.states[ newState ]; 633 | var child; 634 | var args = utils.getLeaklessArgs( arguments ).slice( 2 ); 635 | if ( !clientMeta.inExitHandler && newState !== curState ) { 636 | if ( newStateObj ) { 637 | child = this.configForState( newState ); 638 | if ( curStateObj && curStateObj._onExit ) { 639 | clientMeta.inExitHandler = true; 640 | curStateObj._onExit.call( this, client ); 641 | clientMeta.inExitHandler = false; 642 | } 643 | clientMeta.targetReplayState = newState; 644 | clientMeta.priorState = curState; 645 | clientMeta.state = newState; 646 | var eventPayload = this.buildEventPayload( client, { 647 | fromState: clientMeta.priorState, 648 | action: clientMeta.currentAction, 649 | toState: newState 650 | } ); 651 | this.emit( events.TRANSITION, eventPayload ); 652 | if ( newStateObj._onEnter ) { 653 | newStateObj._onEnter.apply( this, this.getSystemHandlerArgs( args, client ) ); 654 | } 655 | this.emit( events.TRANSITIONED, eventPayload ); 656 | if ( child ) { 657 | child.handle( client, "_reset" ); 658 | } 659 | 660 | if ( clientMeta.targetReplayState === newState ) { 661 | this.processQueue( client, events.NEXT_TRANSITION ); 662 | } 663 | return; 664 | } 665 | this.emit( events.INVALID_STATE, this.buildEventPayload( client, { 666 | state: clientMeta.state, 667 | attemptedState: newState 668 | } ) ); 669 | } 670 | }, 671 | 672 | deferUntilTransition: function( client, stateName ) { 673 | var clientMeta = this.ensureClientMeta( client ); 674 | if ( clientMeta.currentActionArgs ) { 675 | var queued = { 676 | type: events.NEXT_TRANSITION, 677 | untilState: stateName, 678 | args: clientMeta.currentActionArgs 679 | }; 680 | clientMeta.inputQueue.push( queued ); 681 | var eventPayload = this.buildEventPayload( client, { 682 | state: clientMeta.state, 683 | queuedArgs: queued 684 | } ); 685 | this.emit( events.DEFERRED, eventPayload ); 686 | } 687 | }, 688 | 689 | deferAndTransition: function( client, stateName ) { 690 | this.deferUntilTransition( client, stateName ); 691 | this.transition( client, stateName ); 692 | }, 693 | 694 | processQueue: function( client ) { 695 | var clientMeta = this.ensureClientMeta( client ); 696 | var filterFn = function( item ) { 697 | return ( ( !item.untilState ) || ( item.untilState === clientMeta.state ) ); 698 | }; 699 | var toProcess = _.filter( clientMeta.inputQueue, filterFn ); 700 | clientMeta.inputQueue = _.difference( clientMeta.inputQueue, toProcess ); 701 | _.each( toProcess, function( item ) { 702 | this.handle.apply( this, [ client ].concat( item.args ) ); 703 | }.bind( this ) ); 704 | }, 705 | 706 | clearQueue: function( client, name ) { 707 | var clientMeta = this.ensureClientMeta( client ); 708 | if ( !name ) { 709 | clientMeta.inputQueue = []; 710 | } else { 711 | var filter = function( evnt ) { 712 | return ( name ? evnt.untilState !== name : true ); 713 | }; 714 | clientMeta.inputQueue = _.filter( clientMeta.inputQueue, filter ); 715 | } 716 | }, 717 | 718 | compositeState: function( client ) { 719 | var clientMeta = this.ensureClientMeta( client ); 720 | var state = clientMeta.state; 721 | var child = this.states[state]._child && this.states[state]._child.instance; 722 | if ( child ) { 723 | state += "." + child.compositeState( client ); 724 | } 725 | return state; 726 | } 727 | }, emitter.getInstance() ); 728 | 729 | BehavioralFsm.extend = utils.extend; 730 | 731 | module.exports = BehavioralFsm; 732 | 733 | 734 | /***/ }) 735 | /******/ ]) 736 | }); 737 | ; 738 | -------------------------------------------------------------------------------- /www/js/polyfill.js: -------------------------------------------------------------------------------- 1 | 2 | // Needed for Android 4.2 3 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes#Polyfill 4 | if (!String.prototype.includes) { 5 | String.prototype.includes = function(search, start) { 6 | 'use strict'; 7 | if (typeof start !== 'number') { 8 | start = 0; 9 | } 10 | 11 | if (start + search.length > this.length) { 12 | return false; 13 | } else { 14 | return this.indexOf(search, start) !== -1; 15 | } 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /www/js/util.js: -------------------------------------------------------------------------------- 1 | 2 | // Message functions, see https://stackoverflow.com/a/32928812/2866660 3 | var debug = console.log.bind(window.console); 4 | 5 | // Wraps event listener in a setTimeout() on iOS (or risk WebView problems) 6 | if (window.cordova.platformId === 'ios') { 7 | function wrapEventListener(listener) { 8 | setTimeout(listener.bind(this), 0); 9 | } 10 | } else { 11 | function wrapEventListener(listener) { 12 | return listener; 13 | } 14 | } 15 | 16 | // Parse query string from a URL into an object. 17 | // after https://www.joezimjs.com/javascript/3-ways-to-parse-a-query-string-in-a-url/ 18 | var parseQueryString = function(url) { 19 | var params = {}, queryString, queries, temp, i, l; 20 | // Extract query string 21 | queryString = url.substring( url.indexOf('?') + 1 ); 22 | // Split into key/value pairs 23 | queries = queryString.split('&'); 24 | // Convert the array of strings into an object 25 | for ( i = 0, l = queries.length; i < l; i++ ) { 26 | temp = queries[i].split('=', 2); 27 | params[decodeURIComponent(temp[0])] = decodeURIComponent(temp[1]); 28 | } 29 | return params; 30 | }; 31 | --------------------------------------------------------------------------------