├── .gitignore ├── CHANGELOG.md ├── README.md ├── bin ├── cmd_btlejuice.js └── cmd_btlejuice_proxy.js ├── core.js ├── doc └── images │ ├── btlejuice-export.png │ ├── btlejuice-hook.png │ ├── btlejuice-intercept.png │ ├── btlejuice-main-ui.png │ ├── btlejuice-replay.png │ ├── btlejuice-settings.png │ ├── btlejuice-sniffing.png │ └── btlejuice-target-select.png ├── fake.js ├── logging.js ├── package.json ├── proxy.js ├── resources ├── css │ ├── bootstrap-theme.css │ ├── bootstrap-theme.css.map │ ├── bootstrap-theme.min.css │ ├── bootstrap-theme.min.css.map │ ├── bootstrap-treeview.min.css │ ├── bootstrap.css │ ├── bootstrap.css.map │ ├── bootstrap.min.css │ ├── bootstrap.min.css.map │ ├── btlejuice.css │ ├── font-awesome.css │ └── font-awesome.min.css ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.svg │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ ├── fontawesome-webfont.woff2 │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.svg │ ├── glyphicons-halflings-regular.ttf │ ├── glyphicons-halflings-regular.woff │ └── glyphicons-halflings-regular.woff2 └── js │ ├── angular.min.js │ ├── binbuf.js │ ├── bj_proxy.js │ ├── bootstrap.js │ ├── bootstrap.min.js │ ├── controllers │ ├── bj.proxy.js │ └── contextMenu.js │ ├── jquery-2.2.3.js │ ├── managers │ └── interceptor.js │ ├── npm.js │ ├── utils.js │ └── webfont.js └── views └── index.ejs /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | BtleJuice - Bluetooth Man in the Middle framework 2 | ================================================= 3 | 4 | version 1.1.3 5 | ------------- 6 | 7 | * Fixed a bug introduced in the previous version (bad dedup) 8 | 9 | 10 | version 1.1.2 11 | ------------- 12 | 13 | * Improved device detection and acquisition (issue #2) 14 | 15 | version 1.1.1 16 | ------------- 17 | 18 | * Version numbers fixed in both btlejuice and btlejuice-proxy 19 | 20 | version 1.1.0 21 | ------------- 22 | 23 | * Added GATT handles duplication support (true-cloning feature that fixes bad behavior on Android) 24 | * Optimized reconnection through local GATT cache 25 | * Minor bugfixes 26 | 27 | 28 | version 1.0.6 29 | ------------- 30 | 31 | * Added export feature (JSON and text) 32 | * Added support for auto-reconnection: when a remote device disconnects, the proxy will automatically reconnect 33 | * Added support of hex and hexII format in the main interface 34 | * Fixed settings dialog 35 | * Improved bluetooth core 36 | * Fixed other minor bugs 37 | 38 | 39 | version 1.0.5 (initial release) 40 | ------------------------------- 41 | 42 | * Core MITM features 43 | * Web user interface 44 | * Bluetooth GATT operations replay (read, write, notify) 45 | * GATT operations sniffing (read, write, notify) 46 | * On-the-fly data modification through user interface 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | BtleJuice Framework 2 | =================== 3 | 4 | Introduction 5 | ------------ 6 | 7 | BtleJuice is a complete framework to perform Man-in-the-Middle attacks on 8 | Bluetooth Smart devices (also known as Bluetooth Low Energy). It is composed of: 9 | 10 | * an interception core 11 | * an interception proxy 12 | * a dedicated web interface 13 | * Python and Node.js bindings 14 | 15 | How to install BtleJuice ? 16 | -------------------------- 17 | 18 | Installing BtleJuice is a child's play. First of all, make sure your system uses 19 | a recent version of *Node.js* (>=4.3.2) and *npm*. Then, make sure to install all the 20 | required dependencies: 21 | 22 | ### Ubuntu/Debian/Raspbian 23 | 24 | ``` 25 | sudo apt-get install bluetooth bluez libbluetooth-dev libudev-dev 26 | ``` 27 | 28 | ### Fedora / Other-RPM based 29 | 30 | ``` 31 | sudo yum install bluez bluez-libs bluez-libs-devel npm 32 | ``` 33 | 34 | Last, install BtleJuice using *npm*: 35 | 36 | ``` 37 | sudo npm install -g btlejuice 38 | ``` 39 | 40 | If everything went well, BtleJuice is ready to use ! 41 | 42 | 43 | How to use BtleJuice ? 44 | ---------------------- 45 | 46 | BtleJuice is composed of two main components: an interception proxy and a core. 47 | These two components are required to run on independent machines in order to 48 | operate simultaneously two bluetooth 4.0+ adapters. **BtleJuice Proxy does not work 49 | in a Docker container**. 50 | 51 | The use of a virtual machine may help to make this framework work on a single computer. 52 | 53 | From your virtual machine, install *btlejuice* and make sure your USB BT4 adapter is available from the virtual machine: 54 | 55 | ``` 56 | $ sudo hciconfig 57 | hci0: Type: BR/EDR Bus: USB 58 | BD Address: 10:02:B5:18:07:AD ACL MTU: 1021:5 SCO MTU: 96:6 59 | DOWN 60 | RX bytes:1433 acl:0 sco:0 events:171 errors:0 61 | TX bytes:30206 acl:0 sco:0 commands:170 errors:0 62 | $ sudo hciconfig hci0 up 63 | ``` 64 | 65 | Then, make sure your virtual machine has an IP address reachable from the host. 66 | 67 | Launch the proxy in your virtual machine: 68 | 69 | ``` 70 | $ sudo btlejuice-proxy 71 | ``` 72 | 73 | On your host machine, don't forget to stop the bluetooth service and ensure the HCI device remains opened/initialized: 74 | ``` 75 | $ sudo service bluetooth stop 76 | $ sudo hciconfig hci0 up 77 | ``` 78 | 79 | Finally, run the following command on your host machine: 80 | 81 | ``` 82 | $ sudo btlejuice -u -w 83 | ``` 84 | 85 | The *-w* flag tells BtleJuice to start the web interface while the *-u* option specifies the proxy's IP address. 86 | 87 | The Web User Interface is now available at http://localhost:8080. Note the web server port may be changed through command-line. 88 | 89 | Using the web interface 90 | ----------------------- 91 | 92 | The BtleJuice's web interface provides in the top-right corner a set of links to control the interception core, as shown below. 93 | 94 | ![BtleJuice main web UI](doc/images/btlejuice-main-ui.png) 95 | 96 | ### Target selection 97 | 98 | First, click the *Select target* button and a dialog will show up displaying all the available Bluetooth Low Energy devices detected by the interception core: 99 | 100 | ![BtleJuice Target Selection Popup](doc/images/btlejuice-target-select.png) 101 | 102 | Double-click on the desired target, and wait for the interface to be ready (the bluetooth button's aspect will change). Once the dummy device ready, use the associated mobile application or another device (depending on what is expected) to connect to the dummy device. If the connection succeeds, a *Connected* event would be shown on the main interface. 103 | 104 | ![BtleJuice Target Selection Popup](doc/images/btlejuice-sniffing.png) 105 | 106 | All the intercepted GATT operations are then displayed with the corresponding services and characteristics UUID, and of course the data associated with them. The data is shown by default with the HexII format (a variant of the format designed by Ange Albertini), but you may want to switch from HexII to Hex (and back) by clicking on the data itself. Both Hex and HexII format are supported by BtleJuice. 107 | 108 | ### Replay GATT operations 109 | 110 | It is possible to replay any GATT operation by right-clicking it and then selecting the *Replay* option, as shown below: 111 | 112 | ![Replay Popup](doc/images/btlejuice-hook.png) 113 | 114 | ![Replay dialog](doc/images/btlejuice-replay.png) 115 | 116 | Click the *Write* (or *Read*) button to replay the corresponding GATT operation. This operation will be logged in the main interface. 117 | 118 | ### On-the-fly data modification 119 | 120 | Last but not least, the interface may intercept locally or globally any GATT operation and allow on-the-fly data modification. You may either use the global interception by clicking the *Intercept* button in the top-right corner or use the contextual menu to enable or disable a hook on a given service and characteristic. Any time a GATT operation is intercepter, the following dialog box will show up: 121 | 122 | ![Interceptr dialog](doc/images/btlejuice-intercept.png) 123 | 124 | ### Export data to file 125 | 126 | Since version 1.0.6, the interface provides a data export feature allowing readable and JSON exports. These exports are generated based on the intercepted GATT operations, but also include information about the target device. When the *Export* button is clicked, the following dialog will show up: 127 | 128 | ![Export dialog](doc/images/btlejuice-export.png) 129 | 130 | Click the *Export* button at the bottom of the dialog box to download a JSON (or text) version of the intercepted data. 131 | 132 | ### Settings 133 | 134 | The settings dialog provides a single option at the moment allowing to automatically reconnect the proxy when the target device disconnects. This may be useful when dealing with devices that are active during a short amount of time. 135 | 136 | ![Settings dialog](doc/images/btlejuice-settings.png) 137 | 138 | ### Disconnection 139 | 140 | Clicking the top-right *Select Target* button when the proxy is active will stop it and allow target selection again. 141 | 142 | Installing the bindings 143 | ----------------------- 144 | 145 | BtleJuice's Node.js bindings may be installed as well through *npm*: 146 | 147 | ``` 148 | $ sudo npm install -g btlejuice-bindings 149 | ``` 150 | 151 | More information about how to use the Node.js bindings in the [package documentation](https://www.npmjs.com/package/btlejuice-bindings). 152 | 153 | 154 | Thanks 155 | ------ 156 | 157 | A special thank to Slawomir Jasek who pointed out many noble/bleno tricks to avoid issues with mobile applications, and shared BLE MITM strategies during DEF CON 24 =). He is also the author of [Gattacker](https://github.com/securing/gattacker). 158 | 159 | 160 | License 161 | ------- 162 | 163 | Copyright (c) 2016 Econocom Digital Security 164 | 165 | 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: 166 | 167 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 168 | 169 | 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. 170 | -------------------------------------------------------------------------------- /bin/cmd_btlejuice.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | /** 4 | * btlejuice 5 | * 6 | * This command-line tool may be used to launch the btlejuice core interception. 7 | * Available options: 8 | * -i HCI interface to use for dummy device. 9 | * -u Proxy hostname/IP address (default: localhost) 10 | * -f Follow disconnection 11 | * -p Proxy Port (default 8000) 12 | * -w Enable Web UI 13 | * -s Web UI port 14 | **/ 15 | 16 | var optional = require('optional'); 17 | var argparse = require('argparse'); 18 | var BtleJuiceCore = require('../core'); 19 | const colors = require('colors'); 20 | const util = require('util'); 21 | var btim = optional('btim'); 22 | 23 | /** 24 | * Release version 25 | **/ 26 | 27 | /** 28 | * Command-line tool 29 | **/ 30 | var parser = new argparse.ArgumentParser({ 31 | version: '1.1.11', 32 | addHelp: true, 33 | description: 'BtleJuice core & web interface' 34 | }); 35 | parser.addArgument(['-i', '--iface'], { 36 | help: 'Bluetooth interface to use for device emulation (hciX or the interface number)', 37 | }); 38 | parser.addArgument(['-u', '--proxy'], { 39 | help: 'Target BtleJuice proxy IP or hostname (default: localhost)', 40 | required: false, 41 | }); 42 | parser.addArgument(['-p', '--port'], { 43 | help: 'Target BtleJuice proxy port (default: 8000)', 44 | required: false, 45 | }); 46 | parser.addArgument(['-w', '--web'], { 47 | help: 'Enable web UI', 48 | required: false, 49 | action: 'storeTrue', 50 | default: false 51 | }); 52 | parser.addArgument(['-s', '--web-port'], { 53 | help: 'Specify Web UI port', 54 | required: false, 55 | default: 8080 56 | }); 57 | 58 | /* Add additional options if the module btim is present. */ 59 | if (btim != null) { 60 | parser.addArgument(['-m', '--mac'], { 61 | help: 'Spoof the MAC address with a new one', 62 | required: false, 63 | }); 64 | parser.addArgument(['-l', '--list'], { 65 | help: 'List bluetooth interfaces', 66 | required: false, 67 | action: 'storeTrue', 68 | default: false 69 | }); 70 | } 71 | 72 | 73 | args = parser.parseArgs(); 74 | 75 | console.log(' ___ _ _ __ _ '); 76 | console.log(' / __\\ |_| | ___ \\ \\ _ _(_) ___ ___ '); 77 | console.log(' /__\\// __| |/ _ \\ \\ \\ | | | |/ __/ _ \\'); 78 | console.log('/ \\/ \\ |_| | __/\\_/ / |_| | | (_| __/'); 79 | console.log('\\_____/\\__|_|\\___\\___/ \\__,_|_|\\___\\___|'); 80 | console.log(''); 81 | 82 | /* Build proxy URL. */ 83 | var proxyUrl = 'http://'; 84 | if (args.proxy != null) { 85 | proxyUrl += args.proxy; 86 | } else { 87 | proxyUrl += 'localhost'; 88 | } 89 | if (args.port != null) { 90 | var proxyPort = parseInt(args.port); 91 | if ((proxyPort < 0) || (proxyPort > 65535)) { 92 | console.log('[!] Bad proxy port provided'.red); 93 | process.exit(-1); 94 | } 95 | proxyUrl += ':'+parseInt(args.port); 96 | } else { 97 | proxyUrl += ':8000'; 98 | } 99 | console.log(util.format('[i] Using proxy %s', proxyUrl).bold); 100 | 101 | /* Select web UI port */ 102 | if (args.web_port != null) { 103 | var uiPort = parseInt(args.web_port); 104 | } else { 105 | var uiPort = 8080; 106 | } 107 | 108 | var iface; /* Globally defined to use it also for mac spoofing */ 109 | 110 | 111 | // Add implementation of additional options if the module btim is present. 112 | if (btim != null) { 113 | if (args.list) { 114 | function display_interface(item) { 115 | for (property in item) { 116 | console.log(util.format('%s\tType: %s Bus: %s BD Address: %s ' + 117 | 'ACL MTU: %s SCO MTU: %s\n\t%s\n\t' + 118 | 'RX: bytes: %s ACL: %s SCO: %s events: %s errors: %s\n\t' + 119 | 'TX: bytes: %s ACL: %s SCO: %s events: %s errors: %s\n', 120 | property, 121 | item[property]['type'], 122 | item[property]['bus'], 123 | item[property]['address'], 124 | item[property]['acl_mtu'], 125 | item[property]['sco_mtu'], 126 | item[property]['status'], 127 | item[property]['rx']['bytes'], 128 | item[property]['rx']['acl'], 129 | item[property]['rx']['sco'], 130 | item[property]['rx']['events'], 131 | item[property]['rx']['errors'], 132 | item[property]['tx']['bytes'], 133 | item[property]['tx']['acl'], 134 | item[property]['tx']['sco'], 135 | item[property]['tx']['events'], 136 | item[property]['tx']['errors']).bold); 137 | } 138 | } 139 | 140 | console.log(util.format('[info] Listing bluetooth interfaces...\n').green); 141 | var interfaces = btim.list(); 142 | 143 | for (var i = 0; i < interfaces.length; i++) { 144 | display_interface(interfaces[i]); 145 | } 146 | process.exit(0); 147 | } 148 | } 149 | 150 | /* Define bluetooth interface. */ 151 | if (args.iface != null) { 152 | iface = parseInt(args.iface); 153 | 154 | /* Iface not a number, consider a string. */ 155 | if (isNaN(iface)) { 156 | /* String has to be hciX */ 157 | var re = /^hci([0-9]+)$/i; 158 | var result = re.exec(args.iface); 159 | if (result != null) { 160 | /* Keep the interface number. */ 161 | var iface = result[1]; 162 | /* Bring up an interace only if the module btim is present. */ 163 | if (btim != null) { 164 | 165 | /* Bring down all the interfaces. */ 166 | var interfaces = btim.list(); 167 | for (var interface in interfaces) 168 | { 169 | for (var name in interfaces[interface]) 170 | { 171 | var re = /^hci([0-9]+)$/i; 172 | var result = re.exec(name); 173 | btim.down(parseInt(result[1])); 174 | } 175 | } 176 | 177 | /* Then bring up the selected one. */ 178 | iface = parseInt(iface); 179 | btim.up(iface); 180 | 181 | /* Spoof if necessary. */ 182 | if (args.mac != null) { 183 | var mac_regex = /^(([A-Fa-f0-9]{2}[:]){5}[A-Fa-f0-9]{2}[,]?)+$/; 184 | if (mac_regex.test(args.mac)) { 185 | if (btim.spoof_mac(iface, args.mac) != 0) { 186 | console.log(util.format('[!] The MAC address wasn\'t successfully spoofed: %s', args.mac).red); 187 | process.exit(-1); 188 | } 189 | console.log(util.format('[i] MAC address successfully spoofed: %s', args.mac).bold); 190 | } else { 191 | console.log(util.format('[!] The provided MAC address isn\t valid: %s', args.mac).red); 192 | process.exit(-1); 193 | } 194 | } 195 | } 196 | } else { 197 | console.log(util.format('[!] Unknown interface %s', args.iface).red); 198 | process.exit(-1); 199 | } 200 | } 201 | 202 | /* Set up BLENO_HCI_DEVICE_ID. */ 203 | //process.env.BLENO_HCI_DEVICE_ID = iface; 204 | } else { 205 | iface = 0; 206 | } 207 | console.log(util.format('[i] Using interface hci%d', iface).bold); 208 | 209 | /* Set advertisement interval to minimum value (20ms). */ 210 | process.env.BLENO_ADVERTISING_INTERVAL = 20; 211 | 212 | /* Create our core. */ 213 | var enableWebServer = args.web; 214 | var core = new BtleJuiceCore(proxyUrl, enableWebServer, uiPort, iface); 215 | -------------------------------------------------------------------------------- /bin/cmd_btlejuice_proxy.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | /** 4 | * btlejuice 5 | * 6 | * This command-line tool may be used to launch the btlejuice core interception. 7 | * Available options: 8 | * -i HCI interface to use for dummy device. 9 | * -p Proxy Port (default 8000) 10 | **/ 11 | 12 | var argparse = require('argparse'); 13 | var optional = require('optional'); 14 | var proxy = require('../proxy'); 15 | const colors = require('colors'); 16 | const util = require('util'); 17 | var btim = optional('btim'); 18 | 19 | /** 20 | * Command-line tool 21 | **/ 22 | var parser = new argparse.ArgumentParser({ 23 | version: '1.1.11', 24 | addHelp: true, 25 | description: 'BtleJuice proxy' 26 | }); 27 | parser.addArgument(['-i', '--iface'], { 28 | help: 'Bluetooth interface to use for device connection', 29 | }); 30 | parser.addArgument(['-p', '--port'], { 31 | help: 'BtleJuice proxy port (default: 8000)', 32 | required: false, 33 | }); 34 | 35 | /* Add additional options if the module btim is present. */ 36 | if (btim != null) { 37 | parser.addArgument(['-l', '--list'], { 38 | help: 'List bluetooth interfaces', 39 | required: false, 40 | action: 'storeTrue', 41 | default: false 42 | }); 43 | } 44 | 45 | args = parser.parseArgs(); 46 | 47 | if (btim != null) 48 | { 49 | if (args.list) { 50 | function display_interface(item) { 51 | for (property in item) { 52 | console.log(util.format('%s\tType: %s Bus: %s BD Address: %s ' + 53 | 'ACL MTU: %s SCO MTU: %s\n\t%s\n\t' + 54 | 'RX: bytes: %s ACL: %s SCO: %s events: %s errors: %s\n\t' + 55 | 'TX: bytes: %s ACL: %s SCO: %s events: %s errors: %s\n', 56 | property, 57 | item[property]['type'], 58 | item[property]['bus'], 59 | item[property]['address'], 60 | item[property]['acl_mtu'], 61 | item[property]['sco_mtu'], 62 | item[property]['status'], 63 | item[property]['rx']['bytes'], 64 | item[property]['rx']['acl'], 65 | item[property]['rx']['sco'], 66 | item[property]['rx']['events'], 67 | item[property]['rx']['errors'], 68 | item[property]['tx']['bytes'], 69 | item[property]['tx']['acl'], 70 | item[property]['tx']['sco'], 71 | item[property]['tx']['events'], 72 | item[property]['tx']['errors']).bold); 73 | } 74 | } 75 | 76 | console.log(util.format('[info] Listing bluetooth interfaces...\n').green); 77 | var interfaces = btim.list(); 78 | 79 | for (var i = 0; i < interfaces.length; i++) { 80 | display_interface(interfaces[i]); 81 | } 82 | process.exit(0); 83 | } 84 | else if (args.iface != null) { 85 | var iface = parseInt(args.iface); 86 | 87 | /* Iface not a number, consider a string. */ 88 | if (isNaN(iface)) { 89 | /* String has to be hciX */ 90 | var re = /^hci([0-9]+)$/i; 91 | var result = re.exec(args.iface); 92 | if (result != null) { 93 | /* Keep the interface number. */ 94 | var iface = parseInt(result[1]); 95 | 96 | /* Bring down all the interfaces. */ 97 | var interfaces = btim.list(); 98 | for (var interface in interfaces) 99 | { 100 | for (var name in interfaces[interface]) 101 | { 102 | var re = /^hci([0-9]+)$/i; 103 | var result = re.exec(name); 104 | btim.down(parseInt(result[1])); 105 | } 106 | } 107 | 108 | /* Bring up an interface only if the module btim is present. */ 109 | btim.up(iface); 110 | 111 | console.log(util.format('[i] Using interface hci%d', iface).bold); 112 | } else { 113 | console.log(util.format('[!] Unknown interface %s', args.iface).red); 114 | process.exit(-1); 115 | } 116 | } 117 | } 118 | } 119 | 120 | if (args.port != null) { 121 | var proxyPort = parseInt(args.port); 122 | if ((proxyPort < 0) || (proxyPort > 65535)) { 123 | console.log('[!] Bad proxy port provided'.red); 124 | process.exit(-1); 125 | } 126 | } else { 127 | proxyPort = 8000; 128 | } 129 | 130 | /* Create our core. */ 131 | (new proxy({ 132 | port: proxyPort, 133 | })).start(); 134 | -------------------------------------------------------------------------------- /core.js: -------------------------------------------------------------------------------- 1 | /** 2 | * BtleJuice Core module 3 | * 4 | * The App class takes a device profile in input and create a pure NodeJS 5 | * clone uppon which high-level behaviors may be implemented. 6 | **/ 7 | var async = require('async'); 8 | var events = require('events'); 9 | var util = require('util'); 10 | var colors = require('colors'); 11 | const winston = require('winston'); 12 | var io = require('socket.io-client'); 13 | var logging = require('./logging'); 14 | const path = require('path'); 15 | const express = require('express'); 16 | const http = require('http'); 17 | const exec = require('child_process').exec; 18 | 19 | /** 20 | * Core app. 21 | **/ 22 | 23 | var App = function(proxyUrl, enableWebServer, webServerPort, iface) { 24 | events.EventEmitter.call(this); 25 | 26 | /* Save status. */ 27 | this.status = 'disconnected'; 28 | this.target = null; 29 | this.profile = null; 30 | this.server = null; 31 | 32 | /* Web server options. */ 33 | this.webEnabled = (enableWebServer === true); 34 | if (webServerPort != null) 35 | this.webServerPort = webServerPort; 36 | else 37 | this.webServerPort = 8080; 38 | 39 | /* Save proxy URL */ 40 | this.proxyUrl = proxyUrl; 41 | this.connectProxy(); 42 | 43 | /* Logger */ 44 | this.logger = new (winston.Logger)({ 45 | transports: [ 46 | new (winston.transports.Console)({ 47 | level: 'debug', 48 | colorize: true, 49 | timestamp: true, 50 | prettyPrint: true 51 | }) 52 | ] 53 | }); 54 | winston.addColors({ 55 | info: 'green', 56 | warning: 'orange', 57 | error: 'red', 58 | debug: 'purple' 59 | }); 60 | 61 | /* Disable the bluetooth service if it is enabled. */ 62 | this.disableBluetoothService(iface); 63 | 64 | }; 65 | 66 | util.inherits(App, events.EventEmitter); 67 | 68 | App.prototype.createServer = function() { 69 | var app = express(); 70 | var server = http.Server(app); 71 | 72 | /* Check server */ 73 | if (this.server != null) { 74 | this.server.close(); 75 | } 76 | this.server = require('socket.io')(server); 77 | 78 | this.clients = []; 79 | 80 | /* Basic webserver. */ 81 | if (this.webEnabled) { 82 | app.set('view engine','ejs'); 83 | app.set('views', __dirname + '/views'); 84 | app.use(express.static(__dirname + '/resources')) 85 | .get('/', function(req, res){ 86 | res.render('index.ejs'); 87 | }); 88 | } 89 | server.listen(this.webServerPort); 90 | 91 | /* Handles client connection. */ 92 | this.server.sockets.on('connection', function(socket){ 93 | this.onClientConnection(socket); 94 | }.bind(this)); 95 | 96 | }; 97 | 98 | 99 | App.prototype.onClientConnection = function(client) { 100 | /** 101 | * Forward scan_devices to proxy. 102 | **/ 103 | 104 | /* Save client. */ 105 | this.clients.push(client); 106 | 107 | /* Notify client of our current config. */ 108 | this.setProxy(this.proxyUrl); 109 | this.setStatus(this.status); 110 | this.setTarget(this.target); 111 | this.setProfile(this.profile); 112 | 113 | /* Disconnection handler. */ 114 | client.on('disconnect', (function(t, c){ 115 | return function(){ 116 | c.removeAllListeners(); 117 | var index = t.clients.indexOf(c); 118 | if (index > -1) 119 | t.clients.splice(index); 120 | }; 121 | })(this, client)); 122 | 123 | client.on('scan_devices', function(){ 124 | /* Install device discovery handler. */ 125 | this.proxy.removeAllListeners('discover'); 126 | this.proxy.on('discover', function(p, name, rssi){ 127 | client.emit('peripheral', p, name, rssi); 128 | }.bind(this)); 129 | 130 | /* Ask for devices discovery. */ 131 | this.proxy.emit('scan_devices'); 132 | }.bind(this)); 133 | 134 | 135 | /** 136 | * Forward status to proxy. 137 | **/ 138 | 139 | client.on('status', function(){ 140 | this.proxy.emit('status'); 141 | }.bind(this)); 142 | 143 | 144 | /** 145 | * Handles target connection. 146 | **/ 147 | 148 | client.on('target', function(target, keepHandles){ 149 | this.setStatus('connecting'); 150 | this.setTarget(target); 151 | this.send('app.status', 'connecting'); 152 | 153 | /* Set the keepHandles option internally. */ 154 | this.keepHandles = keepHandles; 155 | 156 | /* Forward to target, create mock on callback. */ 157 | this.proxy.emit('target', target); 158 | }.bind(this)); 159 | 160 | /** 161 | * Handles stop. 162 | **/ 163 | 164 | client.on('stop', function(){ 165 | this.setStatus('disconnected'); 166 | 167 | /* Forward to target, create mock on callback. */ 168 | if (this.fake != null) { 169 | this.fake.stop(); 170 | this.fake = null; 171 | } 172 | this.proxy.emit('stop'); 173 | client.emit('app.status', 'disconnected'); 174 | }.bind(this)); 175 | 176 | /** 177 | * Forward profile data to app. 178 | **/ 179 | this.proxy.removeAllListeners('profile'); 180 | this.proxy.on('profile', function(p){ 181 | this.profile = p; 182 | client.emit('profile', p); 183 | 184 | this.createFakeDevice(p); 185 | }.bind(this)); 186 | 187 | this.proxy.on('stopped', function(){ 188 | this.setStatus('disconnected'); 189 | 190 | /* Forward to target, create mock on callback. */ 191 | if (this.fake != null) { 192 | this.fake.stop(); 193 | this.fake = null; 194 | } 195 | 196 | client.emit('app.status', 'disconnected'); 197 | }.bind(this)); 198 | 199 | /** 200 | * Forward ready message to app. 201 | **/ 202 | 203 | this.proxy.removeAllListeners('ready'); 204 | this.proxy.on('ready', function(){ 205 | this.status = 'connected'; 206 | 207 | this.logger.info('proxy set up and ready to use =)'); 208 | client.emit('ready'); 209 | client.emit('app.status', 'connected'); 210 | client.emit('app.target', this.target); 211 | }.bind(this)); 212 | 213 | this.proxy.on('ble_data', function(service, characteristic, data){ 214 | this.logger.debug('Data notification for service %s and charac. %s (ble_data)', service, characteristic); 215 | client.emit('data', service, characteristic, data); 216 | }.bind(this)); 217 | 218 | /** 219 | * Forward write message to proxy. 220 | **/ 221 | client.on('ble_write', function(service, characteristic, data, offset, withoutResponse){ 222 | this.proxy.once('ble_write_resp', function(s,c, error){ 223 | client.emit('ble_write_resp', service, characteristic, error); 224 | }.bind(this)); 225 | this.proxy.emit('ble_write', service, characteristic, data, withoutResponse) 226 | }.bind(this)); 227 | 228 | /** 229 | * Forward read message to proxy 230 | **/ 231 | client.on('ble_read', function(service, characteristic){ 232 | this.proxy.once('ble_read_resp', function(s, c, data){ 233 | client.emit('ble_read_resp', service, characteristic, data); 234 | }.bind(this)); 235 | 236 | this.proxy.emit('ble_read', service, characteristic, 0); 237 | }.bind(this)); 238 | 239 | /** 240 | * Forward notify message to proxy 241 | **/ 242 | client.on('ble_notify', function(service, characteristic, enabled){ 243 | this.proxy.once('ble_notify_resp', function(){ 244 | client.emit('ble_notify_resp', service, characteristic); 245 | }.bind(this)); 246 | this.proxy.emit('ble_notify', service, characteristic, enabled); 247 | }.bind(this)); 248 | 249 | /* 250 | client.on('data', function(service, characteristic, data){ 251 | this.logger.debug('Forward data notification for service %s and charac. %s (data)', s, c); 252 | this.fake.emit('data', service, characteristic, data); 253 | });*/ 254 | 255 | client.on('proxy_notify_resp', function(s,c, error) { 256 | this.fake.emit('notify_resp', s, c, error); 257 | }.bind(this)); 258 | 259 | client.on('proxy_data', function(s,c,d){ 260 | this.logger.debug('Forward data notification for service %s and charac. %s (proxy_data)', s, c); 261 | this.fake.emit('data', s,c,d); 262 | }.bind(this)); 263 | 264 | client.on('proxy_read_resp', function(s, c, data) { 265 | this.fake.emit('read_resp', s, c, data); 266 | }.bind(this)); 267 | 268 | client.on('proxy_write_resp', function(s, c, error) { 269 | this.fake.emit('write_resp', s, c, error); 270 | }.bind(this)); 271 | 272 | }; 273 | 274 | /** 275 | * Send message to clients. 276 | **/ 277 | 278 | App.prototype.send = function(){ 279 | for (var client in this.clients) { 280 | this.clients[client].emit.apply( 281 | this.clients[client], 282 | arguments 283 | ); 284 | } 285 | }; 286 | 287 | /** 288 | * connectProxy() 289 | * 290 | * Connect the mock to a given proxy in order to relay services and 291 | * characteristics. 292 | */ 293 | 294 | App.prototype.connectProxy = function(){ 295 | /* Connect to the proxy. */ 296 | this.proxy = io(this.proxyUrl); 297 | this.proxy.on('connect', function(){ 298 | this.logger.info('successfully connected to proxy'); 299 | 300 | /* Create local server. */ 301 | this.createServer(); 302 | 303 | }.bind(this)); 304 | 305 | /* Error message if connection fails. */ 306 | this.proxy.on('connect_error', function(){ 307 | this.logger.error('cannot connect to proxy.'); 308 | process.exit(-1); 309 | }.bind(this)); 310 | 311 | /* Error message if remote device disconnects. */ 312 | this.proxy.on('device.disconnect', function(target){ 313 | this.logger.warn('remote device has disconnected.'); 314 | this.onRemoteDeviceDisconnected(target); 315 | }.bind(this)); 316 | }; 317 | 318 | App.prototype.createFakeDevice = function(profile) { 319 | const fake = require('./fake'); 320 | this.fake = new fake(profile, this.keepHandles); 321 | 322 | /* Listen for events on this fake device, and notify the web interface. */ 323 | this.fake.on('write', function(service, characteristic, data, offset, withoutResponse){ 324 | /* Notify our client we got a write request. */ 325 | this.send('proxy_write', service, characteristic, data, offset, withoutResponse); 326 | }.bind(this)); 327 | 328 | /* Listen for events on this fake device, and notify the web interface. */ 329 | this.fake.on('read', function(service, characteristic, offset){ 330 | /* Notify our client we got a write request. */ 331 | this.send('proxy_read', service, characteristic, offset); 332 | }.bind(this)); 333 | 334 | this.fake.on('notify', function(service, characteristic, enable){ 335 | this.send('proxy_notify', service, characteristic, enable); 336 | }.bind(this)); 337 | 338 | this.fake.on('connect', function(client) { 339 | this.send('app.connect', client); 340 | }.bind(this)); 341 | 342 | this.fake.on('disconnect', function(client) { 343 | this.send('app.disconnect', client); 344 | }.bind(this)); 345 | 346 | }; 347 | 348 | App.prototype.setStatus = function(status) { 349 | /* Save status. */ 350 | this.status = status; 351 | 352 | /* Notify status change to clients. */ 353 | this.send('app.status', status); 354 | }; 355 | 356 | App.prototype.setTarget = function(target) { 357 | this.send('app.target', target); 358 | this.target = target; 359 | }; 360 | 361 | App.prototype.setProxy = function(proxy) { 362 | this.send('app.proxy', proxy); 363 | }; 364 | 365 | App.prototype.setProfile = function(profile) { 366 | this.send('target.profile', profile); 367 | }; 368 | 369 | App.prototype.disableBluetoothService = function(iface) { 370 | /* First, check if the service exists and is running. */ 371 | exec('service bluetooth status', function(error, stdout, stderr){ 372 | if (stdout.indexOf(': active') >= 0) { 373 | /* Service is active, shut it down. */ 374 | this.logger.info('Stopping BT service ...'); 375 | exec('service bluetooth stop', function(error, stdout, stderr){ 376 | setTimeout(function(){ 377 | /* Re-enable our HCI interface. */ 378 | if (iface == null) 379 | iface = 0; 380 | this.logger.info('Making sure interface hci%d is up ...', iface); 381 | exec(util.format('hciconfig hci%d up', iface)); 382 | }.bind(this), 2000); 383 | }.bind(this)); 384 | } 385 | }.bind(this)); 386 | }; 387 | 388 | App.prototype.onRemoteDeviceDisconnected = function(target) { 389 | this.setStatus('disconnected'); 390 | 391 | /* Forward to target, create mock on callback. */ 392 | if (this.fake != null) { 393 | this.fake.stop(); 394 | this.fake = null; 395 | } 396 | this.proxy.emit('stop'); 397 | this.send('app.status', 'disconnected'); 398 | 399 | /* Notify client. */ 400 | this.send('device.disconnect', target); 401 | }; 402 | 403 | if (!module.parent) { 404 | var b = new App('http://127.0.0.1:8000', true, 8080); 405 | } else { 406 | module.exports = App; 407 | } 408 | -------------------------------------------------------------------------------- /doc/images/btlejuice-export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/doc/images/btlejuice-export.png -------------------------------------------------------------------------------- /doc/images/btlejuice-hook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/doc/images/btlejuice-hook.png -------------------------------------------------------------------------------- /doc/images/btlejuice-intercept.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/doc/images/btlejuice-intercept.png -------------------------------------------------------------------------------- /doc/images/btlejuice-main-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/doc/images/btlejuice-main-ui.png -------------------------------------------------------------------------------- /doc/images/btlejuice-replay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/doc/images/btlejuice-replay.png -------------------------------------------------------------------------------- /doc/images/btlejuice-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/doc/images/btlejuice-settings.png -------------------------------------------------------------------------------- /doc/images/btlejuice-sniffing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/doc/images/btlejuice-sniffing.png -------------------------------------------------------------------------------- /doc/images/btlejuice-target-select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/doc/images/btlejuice-target-select.png -------------------------------------------------------------------------------- /fake.js: -------------------------------------------------------------------------------- 1 | /** 2 | * BtleJuice fake device 3 | * 4 | * This module provides the FakeDevice class that simulates an existing 5 | * device based on its profile (as detected by the proxy), and allows 6 | * interaction with real-world applications and other devices. 7 | **/ 8 | 9 | var bleno = require('bleno'); 10 | var async = require('async'); 11 | var events = require('events'); 12 | var util = require('util'); 13 | var colors = require('colors'); 14 | var winston = require('winston'); 15 | 16 | var FakeDevice = function(profile, keepHandles) { 17 | events.EventEmitter.call(this); 18 | 19 | /* Save logger if any. */ 20 | this.logger = new (winston.Logger)({ 21 | transports: [ 22 | new (winston.transports.Console)({ 23 | level: 'debug', 24 | colorize: true, 25 | timestamp: true, 26 | prettyPrint: true 27 | }) 28 | ] 29 | }); 30 | winston.addColors({ 31 | trace: 'magenta', 32 | input: 'grey', 33 | verbose: 'cyan', 34 | prompt: 'grey', 35 | debug: 'blue', 36 | info: 'green', 37 | data: 'grey', 38 | help: 'cyan', 39 | warn: 'yellow', 40 | error: 'red' 41 | }); 42 | 43 | /* Update callbacks. */ 44 | this.subsCallbacks = {}; 45 | 46 | /* Propagate bleno constants. */ 47 | this.RESULT_SUCCESS = bleno.Characteristic.RESULT_SUCCESS; 48 | this.RESULT_INVALID_OFFSET = bleno.Characteristic.RESULT_INVALID_OFFSET; 49 | this.RESULT_INVALID_ATTRIBUTE_LENGTH = bleno.Characteristic.RESULT_INVALID_ATTRIBUTE_LENGTH; 50 | this.RESULT_UNLIKELY_ERROR = bleno.Characteristic.RESULT_UNLIKELY_ERROR; 51 | 52 | /* Remove all bleno listeners. */ 53 | bleno.removeAllListeners('advertisingStart'); 54 | bleno.removeAllListeners('accept'); 55 | bleno.removeAllListeners('disconnect'); 56 | bleno.removeAllListeners('stateChange'); 57 | 58 | /* Keep handles option. */ 59 | if ((keepHandles != null) && (keepHandles != false)) 60 | this.keepHandles = true; 61 | 62 | /* Set services as described in config file. */ 63 | this.services = []; 64 | for (var service in profile['services']) { 65 | /* Get service information. */ 66 | var _service = profile['services'][service]; 67 | 68 | /* Create the service structure. */ 69 | var service_details = { 70 | uuid: _service['uuid'], 71 | characteristics: [], 72 | }; 73 | 74 | /* Add characteristics. */ 75 | for (var charac in _service['characteristics']) { 76 | var service_char_item = {}; 77 | service_char_item['uuid'] = _service['characteristics'][charac]['uuid']; 78 | service_char_item['properties'] = _service['characteristics'][charac]['properties']; 79 | service_char_item['descriptors'] = []; 80 | for (var desc in _service['characteristics'][charac]['descriptors']) { 81 | service_char_item['descriptors'].push( 82 | new bleno.Descriptor({ 83 | uuid: _service['characteristics'][charac]['descriptors'][desc].uuid 84 | }) 85 | ); 86 | } 87 | 88 | /* Install characteristic read callback if required. */ 89 | if (service_char_item['properties'].indexOf('read') > -1) { 90 | service_char_item['onReadRequest'] = (function(_this, service, characteristic){ 91 | return function(offset, callback) { 92 | _this.onRead(service, characteristic, offset, callback); 93 | } 94 | })(this, _service['uuid'], _service['characteristics'][charac]['uuid']); 95 | } 96 | 97 | /* Install characteristic write callback if required. */ 98 | if ((service_char_item['properties'].indexOf('write') > -1)) { 99 | service_char_item['onWriteRequest'] = (function(_this, service, characteristic){ 100 | return function(data, offset, withoutResponse, callback){ 101 | _this.onWrite(service, characteristic, data, offset, withoutResponse, callback); 102 | } 103 | })(this, _service['uuid'], _service['characteristics'][charac]['uuid']); 104 | } 105 | 106 | /* Install characteristic write callback if required. */ 107 | if ((service_char_item['properties'].indexOf('writeWithoutResponse') > -1)) { 108 | service_char_item['onWriteRequest'] = (function(_this, service, characteristic){ 109 | return function(data, offset, withoutResponse, callback){ 110 | _this.onWrite(service, characteristic, data, offset, withoutResponse, callback); 111 | } 112 | })(this, _service['uuid'], _service['characteristics'][charac]['uuid']); 113 | } 114 | 115 | 116 | /* Install characteristic notify callback if required. */ 117 | if ((service_char_item['properties'].indexOf('notify') > -1) || (service_char_item['properties'].indexOf('indicate') > -1)) { 118 | service_char_item['onSubscribe'] = (function(_this, service, characteristic){ 119 | return function(maxValueSize, updateValueCallback){ 120 | _this.onSubscribe(service, characteristic, maxValueSize, updateValueCallback); 121 | } 122 | })(this, _service['uuid'], _service['characteristics'][charac]['uuid']); 123 | service_char_item['onUnsubscribe'] = (function(_this, service, characteristic){ 124 | return function(){ 125 | _this.onUnsubscribe(service, characteristic); 126 | } 127 | })(this, _service['uuid'], _service['characteristics'][charac]['uuid']); 128 | service_char_item['onNotify'] = (function(_this, service, characteristic){ 129 | return function(maxValueSize, updateValueCallback){ 130 | _this.onNotify(service, characteristic); 131 | } 132 | })(this, _service['characteristics'][charac]['uuid']); 133 | } 134 | 135 | /* Register the service in bleno. */ 136 | service_details['characteristics'].push( 137 | new bleno.Characteristic(service_char_item) 138 | ); 139 | } 140 | 141 | this.services.push(new bleno.PrimaryService(service_details)); 142 | } 143 | 144 | /* Advertise our mocked device. */ 145 | if (profile['scan_data'] != null) { 146 | var scan_data = new Buffer(profile['scan_data'], 'hex'); 147 | } else { 148 | var scan_data = null; 149 | } 150 | 151 | bleno.on('advertisingStart', (function(_this){ 152 | return function(error){ 153 | if (!error) { 154 | //console.log('[setup] services registered'.yellow); 155 | _this.logger.info('BTLE services registered'); 156 | 157 | /* Register services. */ 158 | bleno.setServices(_this.services); 159 | 160 | /* Fix handles. */ 161 | if (_this.keepHandles) { 162 | _this.logger.info('Fixing Bleno handles ...'); 163 | _this.fixBlenoHandles(profile, _this.services); 164 | } 165 | 166 | } else { 167 | //console.log('[setup] error while registering services !'.red); 168 | _this.logger.error('cannot register services !'); 169 | } 170 | }; 171 | })(this)); 172 | 173 | // Notify the console that we've accepted a connection 174 | bleno.on('accept', function(clientAddress) { 175 | //console.log(("[ mock] accepted connection from address: " + clientAddress).green); 176 | this.logger.info('dummy: accepted connection from address: %s', clientAddress); 177 | this.emit('connect', clientAddress); 178 | }.bind(this)); 179 | 180 | // Notify the console that we have disconnected from a client 181 | bleno.on('disconnect', function(clientAddress) { 182 | this.logger.info('dummy: disconnected from address: %s', clientAddress); 183 | 184 | /* Remove existing notification related callbacks. */ 185 | this.subsCallbacks = {}; 186 | 187 | /* Notify disconnection. */ 188 | this.emit('disconnect', clientAddress); 189 | }.bind(this)); 190 | 191 | 192 | /* Monitor state change. */ 193 | bleno.on('stateChange', (function(_this, adv_data, scan_data){ 194 | return function(state){ 195 | if (state === 'poweredOn') { 196 | //console.log('Start advertising ...'.bold); 197 | _this.logger.debug('start advertising'); 198 | bleno.startAdvertisingWithEIRData(adv_data, scan_data); 199 | } else { 200 | bleno.stopAdvertising(); 201 | } 202 | }; 203 | })(this, new Buffer(profile['ad_records'], 'hex'), scan_data)); 204 | 205 | /* If already poweredOn, then start advertising. */ 206 | if (bleno.state === 'poweredOn') { 207 | //console.log('Start advertising ...'.bold); 208 | this.logger.debug('start advertising'); 209 | bleno.startAdvertisingWithEIRData(new Buffer(profile['ad_records'], 'hex'), scan_data); 210 | } else { 211 | bleno.stopAdvertising(); 212 | } 213 | 214 | this.on('data', function(service, characteristic, data){ 215 | var uvCallback = this.getCallback(service, characteristic); 216 | if (uvCallback != null) { 217 | this.logger.info(('!! ['+service+':'+characteristic+']>> '+new Buffer(data).toString('hex'))); 218 | uvCallback(data); 219 | } else { 220 | this.logger.info(('/!\\ Callback not found !').red); 221 | } 222 | 223 | }.bind(this)); 224 | 225 | }; 226 | 227 | util.inherits(FakeDevice, events.EventEmitter); 228 | 229 | /** 230 | * fixBlenoHandles() 231 | * 232 | * Fix bleno handles in order to avoid Gatt Cache issues. 233 | */ 234 | FakeDevice.prototype.fixBlenoHandles = function(profile, services) { 235 | /* Target handles array. */ 236 | var patchedHandles = []; 237 | 238 | /* Find services' start and end handles. */ 239 | for (var i=0; i < profile.services.length; i++) { 240 | var service = profile.services[i]; 241 | var p_service = services[i]; 242 | 243 | patchedHandles[service.startHandle] = { 244 | type: 'service', 245 | uuid: service.uuid, 246 | attribute: services[i], 247 | startHandle: service.startHandle, 248 | endHandle: service.endHandle 249 | }; 250 | 251 | for (var j=0; j", 42 | "license": "MIT" 43 | } 44 | -------------------------------------------------------------------------------- /proxy.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * BtleJuice Proxy 4 | * 5 | * This module provides the Proxy class that may be used to create a link 6 | * between a dummy device and the real device. 7 | * 8 | * By doing so, characteristics operations (read, write, notify) are forwarded 9 | * to the real device allowing Man-in-the-Middle scenarii (including sniffing 10 | * and logging). 11 | * 12 | * Supported messages: 13 | * 14 | * - connect: asks the proxy to connect to a specific device and relay to it. 15 | * - scan_devices: asks the proxy to scan for reachable devices 16 | * - stop: asks the proxy to stop (scanning or relaying operations) 17 | * - write: write data to a characteristic 18 | * - read: read data from a characteristic 19 | * - notify: register for notification for a given characteristic 20 | * 21 | * Produced messages: 22 | * - hello: sent on client connection to notify it is ready to operate 23 | * - ready: sent when the proxy is connected to the target device, 24 | * ready to relay. 25 | * - discover: sent to announce the discovery of a specific device. 26 | * - read: data read 27 | * - write: data write operation result 28 | * - notify: notify operation result 29 | **/ 30 | 31 | var async = require('async'); 32 | var events = require('events'); 33 | var noble = require('noble'); 34 | var util = require('util'); 35 | var colors = require('colors'); 36 | var server = require('socket.io'); 37 | 38 | /* Missing constants. */ 39 | var ATT_OP_WRITE_RESP = 0x13; 40 | var ATT_OP_PREPARE_WRITE_REQ = 0x16; 41 | var ATT_OP_EXECUTE_WRITE_RESP = 0x19; 42 | 43 | 44 | var Proxy = function(options){ 45 | 46 | /* Profiling related properties. */ 47 | this.devices = {}; 48 | 49 | /* BLE target. */ 50 | this.device = null; 51 | this.target = null; 52 | this.state = 'disconnected'; 53 | this.config = null; 54 | this.pendingActions = []; 55 | 56 | /* Websocket server options. */ 57 | if (options && (options.port != undefined)) { 58 | this.port = options.port; 59 | } else { 60 | /* Default port. */ 61 | this.port = 8000; 62 | } 63 | 64 | /* Create server. */ 65 | this.server = new server(); 66 | this.client = null; 67 | 68 | /* LE Adv report */ 69 | this.le_adv_handler = null; 70 | this.watchdog = null; 71 | 72 | /* GATT cache. */ 73 | this.gattCache = {}; 74 | }; 75 | 76 | /** 77 | * start 78 | * 79 | * Start the proxy. 80 | **/ 81 | 82 | Proxy.prototype.start = function(){ 83 | /* Start server. */ 84 | console.log(('[info] Server listening on port '+this.port).bold); 85 | 86 | /* Set connection handler. */ 87 | this.server.on('connection', function(client){ 88 | console.log(('[info] Client connected').green); 89 | this.client = client; 90 | 91 | /* Set config handler. */ 92 | client.on('target', function(config){ 93 | this.configure(config); 94 | }.bind(this)); 95 | 96 | /* Set discovery handler. */ 97 | client.on('scan_devices', function(){ 98 | this.scanDevices(); 99 | }.bind(this)); 100 | 101 | /* Set the stop handler. */ 102 | client.on('stop', function(){ 103 | this.stop(); 104 | }.bind(this)); 105 | 106 | /* Set the status handler. */ 107 | client.on('status', function(){ 108 | this.notifyStatus(); 109 | }.bind(this)); 110 | 111 | client.on('disconnect', function(){ 112 | this.onClientDisconnect(); 113 | }.bind(this)); 114 | 115 | /* Notify client. */ 116 | this.send('hello'); 117 | }.bind(this)); 118 | 119 | /* Listen on this.port. */ 120 | this.server.listen(this.port); 121 | } 122 | 123 | Proxy.prototype.onClientDisconnect = function(){ 124 | if (this.client != null) { 125 | console.log('[warning] client disconnected'); 126 | this.client = null; 127 | } 128 | }; 129 | 130 | Proxy.prototype.send = function() { 131 | /* Forward to client if any. */ 132 | if (this.client != null) { 133 | this.client.emit.apply(this.client, arguments); 134 | } else { 135 | console.log('[error] client disconnected.'.red); 136 | } 137 | }; 138 | 139 | /** 140 | * configure 141 | * 142 | * Provides information about the target to connect to and initiates 143 | * the BLE connection to this target. 144 | **/ 145 | 146 | Proxy.prototype.configure = function(target){ 147 | console.log('Configuring proxy ...'.bold); 148 | this.target = target.toLowerCase(); 149 | 150 | /* If already connected to a target, drop the connection. */ 151 | if (this.device != null) { 152 | /* Remove noble listeners. */ 153 | noble.removeAllListeners('discover'); 154 | 155 | /* Disconnect from device. */ 156 | this.device.disconnect(function(){ 157 | /* Reset services and characteristics. */ 158 | this.nservices = 0; 159 | this.ncharacteristics = 0; 160 | this.services = null; 161 | 162 | /* Start target acquisition. */ 163 | this.state = 'acquisition'; 164 | this.acquireTarget(); 165 | }.bind(this)); 166 | } else { 167 | /* Start target acquisition. */ 168 | this.state = 'acquisition'; 169 | this.acquireTarget(null); 170 | } 171 | } 172 | 173 | /** 174 | * acquireTarget 175 | * 176 | * Scan for the specified target and launch connection when found. 177 | **/ 178 | 179 | Proxy.prototype.acquireTarget = function(config) { 180 | console.log(('[status] Acquiring target ' + this.target).bold); 181 | 182 | /* If device is already known, use the cache. */ 183 | if (this.target in this.devices && this.devices[this.target].cache != null) { 184 | var peripheral = this.devices[this.target].cache.peripheral; 185 | this.connectDevice(peripheral); 186 | } else { 187 | /* Track BLE advertisement reports. */ 188 | if (this.le_adv_handler != null) 189 | noble._bindings._gap._hci.removeListener( 190 | 'leAdvertisingReport', 191 | this.le_adv_handler 192 | ) 193 | this.le_adv_handler = function(status, type, address, addressType, report, rssi){ 194 | this.discoverDeviceAdv(address, report, rssi); 195 | }.bind(this); 196 | noble._bindings._gap._hci.on( 197 | 'leAdvertisingReport', 198 | this.le_adv_handler 199 | ); 200 | 201 | /* Track BLE advertisement reports. */ 202 | noble.on('discover', function(peripheral){ 203 | if (peripheral.address.toLowerCase() === this.target.toLowerCase()) { 204 | noble.stopScanning(); 205 | this.connectDevice(peripheral, config); 206 | } 207 | }.bind(this) 208 | ); 209 | 210 | /* Start scanning when ble device is ready. */ 211 | noble.startScanning(null, true); 212 | } 213 | }; 214 | 215 | /** 216 | * discoverDeviceAdv() 217 | * 218 | * Keep track of discovered raw devices' advertisement records. 219 | **/ 220 | 221 | Proxy.prototype.discoverDeviceAdv = function(bdaddr, report, rssi)  { 222 | if (!(bdaddr in this.devices) && bdaddr != null) { 223 | /* Save advertisement data. */ 224 | this.devices[bdaddr] = { 225 | services: {}, 226 | adv_records: report, 227 | scan_data: null, 228 | connected: false, 229 | cache: null 230 | }; 231 | } else if (bdaddr in this.devices) { 232 | /* Save scan response. */ 233 | this.devices[bdaddr].scan_data = report; 234 | } 235 | }; 236 | 237 | Proxy.prototype.isAllDiscovered = function() { 238 | /* First, check if all services have been discovered. */ 239 | for (var serviceUuid in this.discovered) { 240 | if (this.discovered[serviceUuid].done === false) 241 | return false; 242 | } 243 | 244 | /* Then check if all characteristics have been discovered. */ 245 | for (var serviceUuid in this.discovered) { 246 | for (var characUuid in this.discovered[serviceUuid].characteristics) { 247 | //console.log(serviceUuid+':'+characUuid); 248 | if (this.discovered[serviceUuid].characteristics[characUuid] === false) 249 | return false; 250 | } 251 | } 252 | 253 | /* All discovered, fill GATT cache. */ 254 | var bdaddr = this.target.replace(/:/g,'').toLowerCase(); 255 | var handle = noble._bindings._handles[bdaddr]; 256 | var gatt = noble._bindings._gatts[handle]; 257 | 258 | /* Patching longWrite(). */ 259 | gatt.longWrite = function(serviceUuid, characteristicUuid, data, withoutResponse) { 260 | var characteristic = this._characteristics[serviceUuid][characteristicUuid]; 261 | var limit = this._mtu - 5; 262 | 263 | var prepareWriteCallback = function(data_chunk) { 264 | return function(resp) { 265 | var opcode = resp[0]; 266 | 267 | if (opcode != ATT_OP_PREPARE_WRITE_RESP) { 268 | debug(this._address + ': unexpected reply opcode %d (expecting ATT_OP_PREPARE_WRITE_RESP)', opcode); 269 | } else { 270 | var expected_length = data_chunk.length + 5; 271 | 272 | if (resp.length !== expected_length) { 273 | /* the response should contain the data packet echoed back to the caller */ 274 | debug(this._address + ': unexpected prepareWriteResponse length %d (expecting %d)', resp.length, expected_length); 275 | } 276 | } 277 | }.bind(this); 278 | }.bind(this); 279 | 280 | /* split into prepare-write chunks and queue them */ 281 | var offset = 0; 282 | 283 | while (offset < data.length) { 284 | var end = offset+limit; 285 | var chunk = data.slice(offset, end); 286 | this._queueCommand(this.prepareWriteRequest(characteristic.valueHandle, offset, chunk), 287 | prepareWriteCallback(chunk)); 288 | offset = end; 289 | } 290 | 291 | /* queue the execute command with a callback to emit the write signal when done */ 292 | this._queueCommand(this.executeWriteRequest(characteristic.valueHandle), function(resp) { 293 | var opcode = resp[0]; 294 | 295 | if (opcode === ATT_OP_EXECUTE_WRITE_RESP && !withoutResponse) { 296 | this.emit('write', this._address, serviceUuid, characteristicUuid); 297 | } 298 | }.bind(this)); 299 | }; 300 | 301 | /* Patching write() to support longWrite mode. */ 302 | gatt.write = function(serviceUuid, characteristicUuid, data, withoutResponse) { 303 | var characteristic = this._characteristics[serviceUuid][characteristicUuid]; 304 | 305 | if (withoutResponse) { 306 | this._queueCommand(this.writeRequest(characteristic.valueHandle, data, true), null, function() { 307 | this.emit('write', this._address, serviceUuid, characteristicUuid); 308 | }.bind(this)); 309 | } else if (data.length + 3 > this._mtu) { 310 | return this.longWrite(serviceUuid, characteristicUuid, data, withoutResponse); 311 | } else { 312 | this._queueCommand(this.writeRequest(characteristic.valueHandle, data, false), function(data) { 313 | var opcode = data[0]; 314 | if (opcode === ATT_OP_WRITE_RESP) { 315 | this.emit('write', this._address, serviceUuid, characteristicUuid); 316 | } 317 | }.bind(this)); 318 | } 319 | }; 320 | 321 | this.devices[this.target].cache = { 322 | peripheral: this.device, /* Noble peripheral object. */ 323 | services: gatt._services, /* GATT services. */ 324 | characteristics: gatt._characteristics, /* GATT characteristics. */ 325 | descriptors: gatt._descriptors, /* GATT descriptors. */ 326 | handles: gatt._handles, /* GATT handles -- fixed by BtleJuice if option is set */ 327 | bjServices: this.services, /* BtleJuice services structure. */ 328 | }; 329 | 330 | return true; 331 | } 332 | 333 | 334 | /** 335 | * connectDevice 336 | * 337 | * Connect to the target device and start services discovery. 338 | **/ 339 | 340 | Proxy.prototype.connectDevice = function(peripheral) { 341 | this.device = peripheral; 342 | this.discovered = {}; 343 | this.services = {}; 344 | 345 | /* Remove any connect callback (required by Noble) */ 346 | this.device.removeAllListeners('connect'); 347 | 348 | this.device.connect(function(error) { 349 | 350 | /* If GATT cache already filled, then mark device as connected. */ 351 | var gattCache = this.devices[this.target].cache; 352 | if (this.devices[this.target].cache != null) { 353 | console.log('Target in cache, restoring ...'); 354 | this.state = 'connected'; 355 | 356 | /* Restore Noble internal structures on reconnection. */ 357 | var bdaddr = this.target.replace(/:/g,'').toLowerCase(); 358 | var handle = noble._bindings._handles[bdaddr]; 359 | var gatt = noble._bindings._gatts[handle]; 360 | 361 | gatt._services = gattCache.services; 362 | gatt._characteristics = gattCache.characteristics; 363 | gatt._descriptors = gattCache.descriptors; 364 | this.device = gattCache.peripheral; 365 | this.services = gattCache.bjServices; 366 | 367 | /* Defuse watchdog if any. */ 368 | if (this.watchdog != null) { 369 | clearTimeout(this.watchdog); 370 | this.watchdog = null; 371 | } 372 | 373 | this.setGattHandlers(); 374 | this.state = 'forwarding'; 375 | this.send('profile', this.formatProfile()); 376 | this.send('ready', true); 377 | console.log('[status] Proxy configured and ready to relay !'.green); 378 | 379 | } else { 380 | /* Setup the disconnect handler. */ 381 | peripheral.removeAllListeners('disconnect'); 382 | peripheral.on('disconnect', function(){ 383 | this.onDeviceDisconnected(); 384 | }.bind(this)); 385 | 386 | if (error == undefined) { 387 | /* Save device profile. */ 388 | this.currentDevice = peripheral; 389 | this.devices[this.currentDevice.address].connected = true; 390 | this.devices[this.currentDevice.address].name = peripheral.advertisement.localName; 391 | var device = this.devices[this.currentDevice.address]; 392 | var deviceUuid = this.currentDevice.address.split(':').join('').toLowerCase(); 393 | 394 | /* Discover services ... */ 395 | this.send('discover_services'); 396 | 397 | this.state = 'connected'; 398 | console.log(('[info] Proxy successfully connected to the real device').green); 399 | console.log(('[info] Discovering services and characteristics ...').bold); 400 | 401 | /* Characteristics discovery watchdog (20 seconds). */ 402 | if (this.watchdog == null) { 403 | this.watchdog = setTimeout(function(){ 404 | console.log('[error] discovery timed out, stopping proxy.'); 405 | this.watchdog = null; 406 | this.stop(); 407 | }.bind(this), 60000); 408 | } 409 | 410 | /* Connection OK, now discover services. */ 411 | peripheral.discoverServices(null, function(error, services) { 412 | //console.log('services discovered'); 413 | //console.log(services); 414 | if (error == null) { 415 | for (var service in services) { 416 | 417 | this.services[services[service].uuid] = {}; 418 | this.discovered[services[service].uuid] = { 419 | done: false, 420 | characteristics: {} 421 | }; 422 | 423 | var device = this.devices[this.currentDevice.address]; 424 | device.services[services[service].uuid] = { 425 | startHandle: noble._bindings._gatts[deviceUuid]._services[services[service].uuid].startHandle, 426 | endHandle: noble._bindings._gatts[deviceUuid]._services[services[service].uuid].endHandle, 427 | characteristics: {} 428 | }; 429 | 430 | /* We are using a closure to keep a copy of the service's uuid. */ 431 | services[service].discoverCharacteristics(null, (function(serviceUuid){ 432 | return function(error, characs){ 433 | 434 | if (error == null) { 435 | for (var c in characs) { 436 | this.services[serviceUuid][characs[c].uuid] = characs[c]; 437 | 438 | /* Characteristic is not discovered by default. */ 439 | this.discovered[serviceUuid].characteristics[characs[c].uuid] = false; 440 | 441 | /* Save characteristic. */ 442 | var _service = device.services[serviceUuid]; 443 | _service.characteristics[characs[c].uuid] = { 444 | uuid: characs[c].uuid, 445 | properties: characs[c].properties, 446 | descriptors: [], 447 | 448 | /* We read the handles from Noble's internal objects and save them. */ 449 | startHandle: noble._bindings._gatts[deviceUuid]._characteristics[serviceUuid][characs[c].uuid].startHandle, 450 | valueHandle: noble._bindings._gatts[deviceUuid]._characteristics[serviceUuid][characs[c].uuid].valueHandle, 451 | endHandle: noble._bindings._gatts[deviceUuid]._characteristics[serviceUuid][characs[c].uuid].endHandle 452 | }; 453 | 454 | characs[c].discoverDescriptors((function(t, service, charac){ 455 | return function(error, descriptors) { 456 | if (error == undefined) { 457 | var device = t.devices[t.currentDevice.address]; 458 | var _charac = device.services[service].characteristics[charac]; 459 | for (var desc in descriptors) { 460 | _charac.descriptors.push({ 461 | 'uuid': descriptors[desc].uuid, 462 | 'handle': noble._bindings._gatts[deviceUuid]._descriptors[service][charac][descriptors[desc].uuid].handle, 463 | 'value': (descriptors[desc].uuid == '2901')?new Buffer([]):null 464 | }); 465 | } 466 | t.onCharacteristicDiscovered(service, charac); 467 | } else { 468 | console.log('[error] cannot discover descriptor for service '+ service+':'+charac); 469 | } 470 | } 471 | })(this, serviceUuid, characs[c].uuid)); 472 | } 473 | this.discovered[serviceUuid].done = true; 474 | } else { 475 | console.log(('[error] cannot discover characteristic ' + charac).red) 476 | } 477 | }; 478 | })(services[service].uuid).bind(this)); 479 | } 480 | } else { 481 | console.log(('[error] cannot discover service ' + serviceUuid).red); 482 | } 483 | }.bind(this)); 484 | } else { 485 | this.send('ready', false); 486 | } 487 | } 488 | }.bind(this)); 489 | }; 490 | 491 | Proxy.prototype.onDeviceDisconnected = function(){ 492 | console.log('[error] Remote device has just disconnected'.red); 493 | 494 | /* Defuse watchdog if any. */ 495 | if (this.watchdog != null) { 496 | console.log('disarming watchdog'); 497 | clearTimeout(this.watchdog); 498 | this.watchdog = null; 499 | } 500 | 501 | /* Reset services and characteristics. */ 502 | this.services = null; 503 | this.discovered = {}; 504 | 505 | /* Notify core device disconnected if not stopping. */ 506 | if ((this.state != 'stopping') && (this.state != 'disconnected')) { 507 | this.send('device.disconnect', this.target); 508 | } 509 | this.send('status', 'disconnected'); 510 | 511 | /* Mark as disconnected. */ 512 | this.state = 'disconnected'; 513 | this.device = null; 514 | }; 515 | 516 | Proxy.prototype.formatProfile = function() { 517 | /* Create our serialized data. */ 518 | var device_info = {}; 519 | device_info['ad_records'] = this.devices[this.target].adv_records.toString('hex'); 520 | if (this.devices[this.target].scan_data != null) 521 | device_info['scan_data'] = this.devices[this.target].scan_data.toString('hex'); 522 | else 523 | device_info['scan_data'] = ''; 524 | device_info['name'] = this.devices[this.target].name; 525 | device_info['services'] = []; 526 | device_info['address'] = this.target; 527 | for (var _service in this.devices[this.target].services) { 528 | var _chars = this.devices[this.target].services[_service].characteristics; 529 | 530 | var service = {}; 531 | service['uuid'] = _service; 532 | service['startHandle'] = this.devices[this.target].services[_service].startHandle; 533 | service['endHandle'] = this.devices[this.target].services[_service].endHandle; 534 | service['characteristics'] = []; 535 | for (var device_char in _chars) { 536 | var char = {}; 537 | char['uuid'] = _chars[device_char]['uuid']; 538 | char['properties'] = _chars[device_char]['properties']; 539 | char['descriptors'] = _chars[device_char]['descriptors']; 540 | char['startHandle'] = _chars[device_char]['startHandle']; 541 | char['valueHandle'] = _chars[device_char]['valueHandle']; 542 | char['endHandle'] = _chars[device_char]['endHandle']; 543 | service['characteristics'].push(char); 544 | } 545 | device_info['services'].push(service); 546 | } 547 | return device_info; 548 | } 549 | 550 | Proxy.prototype.onDiscoverCharacteristic = function(peripheral, service, charac, callback) { 551 | charac.discoverDescriptors((function(_this, peripheral, service, charac, callback){ 552 | return function(error, descriptors) { 553 | callback(); 554 | } 555 | })(this, peripheral, service, charac, callback)); 556 | }; 557 | 558 | Proxy.prototype.onCharacteristicDiscovered = function(service, characteristic) { 559 | this.discovered[service].characteristics[characteristic] = true; 560 | if (this.isAllDiscovered()) { 561 | 562 | /* Defuse watchdog if any. */ 563 | if (this.watchdog != null) { 564 | clearTimeout(this.watchdog); 565 | this.watchdog = null; 566 | } 567 | 568 | this.setGattHandlers(); 569 | this.state = 'forwarding'; 570 | this.send('profile', this.formatProfile()); 571 | this.send('ready', true); 572 | console.log('[status] Proxy configured and ready to relay !'.green); 573 | } 574 | }; 575 | 576 | /** 577 | * setGattHandlers 578 | * 579 | * Install basic GATT operations handlers. 580 | **/ 581 | 582 | Proxy.prototype.setGattHandlers = function(){ 583 | if (this.client == null) 584 | return; 585 | 586 | /* Remove previous listeners. */ 587 | this.client.removeAllListeners('ble_read'); 588 | this.client.removeAllListeners('ble_write'); 589 | this.client.removeAllListeners('ble_notify'); 590 | 591 | /* Install read handler. */ 592 | this.client.on('ble_read', function(service, characteristic, offset){ 593 | /* Force lower case. */ 594 | service = service.toLowerCase(); 595 | characteristic = characteristic.toLowerCase(); 596 | 597 | /* Read characteristic. */ 598 | if (this.services != null) { 599 | this.services[service][characteristic].read(function(error, data){ 600 | this.send('ble_read_resp', service, characteristic, new Buffer(data)); 601 | }.bind(this)); 602 | } 603 | }.bind(this)); 604 | 605 | /* Install write handler. */ 606 | this.client.on('ble_write', function(service, characteristic, data, withoutResponse){ 607 | /* Force lower case. */ 608 | service = service.toLowerCase(); 609 | characteristic = characteristic.toLowerCase(); 610 | 611 | /* Write characteristic. */ 612 | if (this.services != null) { 613 | this.services[service][characteristic].write(data, withoutResponse, function(error){ 614 | this.send('ble_write_resp', service, characteristic, error); 615 | }.bind(this)); 616 | } 617 | }.bind(this)); 618 | 619 | /* Install notify handler. */ 620 | this.client.on('ble_notify', function(service, characteristic, enable){ 621 | if (this.services != null) { 622 | /* Register our automatic read handler. */ 623 | this.services[service][characteristic].removeAllListeners('data'); 624 | this.services[service][characteristic].on('data', function(_this, _service, _charac){ 625 | return function(data, isnotif)  { 626 | if (isnotif) { 627 | _this.send('ble_data', _service, _charac, data, isnotif); 628 | } 629 | }; 630 | }(this, service, characteristic)); 631 | 632 | /* Subscribe for notification. */ 633 | this.services[service][characteristic].notify(enable, function(_this, _service, _charac){ 634 | return function(error) { 635 | _this.send('ble_notify_resp', service, characteristic, error); 636 | }; 637 | }(this, service, characteristic)); 638 | } 639 | }.bind(this)); 640 | }; 641 | 642 | Proxy.prototype.scanDevices = function(){ 643 | if (this.state == 'connected') { 644 | noble.disconnect(); 645 | } 646 | 647 | /* Get discovery announces. */ 648 | noble.removeAllListeners('discover'); 649 | noble.on('discover', function(peripheral){ 650 | /* Forward discovery message to consumer. */ 651 | this.send('discover', peripheral.address, peripheral.advertisement.localName, peripheral.rssi); 652 | }.bind(this) 653 | ); 654 | 655 | this.state = 'scanning'; 656 | noble.startScanning(null, true); 657 | }; 658 | 659 | Proxy.prototype.stop = function(){ 660 | this.state = 'stopping'; 661 | console.log('[i] Stopping current proxy.'.bold); 662 | 663 | /* If already connected to a target, drop the connection. */ 664 | if (this.device != null) { 665 | /* Disconnect from device. */ 666 | this.device.disconnect(function(){ 667 | /* Reset services and characteristics. */ 668 | this.services = null; 669 | this.discovered = null; 670 | 671 | /* Mark as disconnected. */ 672 | this.state = 'disconnected'; 673 | this.device = null; 674 | }.bind(this)); 675 | } 676 | 677 | /* Reset. */ 678 | this.state = 'disconnected'; 679 | this.services = null; 680 | this.discovered = null; 681 | 682 | /* Notify client if any. */ 683 | if (this.client != null) { 684 | this.send('stopped'); 685 | } 686 | }; 687 | 688 | Proxy.prototype.notifyStatus = function(){ 689 | switch(this.state) { 690 | case 'connected': 691 | this.send('status', 'connected'); 692 | break; 693 | case 'disconnected': 694 | this.send('status', 'ready'); 695 | break; 696 | case 'scanning': 697 | this.send('status', 'scanning'); 698 | break; 699 | default: 700 | this.send('status', 'busy'); 701 | break; 702 | }; 703 | } 704 | 705 | if (!module.parent) { 706 | var proxy = new Proxy(null); 707 | proxy.start(); 708 | } else { 709 | module.exports = Proxy; 710 | } 711 | -------------------------------------------------------------------------------- /resources/css/bootstrap-theme.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.6 (http://getbootstrap.com) 3 | * Copyright 2011-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger.disabled,.btn-danger[disabled],.btn-default.disabled,.btn-default[disabled],.btn-info.disabled,.btn-info[disabled],.btn-primary.disabled,.btn-primary[disabled],.btn-success.disabled,.btn-success[disabled],.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-danger,fieldset[disabled] .btn-default,fieldset[disabled] .btn-info,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-warning{-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} 6 | /*# sourceMappingURL=bootstrap-theme.min.css.map */ -------------------------------------------------------------------------------- /resources/css/bootstrap-theme.min.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["less/theme.less","less/mixins/vendor-prefixes.less","less/mixins/gradients.less","less/mixins/reset-filter.less"],"names":[],"mappings":";;;;AAmBA,YAAA,aAAA,UAAA,aAAA,aAAA,aAME,YAAA,EAAA,KAAA,EAAA,eC2CA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBDvCR,mBAAA,mBAAA,oBAAA,oBAAA,iBAAA,iBAAA,oBAAA,oBAAA,oBAAA,oBAAA,oBAAA,oBCsCA,mBAAA,MAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,iBDlCR,qBAAA,sBAAA,sBAAA,uBAAA,mBAAA,oBAAA,sBAAA,uBAAA,sBAAA,uBAAA,sBAAA,uBAAA,+BAAA,gCAAA,6BAAA,gCAAA,gCAAA,gCCiCA,mBAAA,KACQ,WAAA,KDlDV,mBAAA,oBAAA,iBAAA,oBAAA,oBAAA,oBAuBI,YAAA,KAyCF,YAAA,YAEE,iBAAA,KAKJ,aErEI,YAAA,EAAA,IAAA,EAAA,KACA,iBAAA,iDACA,iBAAA,4CAAA,iBAAA,qEAEA,iBAAA,+CCnBF,OAAA,+GH4CA,OAAA,0DACA,kBAAA,SAuC2C,aAAA,QAA2B,aAAA,KArCtE,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAgBN,aEtEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAiBN,aEvEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAkBN,UExEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,gBAAA,gBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,iBAAA,iBAEE,iBAAA,QACA,aAAA,QAMA,mBAAA,0BAAA,yBAAA,0BAAA,yBAAA,yBAAA,oBAAA,2BAAA,0BAAA,2BAAA,0BAAA,0BAAA,6BAAA,oCAAA,mCAAA,oCAAA,mCAAA,mCAME,iBAAA,QACA,iBAAA,KAmBN,aEzEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAoBN,YE1EI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,kBAAA,kBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,mBAAA,mBAEE,iBAAA,QACA,aAAA,QAMA,qBAAA,4BAAA,2BAAA,4BAAA,2BAAA,2BAAA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,+BAAA,sCAAA,qCAAA,sCAAA,qCAAA,qCAME,iBAAA,QACA,iBAAA,KA2BN,eAAA,WClCE,mBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,EAAA,IAAA,IAAA,iBD2CV,0BAAA,0BE3FI,iBAAA,QACA,iBAAA,oDACA,iBAAA,+CAAA,iBAAA,wEACA,iBAAA,kDACA,OAAA,+GF0FF,kBAAA,SAEF,yBAAA,+BAAA,+BEhGI,iBAAA,QACA,iBAAA,oDACA,iBAAA,+CAAA,iBAAA,wEACA,iBAAA,kDACA,OAAA,+GFgGF,kBAAA,SASF,gBE7GI,iBAAA,iDACA,iBAAA,4CACA,iBAAA,qEAAA,iBAAA,+CACA,OAAA,+GACA,OAAA,0DCnBF,kBAAA,SH+HA,cAAA,ICjEA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBD6DV,sCAAA,oCE7GI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SD2CF,mBAAA,MAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,iBD0EV,cAAA,iBAEE,YAAA,EAAA,IAAA,EAAA,sBAIF,gBEhII,iBAAA,iDACA,iBAAA,4CACA,iBAAA,qEAAA,iBAAA,+CACA,OAAA,+GACA,OAAA,0DCnBF,kBAAA,SHkJA,cAAA,IAHF,sCAAA,oCEhII,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SD2CF,mBAAA,MAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,gBDgFV,8BAAA,iCAYI,YAAA,EAAA,KAAA,EAAA,gBAKJ,qBAAA,kBAAA,mBAGE,cAAA,EAqBF,yBAfI,mDAAA,yDAAA,yDAGE,MAAA,KE7JF,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,UFqKJ,OACE,YAAA,EAAA,IAAA,EAAA,qBC3HA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,gBDsIV,eEtLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAKF,YEvLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAMF,eExLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAOF,cEzLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAeF,UEjMI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFuMJ,cE3MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFwMJ,sBE5MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFyMJ,mBE7MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF0MJ,sBE9MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF2MJ,qBE/MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF+MJ,sBElLI,iBAAA,yKACA,iBAAA,oKACA,iBAAA,iKFyLJ,YACE,cAAA,IC9KA,mBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,EAAA,IAAA,IAAA,iBDgLV,wBAAA,8BAAA,8BAGE,YAAA,EAAA,KAAA,EAAA,QEnOE,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFiOF,aAAA,QALF,+BAAA,qCAAA,qCAQI,YAAA,KAUJ,OCnME,mBAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,EAAA,IAAA,IAAA,gBD4MV,8BE5PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFyPJ,8BE7PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF0PJ,8BE9PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF2PJ,2BE/PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF4PJ,8BEhQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF6PJ,6BEjQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFoQJ,MExQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFsQF,aAAA,QC3NA,mBAAA,MAAA,EAAA,IAAA,IAAA,gBAAA,EAAA,IAAA,EAAA,qBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,gBAAA,EAAA,IAAA,EAAA"} -------------------------------------------------------------------------------- /resources/css/bootstrap-treeview.min.css: -------------------------------------------------------------------------------- 1 | .treeview .list-group-item{cursor:pointer}.treeview span.indent{margin-left:10px;margin-right:10px}.treeview span.icon{width:12px;margin-right:5px}.treeview .node-disabled{color:silver;cursor:not-allowed} -------------------------------------------------------------------------------- /resources/css/btlejuice.css: -------------------------------------------------------------------------------- 1 | div.event-connect { 2 | text-align: center; 3 | background-color: #8cbd79; 4 | font-size: 10pt; 5 | font-weight: bold; 6 | text-align: center; 7 | border-bottom: solid 1px #eee; 8 | } 9 | 10 | div.event-disconnect { 11 | text-align: center; 12 | background-color: #dfa0a0; 13 | font-size: 10pt; 14 | font-weight: bold; 15 | text-align: center; 16 | border-bottom: solid 1px #eee; 17 | } 18 | 19 | div.transaction { 20 | cursor: pointer; 21 | background: none; 22 | } 23 | 24 | div.transaction:hover { 25 | background: #eee; 26 | } 27 | -------------------------------------------------------------------------------- /resources/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/resources/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /resources/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/resources/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /resources/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/resources/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /resources/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/resources/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /resources/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/resources/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /resources/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/resources/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /resources/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/resources/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /resources/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/resources/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /resources/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DigitalSecurity/btlejuice/981b2e3fcaeca3d0c8faf6d61b4efd8130984bf4/resources/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /resources/js/binbuf.js: -------------------------------------------------------------------------------- 1 | 2 | function BinBuf(len) { 3 | 'use strict'; 4 | this.uuid = generateUUID(); 5 | this.buffer = []; 6 | this.marked = []; 7 | this.name = "unnamed"; 8 | this.changed = 1; 9 | 10 | this.offset = 0; 11 | this.current = 0; 12 | this.nibble = 0; 13 | 14 | this.selectionStart = -1; 15 | this.selectionStop = -1; 16 | if (len) { 17 | this.buffer = new Uint8Array(len); 18 | this.colors = new Uint8Array(len); 19 | this.marked = new Uint8Array(len); 20 | 21 | } 22 | } 23 | BinBuf.prototype = { 24 | 25 | setName: function(name) { // set name 26 | this.name = name; 27 | }, 28 | getName: function() { 29 | return this.name; 30 | }, 31 | 32 | length: function() { // leng 33 | return this.buffer.length; 34 | }, 35 | 36 | loadDataFromFile: function (data) { // load data from binary blob 37 | var binobj = new jBinary(data); 38 | this.buffer = binobj.read(['blob', binobj.view.byteLength ], 0); 39 | var len = this.length() 40 | this.colors = new Uint8Array(len); 41 | this.marked = new Uint8Array(len); 42 | 43 | return 1; 44 | }, 45 | 46 | loadDataFromLocalStorage: function(data) { // load from localstorage 47 | 48 | if (data.data) { 49 | this.buffer = data.data; 50 | this.unpackLS(data.data); 51 | this.marked = new Uint8Array(this.length()); 52 | 53 | if (data.colors) { 54 | this.colors = data.colors; 55 | } else { 56 | this.colors = new Uint8Array(len); 57 | } 58 | this.uuid= data.uuid; 59 | return 1; 60 | } 61 | return 0; 62 | 63 | }, 64 | 65 | saveToDict: function () { 66 | this.changed=0; 67 | var data = { 68 | name: this.getName(), 69 | colors:this.colors, 70 | data: this.toBuffer(), 71 | uuid: this.uuid 72 | }; 73 | return data; 74 | 75 | }, 76 | getByte: function(adr) { 77 | return this.buffer[adr]; 78 | }, 79 | 80 | getByteHex: function(adr) { 81 | return toHex(this.buffer[adr],2); 82 | }, 83 | 84 | getColoredRegion : function (adr) { // if hovering colored region, get regions extents 85 | var color = this.colors[adr] 86 | var startadr = adr; 87 | var endadr = adr; 88 | if (color === 0) { 89 | return undefined; // threris no color chunk under cursor 90 | } else { 91 | 92 | for (var i=adr;i=0;i--) { 100 | if (this.colors[i] != color) { 101 | 102 | break; 103 | } 104 | startadr = i; 105 | } 106 | return {start:startadr,end:endadr} 107 | } 108 | }, 109 | compareToBuffer : function (target) { 110 | 111 | for (var i=0;i end) { 253 | var t = start; 254 | start = end; 255 | end = t; 256 | } 257 | this.selectionStart = start; 258 | this.selectionStop = end; 259 | 260 | }, 261 | isSelected: function (index) { 262 | if (this.selectionStart < 0) return false; 263 | if (this.selectionStop < 0) return false; 264 | if((index >= this.selectionStart) && (index <=this.selectionStop)) { 265 | return true; 266 | } 267 | return false 268 | }, 269 | fillWithSequence: function (start,end,sequence,xor) { 270 | 271 | var slen = sequence.length; 272 | if (slen === 0) return; 273 | var seqpos=0; 274 | 275 | for (var i=start;i<=end;i++) { 276 | var sval = sequence[seqpos++]; 277 | if (seqpos >= slen) seqpos=0; 278 | if (xor) { 279 | sval = (this.getByte(i) ^ sval) & 0xFF; 280 | } 281 | this.setByte(i,sval); 282 | } 283 | 284 | } 285 | 286 | 287 | }; 288 | -------------------------------------------------------------------------------- /resources/js/bj_proxy.js: -------------------------------------------------------------------------------- 1 | var socket = io(); 2 | 3 | 4 | /* Forward writes. */ 5 | /* 6 | socket.on('proxy_write', function(s, c, d, o, w) { 7 | socket.once('ble_write_resp', function(s,c,e){ 8 | socket.emit('proxy_write_resp', s,c,e); 9 | }); 10 | socket.emit('ble_write', s, c, d, o, w); 11 | });*/ 12 | 13 | 14 | /* Forward reads. */ 15 | /* 16 | socket.on('proxy_read', function(s,c) { 17 | socket.once('ble_read_resp', function(s,c,data){ 18 | console.log('send data back to app'); 19 | socket.emit('proxy_read_resp', s,c,data); 20 | }); 21 | socket.emit('ble_read', s, c); 22 | });*/ 23 | var interceptor = new Interceptor(); 24 | -------------------------------------------------------------------------------- /resources/js/controllers/bj.proxy.js: -------------------------------------------------------------------------------- 1 | /* Main controller */ 2 | var BjProxy = angular.module('BjProxy', ['ui.bootstrap.contextMenu']); 3 | 4 | /** 5 | * UUID filter 6 | **/ 7 | 8 | BjProxy.filter('makeUUID', function () { 9 | return function (item) { 10 | return formatUUID(item); 11 | }; 12 | }); 13 | 14 | 15 | /** 16 | * Transaction list controller 17 | **/ 18 | 19 | BjProxy.controller('TransactionListCtrl', function($scope, $rootScope, $window){ 20 | $scope.transactions = []; 21 | /* 22 | $scope.options = [ 23 | ['Replay', function ($itemScope) { 24 | console.log('replay'); 25 | console.log($itemScope); 26 | }], 27 | ['Enable hooking', function($itemScope) { 28 | console.log('set hooking'); 29 | }], 30 | ];*/ 31 | 32 | $rootScope.$on('transactions.reset', function(){ 33 | console.log('got transactions.reset'); 34 | $scope.transactions = []; 35 | }); 36 | 37 | $rootScope.$on('transactions.export.file', function(event, filename, format){ 38 | 39 | /* Append extension. */ 40 | if (format === 'json') 41 | var ext = 'json'; 42 | else 43 | var ext = 'txt'; 44 | filename += '.' + ext; 45 | 46 | /* Format data based on selected format. */ 47 | switch(format) { 48 | case 'json': 49 | /* Export as JSON data, easy to process. */ 50 | var exportData = { 51 | 'target': interceptor.getProfile(), 52 | 'activity': [], 53 | } 54 | for (var i in $scope.transactions) { 55 | var t = $scope.transactions[i]; 56 | exportData.activity.push({ 57 | type: t.op, 58 | service: formatUUID(t.service), 59 | characteristic: formatUUID(t.characteristic), 60 | data: t.dataHex.replace(/ /g,''), 61 | }) 62 | } 63 | /* Convert to JSON. */ 64 | exportData = angular.toJson(exportData); 65 | break; 66 | 67 | /* Text export is intended to be readable. */ 68 | case 'text': 69 | var profile = interceptor.getProfile(); 70 | var exportData = 'BtleJuice export data\n\n'; 71 | exportData += '==< Capture Information >\n'; 72 | exportData += ' BD Address : ' + profile.address + '\n'; 73 | exportData += ' Device Name: ' + profile.name + '\n'; 74 | if (profile.ad_records != null) { 75 | exportData += ' Adv. Data : ' + profile.ad_records + '\n'; 76 | } 77 | if (profile.scan_data != null) { 78 | exportData += ' Scan Data : ' + profile.scan_data + '\n'; 79 | } 80 | exportData += ' Saved on : ' + (new Date()).toUTCString() + '\n'; 81 | exportData += '==========================\n\n'; 82 | 83 | for (var i in $scope.transactions) { 84 | var row = ''; 85 | var t = $scope.transactions[i]; 86 | if (t.op == 'event') { 87 | if (t.service == 'connect') 88 | row = '>>> Connection from remote device to dummy'; 89 | else if (t.service == 'disconnect') 90 | row = '>>> Disconnection from dummy'; 91 | } else { 92 | /* TODO: add ASCII dump. */ 93 | switch (t.op) { 94 | case 'read': 95 | row = 'READ from '+formatUUID(t.service)+':'+formatUUID(t.characteristic)+' -- ' + t.dataHex; 96 | break; 97 | 98 | case 'write': 99 | row = 'WRITE to ' + formatUUID(t.service)+':'+formatUUID(t.characteristic)+' -- ' + t.dataHex; 100 | break; 101 | 102 | case 'notification': 103 | row = 'NOTIFICATION from ' + formatUUID(t.service)+':'+formatUUID(t.characteristic)+' -- ' + t.dataHex; 104 | break; 105 | } 106 | } 107 | exportData += row + '\n'; 108 | } 109 | break; 110 | } 111 | 112 | var blob = new Blob([exportData]); 113 | var link = angular.element(''); 114 | link.attr('href', window.URL.createObjectURL(blob)); 115 | link.attr('download',filename); 116 | link[0].click(); 117 | }); 118 | 119 | $scope.options = function(item) { 120 | if (item.op == 'event') { 121 | return []; 122 | } else { 123 | /* Check if item is already intercepted. */ 124 | //TODO: check ! 125 | return [ 126 | ['Replay', function($itemScope){ 127 | $scope.onReplayItem($itemScope.t); 128 | }], 129 | [function($itemScope){ 130 | if (interceptor.isHooked($itemScope.t.service, $itemScope.t.characteristic)) { 131 | return 'Disable hook'; 132 | } else { 133 | return 'Set hook'; 134 | } 135 | },function($itemScope){ 136 | if (interceptor.isHooked($itemScope.t.service, $itemScope.t.characteristic)) { 137 | $scope.onRemoveHook($itemScope.t); 138 | } else { 139 | $scope.onSetHook($itemScope.t); 140 | } 141 | }] 142 | ]; 143 | } 144 | }; 145 | 146 | $scope.dimensions = { 147 | 'height': (window.innerHeight - 82)+'px' 148 | }; 149 | 150 | $scope.addTransaction = function(transaction, disableRefresh) { 151 | $scope.transactions.push(transaction); 152 | if (!disableRefresh) 153 | $scope.$apply(); 154 | }; 155 | 156 | $scope.onSwitchDisplay = function(transaction) { 157 | if (transaction.data === transaction.dataHexii) { 158 | transaction.data = transaction.dataHex; 159 | } else { 160 | transaction.data = transaction.dataHexii; 161 | } 162 | } 163 | 164 | $scope.onReplayItem = function(transaction) { 165 | console.log('replay'); 166 | console.log(transaction); 167 | $rootScope.$emit('replay', transaction); 168 | }; 169 | 170 | $scope.onRemoveHook = function(transaction) { 171 | interceptor.removeHook(transaction.service, transaction.characteristic); 172 | }; 173 | 174 | $scope.onSetHook = function(transaction) { 175 | interceptor.setHook(transaction.service, transaction.characteristic); 176 | }; 177 | }) 178 | 179 | .directive('resize', function(){ 180 | return { 181 | restrict: 'A', 182 | link: function(scope, elem, attr) { 183 | angular.element(window).on('resize', function(){ 184 | console.log('resize'); 185 | scope.$apply(function(){ 186 | scope.dimensions = { 187 | 'height': (window.innerHeight - 82)+'px' 188 | }; 189 | }); 190 | }); 191 | } 192 | } 193 | }); 194 | 195 | /** 196 | * Navbar controller. 197 | **/ 198 | 199 | BjProxy.controller('NavCtrl', function($scope, $rootScope, $element){ 200 | 201 | $scope.state = 'disconnected'; 202 | $scope.intercepting = interceptor.isInteractive(); 203 | 204 | interceptor.on('status.change', function(status){ 205 | $scope.config = interceptor.getConfig(); 206 | $scope.state = status; 207 | $scope.$apply(); 208 | }); 209 | 210 | $scope.config = interceptor.getConfig(); 211 | console.log($scope.config); 212 | $scope.target = null; 213 | 214 | $scope.onSelectTarget = function() { 215 | console.log('select target !'); 216 | /* Reset transactions. */ 217 | $scope.$emit('transactions.reset'); 218 | $scope.$emit('target.select'); 219 | }; 220 | 221 | $scope.onDisconnect = function() { 222 | console.log('disconnect target'); 223 | $scope.$emit('target.disconnect'); 224 | }; 225 | 226 | $scope.onSettings = function(){ 227 | $scope.$emit('settings.show'); 228 | }; 229 | 230 | $scope.onServices = function(){ 231 | $scope.$emit('profile.show'); 232 | }; 233 | 234 | $scope.onEnableIntercept = function(){ 235 | interceptor.setMode(interceptor.MODE_INTERACTIVE); 236 | $scope.intercepting = true; 237 | }; 238 | 239 | $scope.onDisableIntercept = function(){ 240 | interceptor.setMode(interceptor.MODE_FORWARD); 241 | $scope.intercepting = false; 242 | }; 243 | 244 | $scope.onExport = function(){ 245 | $scope.$emit('transactions.export'); 246 | } 247 | 248 | $rootScope.$on('target.connected', function(event, target){ 249 | console.log('target is connected !'); 250 | $scope.target = target.address; 251 | $scope.state = 'connected'; 252 | $scope.$apply(); 253 | }); 254 | 255 | }) 256 | 257 | .directive('toggle', function(){ 258 | return { 259 | restrict: 'A', 260 | link: function(scope, element, attrs){ 261 | if (attrs.toggle=="tooltip"){ 262 | $(element).tooltip(); 263 | } 264 | if (attrs.toggle=="popover"){ 265 | $(element).popover(); 266 | } 267 | } 268 | }; 269 | }); 270 | 271 | /** 272 | * Target selection controller. 273 | **/ 274 | 275 | BjProxy.controller('TargetCtrl', function($scope, $rootScope){ 276 | 277 | $scope.targets = []; 278 | $scope.seen = {}; 279 | $scope.error = false; 280 | 281 | $scope.target = null; 282 | 283 | $scope.selectTarget = function(target) { 284 | $scope.target = target; 285 | }; 286 | 287 | $scope.isSelected = function(target) { 288 | if ($scope.target == null) 289 | return false; 290 | else 291 | return ($scope.target.address == target.address); 292 | }; 293 | 294 | console.log('register target selection'); 295 | $rootScope.$on('target.select', function(){ 296 | console.log('event target.select fired'); 297 | 298 | interceptor.listDevices(function(peripheral, name, rssi){ 299 | if (!(peripheral in $scope.seen)) { 300 | $scope.targets.push({ 301 | address: peripheral, 302 | name: (name!=undefined)?name:'', 303 | rssi: rssi 304 | }); 305 | $scope.seen[peripheral] = null; 306 | $scope.$apply(); 307 | } 308 | }); 309 | 310 | /* Popup modal. */ 311 | $scope.targets = []; 312 | $scope.seen = {}; 313 | $('#m_target').modal(); 314 | }); 315 | 316 | $rootScope.$on('target.disconnect', function(){ 317 | console.log('event target.disconnect fired'); 318 | interceptor.disconnect(); 319 | $scope.targets = []; 320 | $scope.seen = {}; 321 | }); 322 | 323 | $scope.onSelectClick = function(target){ 324 | /* If a target has been selected, tell the interceptor to use it. */ 325 | if ($scope.target != null) { 326 | interceptor.selectTarget($scope.target.address, function(){ 327 | $rootScope.$emit('target.connected', $scope.target); 328 | }); 329 | 330 | $('#m_target').modal('hide'); 331 | } else { 332 | /* Display an error. */ 333 | $scope.error = true; 334 | } 335 | }; 336 | 337 | $scope.onSelectDblClick = function(target) { 338 | /* Select target. */ 339 | $scope.target = target; 340 | 341 | /* Simulate a click on the 'Select' button. */ 342 | $scope.onSelectClick(); 343 | }; 344 | }); 345 | 346 | 347 | /** 348 | * Settings controller. 349 | **/ 350 | 351 | BjProxy.controller('SettingsCtrl', function($scope, $rootScope){ 352 | 353 | $scope.config = { 354 | reconnect: interceptor.shouldReconnect, 355 | keepHandles: interceptor.keepHandles, 356 | }; 357 | 358 | $rootScope.$on('settings.show', function(){ 359 | $('#m_settings').modal(); 360 | }); 361 | 362 | $scope.onSave = function(){ 363 | /* Save interceptor config parameters. */ 364 | interceptor.shouldReconnect = $scope.config.reconnect; 365 | interceptor.keepHandles = $scope.config.keepHandles; 366 | $('#m_settings').modal('hide'); 367 | }; 368 | 369 | $scope.onCancel = function(){ 370 | $('#m_settings').modal('hide'); 371 | }; 372 | 373 | }); 374 | 375 | 376 | /** 377 | * Services controller. 378 | **/ 379 | 380 | BjProxy.controller('HookCtrl', function($scope, $rootScope, $element){ 381 | $scope.title = 'Intercept:'; 382 | $scope.action = { 383 | op: null, 384 | service: null, 385 | characteristic: null, 386 | data: null, 387 | offset: null, 388 | withoutResponse: null, 389 | }; 390 | 391 | interceptor.on('hooks.write', function(service, characteristic, data, offset, withoutResponse, noRefresh){ 392 | console.log('>>> got hooks.write'); 393 | console.log(arguments); 394 | $scope.action = { 395 | op: 'write', 396 | service: service, 397 | characteristic: characteristic, 398 | data: data, 399 | dataHexii: buffer2hexII(data), 400 | dataHex: buffer2hex(data), 401 | offset: offset, 402 | withoutResponse: withoutResponse 403 | }; 404 | if (noRefresh == null) 405 | $scope.$apply(); 406 | $('#m_hook').modal(); 407 | }); 408 | 409 | interceptor.on('hooks.read', function(service, characteristic, data, noRefresh){ 410 | console.log('>>> got hooks.read'); 411 | $scope.action = { 412 | op: 'read', 413 | service: service, 414 | characteristic: characteristic, 415 | data: data, 416 | dataHexii: buffer2hexII(data), 417 | dataHex: buffer2hex(data) 418 | }; 419 | if (noRefresh == null) 420 | $scope.$apply(); 421 | $('#m_hook').modal(); 422 | }); 423 | 424 | interceptor.on('hooks.notify', function(service, characteristic, data, noRefresh){ 425 | console.log('>>> got hooks.notify'); 426 | $scope.action = { 427 | op: 'notify', 428 | service: service, 429 | characteristic: characteristic, 430 | data: data, 431 | dataHexii: buffer2hexII(data), 432 | dataHex: buffer2hex(data) 433 | }; 434 | if (noRefresh == null) 435 | $scope.$apply(); 436 | $('#m_hook').modal(); 437 | }); 438 | 439 | 440 | $scope.onForward = function(){ 441 | /* Check if HexII is correct, and forward. */ 442 | console.log($scope.action.dataHexii); 443 | var data = hexII2buffer($scope.action.dataHexii); 444 | if (data != null) { 445 | 446 | /* Forward. */ 447 | if ($scope.action.op == 'write') { 448 | console.log(data); 449 | interceptor.deviceWrite( 450 | $scope.action.service, 451 | $scope.action.characteristic, 452 | data, 453 | $scope.action.offset, 454 | $scope.action.withoutResponse, 455 | 456 | /* Disable refresh (causes an error with angular). */ 457 | true 458 | ); 459 | 460 | /* Remove the edit popup. */ 461 | $('#m_hook').modal('hide'); 462 | 463 | /* Editing no more. */ 464 | interceptor.processNextRequest(); 465 | 466 | 467 | } else if ($scope.action.op == 'read') { 468 | interceptor.proxyReadResponse( 469 | $scope.action.service, 470 | $scope.action.characteristic, 471 | data, 472 | /* Disable refresh. */ 473 | true 474 | ); 475 | 476 | /* Remove the edit popup. */ 477 | $('#m_hook').modal('hide'); 478 | 479 | /* Editing no more. */ 480 | interceptor.processNextRequest(); 481 | } else if ($scope.action.op == 'notify') { 482 | interceptor.proxyNotifyData( 483 | $scope.action.service, 484 | $scope.action.characteristic, 485 | data, 486 | /* Disable refresh. */ 487 | true 488 | ); 489 | 490 | /* Remove the edit popup. */ 491 | $('#m_hook').modal('hide'); 492 | 493 | /* Editing no more. */ 494 | interceptor.processNextRequest(); 495 | } 496 | } else { 497 | /* TODO: notify user the hexii is incorrect. */ 498 | } 499 | }; 500 | 501 | $scope.onDismiss = function() { 502 | /* Dismiss. */ 503 | if ($scope.action.op == 'write') { 504 | interceptor.proxyWriteResponse( 505 | $scope.action.service, 506 | $scope.action.characteristic, 507 | null 508 | ); 509 | 510 | /* Remove the edit popup. */ 511 | $('#m_hook').modal('hide'); 512 | 513 | /* Editing no more. */ 514 | interceptor.processNextRequest(); 515 | } 516 | }; 517 | 518 | }); 519 | 520 | 521 | /** 522 | * Services controller. 523 | **/ 524 | 525 | BjProxy.controller('ServicesCtrl', function($scope, $rootScope, $element){ 526 | 527 | $scope.services = {}; 528 | $scope.selectedService = null; 529 | 530 | interceptor.on('target.profile', function(){ 531 | var profile = interceptor.getProfile(); 532 | if (profile) { 533 | for (var i in profile.services) { 534 | $scope.services[profile.services[i].uuid] = []; 535 | for (var j in profile.services[i].characteristics) { 536 | $scope.services[profile.services[i].uuid].push(profile.services[i].characteristics[j].uuid); 537 | } 538 | } 539 | $scope.$apply(); 540 | } 541 | }); 542 | 543 | $rootScope.$on('profile.show', function(){ 544 | $('#m_profile').modal(); 545 | }); 546 | 547 | $scope.onSave = function(){ 548 | alert('proxy: '+$scope.proxy + ', device:'+$scope.hciDevice); 549 | }; 550 | 551 | $scope.onCancel = function(){ 552 | $('#m_settings').modal('hide'); 553 | }; 554 | 555 | $scope.onSelect = function(uuid) { 556 | $scope.selected = uuid; 557 | $scope.$apply(); 558 | }; 559 | 560 | $scope.onServiceSelect = function() { 561 | console.log($scope.selectedService); 562 | }; 563 | 564 | $scope.onToggle = function(item, delay) { 565 | console.log(arguments); 566 | $('#'+item).parent().children('ul.tree').toggle(delay); 567 | }; 568 | 569 | }); 570 | 571 | 572 | 573 | /** 574 | * Replay controller 575 | **/ 576 | 577 | BjProxy.controller('ReplayCtrl', function($scope, $rootScope, $window){ 578 | $scope.op = null; 579 | $scope.service = null; 580 | $scope.characteristic = null; 581 | $scope.dataHexii = null; 582 | $scope.title = 'Replay' 583 | 584 | $rootScope.$on('replay', function(event, transaction){ 585 | console.log(transaction); 586 | switch(transaction.op) { 587 | case 'read': 588 | $scope.title = 'Replay read'; 589 | break; 590 | 591 | case 'write': 592 | $scope.title = 'Replay write'; 593 | break; 594 | 595 | case 'notification': 596 | $scope.title = 'Replay notification'; 597 | break; 598 | } 599 | $scope.op = transaction.op; 600 | $scope.service = transaction.service; 601 | $scope.characteristic = transaction.characteristic; 602 | $scope.dataHexii = transaction.data; 603 | 604 | $('#m_replay').modal(); 605 | }); 606 | 607 | $scope.onClose = function(){ 608 | $('#m_replay').modal('hide'); 609 | }; 610 | 611 | $scope.onRead = function(){ 612 | /* Read the characteristic from the device. */ 613 | interceptor.deviceRead($scope.service, $scope.characteristic, function(service, characteristic, data){ 614 | console.log('got data, update'); 615 | $scope.dataHexii = buffer2hexII(data); 616 | $scope.$apply(); 617 | }); 618 | }; 619 | 620 | $scope.onWrite = function() { 621 | /* Try to convert data to hexii. */ 622 | var data = hexII2buffer($scope.dataHexii); 623 | if (data == null) { 624 | console.log('error, bad data format'); 625 | } else { 626 | /* Write the data to characteristic. */ 627 | interceptor.deviceWrite($scope.service, $scope.characteristic, data, $scope.offset, false); 628 | } 629 | }; 630 | 631 | $scope.onNotify = function() { 632 | /* Try to convert data to hexii. */ 633 | var data = hexII2buffer($scope.dataHexii); 634 | if (data == null) { 635 | console.log('error, bad data format'); 636 | } else { 637 | /* Write the data to characteristic. */ 638 | interceptor.proxyNotifyData($scope.service, $scope.characteristic, data, true); 639 | } 640 | }; 641 | }); 642 | 643 | /** 644 | * Export controller 645 | **/ 646 | 647 | BjProxy.controller('ExportCtrl', function($scope, $rootScope, $window){ 648 | 649 | $scope.filename = filename; 650 | $scope.format = 'json'; 651 | 652 | $rootScope.$on('transactions.export', function(){ 653 | $scope.showExportDlg(); 654 | }); 655 | 656 | $scope.showExportDlg = function(){ 657 | /* Lil' hack to get the actual number of transactions displayed. */ 658 | var numTransactions = angular.element(document.getElementById('transactions')).scope().transactions.length; 659 | 660 | /* Get actual date. */ 661 | var exportDate = (new Date()) 662 | .toISOString() 663 | .replace(/T/g,'_') 664 | .replace(/-/g,'') 665 | .replace(/:/g,'') 666 | .slice(0,15); 667 | 668 | var profile = interceptor.getProfile(); 669 | if (numTransactions === 0) { 670 | return; 671 | } else { 672 | var deviceAddress = profile.address.replace(/:/g,''); 673 | var deviceName = profile.name.replace(/ /g,'_') 674 | .replace(/[^\x20-\x7E]+/g, ''); 675 | var filename = deviceAddress+'-'+deviceName+'-'+exportDate 676 | } 677 | $scope.filename = filename; 678 | $('#m_export').modal(); 679 | }; 680 | 681 | $scope.onCancel = function(){ 682 | $('#m_export').modal('hide'); 683 | }; 684 | 685 | $scope.onExport = function(){ 686 | console.log($scope.filename); 687 | console.log($scope.format); 688 | $scope.$emit('transactions.export.file', $scope.filename, $scope.format); 689 | $('#m_export').modal('hide'); 690 | }; 691 | 692 | }); 693 | -------------------------------------------------------------------------------- /resources/js/controllers/contextMenu.js: -------------------------------------------------------------------------------- 1 | angular.module('ui.bootstrap.contextMenu', []) 2 | 3 | .service('CustomService', function () { 4 | "use strict"; 5 | 6 | return { 7 | initialize: function (item) { 8 | console.log("got here", item); 9 | } 10 | } 11 | 12 | }) 13 | .directive('contextMenu', ["$parse", "$q", "CustomService", "$sce", function ($parse, $q, custom, $sce) { 14 | 15 | var contextMenus = []; 16 | var $currentContextMenu = null; 17 | var defaultItemText = "New Item"; 18 | 19 | var removeContextMenus = function (level) { 20 | /// Remove context menu. 21 | while (contextMenus.length && (!level || contextMenus.length > level)) { 22 | contextMenus.pop().remove(); 23 | } 24 | if (contextMenus.length == 0 && $currentContextMenu) { 25 | $currentContextMenu.remove(); 26 | } 27 | }; 28 | 29 | 30 | var processTextItem = function ($scope, item, text, event, model, $promises, nestedMenu, $) { 31 | "use strict"; 32 | 33 | var $a = $(''); 34 | $a.css("padding-right", "8px"); 35 | $a.attr({ tabindex: '-1', href: '#' }); 36 | 37 | if (typeof item[0] === 'string') { 38 | text = item[0]; 39 | } 40 | else if (typeof item[0] === "function") { 41 | text = item[0].call($scope, $scope, event, model); 42 | } else if (typeof item.text !== "undefined") { 43 | text = item.text; 44 | } 45 | 46 | var $promise = $q.when(text); 47 | $promises.push($promise); 48 | $promise.then(function (text) { 49 | if (nestedMenu) { 50 | $a.css("cursor", "default"); 51 | $a.append($('>')); 52 | } 53 | $a.append(text); 54 | }); 55 | 56 | return $a; 57 | 58 | }; 59 | 60 | var processItem = function ($scope, event, model, item, $ul, $li, $promises, $q, $, level) { 61 | /// Process individual item 62 | "use strict"; 63 | // nestedMenu is either an Array or a Promise that will return that array. 64 | var nestedMenu = angular.isArray(item[1]) || (item[1] && angular.isFunction(item[1].then)) 65 | ? item[1] : angular.isArray(item[2]) || (item[2] && angular.isFunction(item[2].then)) 66 | ? item[2] : angular.isArray(item[3]) || (item[3] && angular.isFunction(item[3].then)) 67 | ? item[3] : null; 68 | 69 | // if html property is not defined, fallback to text, otherwise use default text 70 | // if first item in the item array is a function then invoke .call() 71 | // if first item is a string, then text should be the string. 72 | 73 | var text = defaultItemText; 74 | if (typeof item[0] === 'function' || typeof item[0] === 'string' || typeof item.text !== "undefined") { 75 | text = processTextItem($scope, item, text, event, model, $promises, nestedMenu, $); 76 | } 77 | else if (typeof item.html !== "undefined") { 78 | // leave styling open to dev 79 | text = item.html 80 | } 81 | 82 | $li.append(text); 83 | 84 | 85 | 86 | 87 | // if item is object, and has enabled prop invoke the prop 88 | // els if fallback to item[2] 89 | 90 | var isEnabled = function () { 91 | if (typeof item.enabled !== "undefined") { 92 | return item.enabled.call($scope, $scope, event, model, text); 93 | } else if (typeof item[2] === "function") { 94 | return item[2].call($scope, $scope, event, model, text); 95 | } else { 96 | return true; 97 | } 98 | }; 99 | 100 | registerEnabledEvents($scope, isEnabled(), item, $ul, $li, nestedMenu, model, text, event, $, level); 101 | }; 102 | 103 | var handlePromises = function ($ul, level, event, $promises) { 104 | /// 105 | /// calculate if drop down menu would go out of screen at left or bottom 106 | /// calculation need to be done after element has been added (and all texts are set; thus thepromises) 107 | /// to the DOM the get the actual height 108 | /// 109 | "use strict"; 110 | $q.all($promises).then(function () { 111 | var topCoordinate = event.pageY; 112 | var menuHeight = angular.element($ul[0]).prop('offsetHeight'); 113 | var winHeight = event.view.innerHeight; 114 | if (topCoordinate > menuHeight && winHeight - topCoordinate < menuHeight) { 115 | topCoordinate = event.pageY - menuHeight; 116 | } else if(winHeight <= menuHeight) { 117 | // If it really can't fit, reset the height of the menu to one that will fit 118 | angular.element($ul[0]).css({"height": winHeight - 5, "overflow-y": "scroll"}); 119 | // ...then set the topCoordinate height to 0 so the menu starts from the top 120 | topCoordinate = 0; 121 | } else if(winHeight - topCoordinate < menuHeight) { 122 | var reduceThreshold = 5; 123 | if(topCoordinate < reduceThreshold) { 124 | reduceThreshold = topCoordinate; 125 | } 126 | topCoordinate = winHeight - menuHeight - reduceThreshold; 127 | } 128 | 129 | var leftCoordinate = event.pageX; 130 | var menuWidth = angular.element($ul[0]).prop('offsetWidth'); 131 | var winWidth = event.view.innerWidth; 132 | var rightPadding = 5; 133 | if (leftCoordinate > menuWidth && winWidth - leftCoordinate - rightPadding < menuWidth) { 134 | leftCoordinate = winWidth - menuWidth - rightPadding; 135 | } else if(winWidth - leftCoordinate < menuWidth) { 136 | var reduceThreshold = 5; 137 | if(leftCoordinate < reduceThreshold + rightPadding) { 138 | reduceThreshold = leftCoordinate + rightPadding; 139 | } 140 | leftCoordinate = winWidth - menuWidth - reduceThreshold - rightPadding; 141 | } 142 | 143 | $ul.css({ 144 | display: 'block', 145 | position: 'absolute', 146 | left: leftCoordinate + 'px', 147 | top: topCoordinate + 'px' 148 | }); 149 | }); 150 | 151 | }; 152 | 153 | var registerEnabledEvents = function ($scope, enabled, item, $ul, $li, nestedMenu, model, text, event, $, level) { 154 | /// If item is enabled, register various mouse events. 155 | if (enabled) { 156 | var openNestedMenu = function ($event) { 157 | removeContextMenus(level + 1); 158 | /* 159 | * The object here needs to be constructed and filled with data 160 | * on an "as needed" basis. Copying the data from event directly 161 | * or cloning the event results in unpredictable behavior. 162 | */ 163 | var ev = { 164 | pageX: event.pageX + $ul[0].offsetWidth - 1, 165 | pageY: $ul[0].offsetTop + $li[0].offsetTop - 3, 166 | view: event.view || window 167 | }; 168 | 169 | /* 170 | * At this point, nestedMenu can only either be an Array or a promise. 171 | * Regardless, passing them to when makes the implementation singular. 172 | */ 173 | $q.when(nestedMenu).then(function(promisedNestedMenu) { 174 | renderContextMenu($scope, ev, promisedNestedMenu, model, level + 1); 175 | }); 176 | }; 177 | 178 | $li.on('click', function ($event) { 179 | $event.preventDefault(); 180 | $scope.$apply(function () { 181 | if (nestedMenu) { 182 | openNestedMenu($event); 183 | } else { 184 | $(event.currentTarget).removeClass('context'); 185 | removeContextMenus(); 186 | 187 | if (angular.isFunction(item[1])) { 188 | item[1].call($scope, $scope, event, model, text) 189 | } else { 190 | item.click.call($scope, $scope, event, model, text); 191 | } 192 | } 193 | }); 194 | }); 195 | 196 | $li.on('mouseover', function ($event) { 197 | $scope.$apply(function () { 198 | if (nestedMenu) { 199 | openNestedMenu($event); 200 | } 201 | }); 202 | }); 203 | } else { 204 | $li.on('click', function ($event) { 205 | $event.preventDefault(); 206 | }); 207 | $li.addClass('disabled'); 208 | } 209 | 210 | }; 211 | 212 | 213 | var renderContextMenu = function ($scope, event, options, model, level, customClass) { 214 | /// Render context menu recursively. 215 | if (!level) { level = 0; } 216 | if (!$) { var $ = angular.element; } 217 | $(event.currentTarget).addClass('context'); 218 | var $contextMenu = $('
'); 219 | if ($currentContextMenu) { 220 | $contextMenu = $currentContextMenu; 221 | } else { 222 | $currentContextMenu = $contextMenu; 223 | $contextMenu.addClass('angular-bootstrap-contextmenu dropdown clearfix'); 224 | } 225 | if (customClass) { 226 | $contextMenu.addClass(customClass); 227 | } 228 | var $ul = $('