diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34a76cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +builds/ +dist/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..09c83b3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2017 Belltown + +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: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9edbba --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +

![](https://github.com/belltown/violetbug/blob/master/doc/icon.png) VioletBug — Roku Debugger Graphical Interface

+ +VioletBug is a cross-platform desktop application providing a graphical interface to the Roku Debugger as an alternative to Telnet. It is similar to PurpleBug, https://belltown-roku.tk/PurpleBug, which is still supported; however, PurpleBug only runs on Windows PCs, and is closed-source. VioletBug, in contrast, is open-source running under [Electron](http://electron.atom.io/) and [Node.js](https://nodejs.org), written entirely in HTML, CSS and JavaScript. The source code can be found on [GitHub](https://github.com/belltown/violetbug). + +## Features + +* Runs under Windows (7+), macOS (10.9+), and linux +* Automatic discovery of Rokus on the local network +* A drop-down menu of well-known Roku ports +* Separate tabs for each Roku/port connection +* Floating tabs (right-click the tab header) +* Session logging for each tab +* Clear screen/clear line +* Configurable foreground/background colors +* Configurable font settings (9 monospace fonts included) +* Auto scroll, auto wrap +* Really large window buffers +* Really large command history buffer +* Command-line editing using arrow and paging keys +* Command-line completion (tab and shift-tab) +* User-configurable shortcut keys + +## Installation + +### macOS Installation + +* Download `violetbug-mac.zip` from https://github.com/belltown/violetbug/releases/latest +* Click the downloaded file to unzip +* Move it anywhere; click to run +* You may receive an *unidentified developer* warning. Open the file anyway (you may need to right-click the app, then select Open). For further information, click on the question-mark in the dialog box for Apple's help, or consult https://support.apple.com/kb/PH21769?locale=en_US + +### Windows Installation + +VioletBug can be downloaded on Windows using either an automatic "One-Click" NSIS installer, or by manual installation. If one method doesn't work, try the other. + +###### Automatic Installation + +* Download `VioletBug Setup x.y.z.exe` from https://github.com/belltown/violetbug/releases/latest +* Run the downloaded file to install VioletBug. It may appear as if nothing's happening for a minute or so, depending on how your computer is configured. Be patient and the installation should complete, then VioletBug will launch +* Pin the VioletBug icon to the Taskbar for convenient access, or use the desktop shortcut icon +* You may have to disable your anti-virus protection (unless using only Windows Defender), before downloading and running the installer +* If Windows *SmartScreen* appears (twice), click `More info`, then `Run anyway` + +###### Manual Installation + +* Download `violetbug-win.zip` from https://github.com/belltown/violetbug/releases/latest +* Extract `violetbug-win-zip` to `C:\Program Files` (Explorer, right-click, Extract All ...) +* Click on the `violetbug.exe` executable in `C:\Program Files\violetbug-win32-x64` to run VioletBug +* Click `Allow access` if prompted to allow firewall access +* Pin the icon to the Taskbar for convenience, or update the Path variable to run from the command line + +### Linux Installation + +Compiled binaries and installers for various linux distributions are provided at https://github.com/belltown/violetbug/releases/latest + +###### To download compiled binaries for most linux distributions + +Download `violetbug-linux.zip` from https://github.com/belltown/violetbug/releases/latest, e.g: +``` +cd ~/Downloads +wget https://github.com/belltown/violetbug/releases/download/vx.y.z/violetbug.zip +``` + +Unzip to the appropriate folder, e.g. `/opt` +``` +sudo unzip -o -q violetbug.zip -d /opt +``` + +Run the application: +``` + /opt/violetbug-linux-x64/violetbug +``` + +Attach to the Launcher for convenience. If you don't see the application icon, try copying the file `/opt/violetbug-linux-x64/resources/app/violetbug.desktop` to `~/.local/share/applications/violetbug.desktop`, editing it if necessary for the correct path name/icon file, etc. + +###### To download installers for specific linux distributions + +Download one of the following installers: + +- deb package (Ubuntu/Mint/Debian, etc) +- rpm package (Fedora/CentOS/Red Hat, etc) +- freebsd package (FreeBSD) +- AppImage package file (multiple linux distributions) + +AppImage files should run on any linux distribution that supports [AppImage](http://appimage.org/): + +* Set the file to be executable, e.g: `chmod u+x violetbug*.AppImage` +* Run the file, e.g: `./violetbug*.AppImage` +* All required dependencies and resources are contained in the `.AppImage` file. Nothing else gets installed. Run the file from any location; delete it to uninstall. + +###### Linux dependency issues + +If you get this error on linux: **error while loading shared libraries: libXss.so.1: cannot open shared object file: No such file or directory**, then install libXScrnSaver, e.g: `sudo yum install libXScrnSaver`. + + +### Firewall Configuration + +You may need to configure your firewall for automatic device discovery, particularly when using linux. VioletBug listens for SSDP M-SEARCH and NOTIFY responses. + +On Fedora or CentOS, for example, use the following commands to configure the firewall: + +``` +sudo firewall-cmd --permanent --add-port=1900/udp +sudo firewall-cmd --permanent --add-port=32768-61000/udp +sudo firewall-cmd --reload +``` + +On Ubuntu, the firewall is typically disabled by default. However, if enabled then all, some, or none, of the following incoming and/or outgoing rules may be required depending on how the firewall is set up: + +``` +### Incoming rules + +# SSDP NOTIFY responses +sudo ufw allow in from 192.168.0.0/24 to any port 1900 proto udp + +# SSDP M-SEARCH responses +sudo ufw allow in from 192.168.0.0/24 port 1900 proto udp + +# ECP & Debug responses +sudo ufw allow in from 192.168.0.0/24 port 8060:8093 proto tcp + +### Outgoing Rules + +# SSDP M-SEARCH requests +sudo ufw allow out to 239.255.255.250 port 1900 proto udp + +# ECP & Debug requests +sudo ufw allow out to 192.168.0.0/24 port 8060:8093 proto tcp + +### Reload the new firewall configuration +sudo ufw reload +``` + +## Updates + +Automatic updates are not supported. Check the project's Releases page, https://github.com/belltown/violetbug/releases for updates. + +Install a new update just like you did the original. All configuration settings should be saved. + +## Build Instructions + +To build your own version of VioletBug from the source code, see [Build Instructions](https://github.com/belltown/violetbug/blob/master/doc/BUILD.md). + +## Support + +Contact [belltown](https://forums.roku.com/ucp.php?i=pm&mode=compose&u=37784) through the [Roku Forums](https://forums.roku.com/viewforum.php?f=34) + +File a GitHub issue at https://github.com/belltown/violetbug/issues + +## Screenshots + +![Rokus](https://github.com/belltown/violetbug/blob/master/doc/ScreenShotRokus.png) + +![Connections](https://github.com/belltown/violetbug/blob/master/doc/ScreenShotConn.png) diff --git a/doc/BUILD.md b/doc/BUILD.md new file mode 100644 index 0000000..2d24c39 --- /dev/null +++ b/doc/BUILD.md @@ -0,0 +1,51 @@ +## Build Instructions + +- These build instructions are only necessary if you wish to build your own version of VioletBug after changing the source code +- All builds are for 64-bit systems +- Building is controlled by the source/package.json file + +#### Build setup + +- Install node.js and npm by following the instructions on https://nodejs.org +- Change into the directory that will contain the violetbug project directory: `cd violetbug` +- Clone the GitHub repository: `git clone https://github.com/belltown/violetbug` +- Change into the `source` directory: `cd violetbug/source` +- Install the npm packages: `npm install` [Note that the development dependency versions listed in package.json are specified as fixed version numbers. Change these if you want more up-to-date versions] +- Run violetbug: `npm start` + +#### Building from source (only required if generating binary zip files) + +*All builds must be run from the `violetbug/source` directory.* + +||| +|---|--- +|`npm run build-mac` | Run on **linux or macOS** to generate `/builds/mac` +| `npm run build-linux` | Run on **linux or macOS** to generate `/builds/linux` +| `npm run build-win` | Run on **Windows** to generate `/builds/win` + +#### Generating binary zip files (run on linux or macOS - not on Windows) + +*All zips must be run from the `violetbug/source` directory, and require the corresponding build step above to have been run.* + +||| +|---|--- +| `npm run zip-mac` | To generate `/dist/violetbug-mac.zip` +| `npm run zip-linux` | To generate `/dist/violetbug-linux.zip` +| `npm run zip-win` | To generate `/dist/violetbug-win.zip` + +#### Generating installers (does not require build from source or binary zip files) + +*Ideally, generate the installer from the target system you are generating the installer for.* + +There is no installer implemented for macOS. For macOS, use the build/zip mechanism above. + +The following commands are available to generate installers in `/dist`: + +|| +|--- +| `npm run dist-appimage` +| `npm run dist-deb` +| `npm run dist-rpm` +| `npm run dist-freebsd` +| `npm run dist-tarxz` +| `npm run dist-win` diff --git a/doc/README.md.html b/doc/README.md.html new file mode 100644 index 0000000..39191ef --- /dev/null +++ b/doc/README.md.html @@ -0,0 +1,572 @@ + + + + + VioletBug + + +

VioletBug — Roku Debugger Graphical Interface

+ +

VioletBug is a cross-platform desktop application providing a graphical interface to the Roku Debugger as an alternative to Telnet. It is similar to PurpleBug, https://belltown-roku.tk/PurpleBug, which is still supported; however, PurpleBug only runs on Windows PCs, and is closed-source. VioletBug, in contrast, is open-source running under Electron and Node.js, written entirely in HTML, CSS and JavaScript. The source code can be found on GitHub.

+

Features

+ +

Installation

+

macOS Installation

+ +

Windows Installation

+

VioletBug can be downloaded on Windows using either an automatic "One-Click" NSIS installer, or by manual installation. If one method doesn't work, try the other.

+
Automatic Installation
+ +
Manual Installation
+ +

Linux Installation

+

Compiled binaries and installers for various linux distributions are provided at https://github.com/belltown/violetbug/releases/latest

+
To download compiled binaries for most linux distributions
+

Download violetbug-linux.zip from https://github.com/belltown/violetbug/releases/latest, e.g:

+
cd ~/Downloads
wget https://github.com/belltown/violetbug/releases/download/vx.y.z/violetbug.zip

Unzip to the appropriate folder, e.g. /opt

+
sudo unzip -o -q violetbug.zip -d /opt

Run the application:

+
 /opt/violetbug-linux-x64/violetbug

Attach to the Launcher for convenience. If you don't see the application icon, try copying the file /opt/violetbug-linux-x64/resources/app/violetbug.desktop to ~/.local/share/applications/violetbug.desktop, editing it if necessary for the correct path name/icon file, etc.

+
To download installers for specific linux distributions
+

Download one of the following installers:

+ +

AppImage files should run on any linux distribution that supports AppImage:

+ +
Linux dependency issues
+

If you get this error on linux: error while loading shared libraries: libXss.so.1: cannot open shared object file: No such file or directory, then install libXScrnSaver, e.g: sudo yum install libXScrnSaver.

+

Firewall Configuration

+

You may need to configure your firewall for automatic device discovery, particularly when using linux. VioletBug listens for SSDP M-SEARCH and NOTIFY responses.

+

On Fedora or CentOS, for example, use the following commands to configure the firewall:

+
sudo firewall-cmd --permanent --add-port=1900/udp
sudo firewall-cmd --permanent --add-port=32768-61000/udp
sudo firewall-cmd --reload

On Ubuntu, the firewall is typically disabled by default. However, if enabled then all, some, or none, of the following incoming and/or outgoing rules may be required depending on how the firewall is set up:

+
### Incoming rules
 
# SSDP NOTIFY responses
sudo ufw allow in from 192.168.0.0/24 to any port 1900 proto udp
 
# SSDP M-SEARCH responses
sudo ufw allow in from 192.168.0.0/24 port 1900 proto udp
 
# ECP & Debug responses
sudo ufw allow in from 192.168.0.0/24 port 8060:8093 proto tcp
 
### Outgoing Rules
 
# SSDP M-SEARCH requests
sudo ufw allow out to 239.255.255.250 port 1900 proto udp
 
# ECP & Debug requests
sudo ufw allow out to 192.168.0.0/24 port 8060:8093 proto tcp
 
### Reload the new firewall configuration
sudo ufw reload

Updates

+

Automatic updates are not supported. Check the project's Releases page, https://github.com/belltown/violetbug/releases for updates.

+

Install a new update just like you did the original. All configuration settings should be saved.

+

Build Instructions

+

To build your own version of VioletBug from the source code, see Build Instructions.

+

Support

+

Contact belltown through the Roku Forums

+

File a GitHub issue at https://github.com/belltown/violetbug/issues

+

Screenshots

+

Rokus

+

Connections

+ diff --git a/doc/ScreenShotConn.png b/doc/ScreenShotConn.png new file mode 100644 index 0000000..0c79bec Binary files /dev/null and b/doc/ScreenShotConn.png differ diff --git a/doc/ScreenShotRokus.png b/doc/ScreenShotRokus.png new file mode 100644 index 0000000..77135d2 Binary files /dev/null and b/doc/ScreenShotRokus.png differ diff --git a/doc/icon.png b/doc/icon.png new file mode 100644 index 0000000..e3c39cd Binary files /dev/null and b/doc/icon.png differ diff --git a/source/.gitignore b/source/.gitignore new file mode 100644 index 0000000..bca51b3 --- /dev/null +++ b/source/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/source/about.html b/source/about.html new file mode 100644 index 0000000..1b60a05 --- /dev/null +++ b/source/about.html @@ -0,0 +1,32 @@ + + + + + About VioletBug + + + +

VioletBug

+

Version 1.0.0

+

Copyright © 2017 Belltown

+ + + diff --git a/source/colors.css b/source/colors.css new file mode 100644 index 0000000..ba9da53 --- /dev/null +++ b/source/colors.css @@ -0,0 +1,227 @@ +@import './fontList.css'; + +body, input, select, option, button { + font-family: 'Roboto', sans-serif; +} + +select, button, input { + font-size: 1rem; /* Make same size as root font */ +} + +html { + height: 100vh; /* Document occupies full viewport height */ +} + +body { + margin: 0; /* No space around the edge of the body canvas */ + height: 100%; /* So we can vertically center content in body */ + display: flex; /* To make it easy to center content in page */ + flex-direction: column; /* To put buttons below color selection */ + align-items: center; /* Center content along cross axis (column) */ + justify-content: center; /* Center content along main axis (row) */ + min-width: max-content; /* Allow resized window to contain all content */ +} + +/* Everything is contained within the content panel */ +#contentPanel { + display: grid; + grid-template-columns: auto auto; + grid-template-rows: auto auto; +} + +/* Foreground and background radio buttons */ +#fgbgSelect { + grid-row: 1; + grid-column: 1; + align-self: center; + padding: 0 2rem 0 0; +} + +#fgbgSelect input { + margin: 0; +} + +/* Vertical space between fg and bg buttons */ +#fgbgSelect > div { + padding: 0.5rem 0; +} + +fieldset { + border: 1px solid #777; + border-radius: 0.5rem; + padding: 0 1rem; + margin: 0; +} + +legend { + color: #777; + background-color: white; + cursor: default; +} + +#colorSelectionGroup { + grid-row: 1; + grid-column: 2; +} + +#colorSelectionPanel { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 1rem; +} + +#sliders { + display: grid; + grid-template-columns: auto auto auto; + grid-row-gap: 0.5rem; + grid-column-gap: 1rem; +} + +#sliders input[type=text] { + width: 3ch; + height: 1em; + padding: 0.2em; + border: 1px solid #777; +} + +#sliders input[type=range] { + width: 10rem; +} + +/* Dropdown list of preset color values */ +#presets { + width: 80%; + margin-top: 1.5rem; + cursor: default; +} + +#presets ul { + list-style-type: none; /* Get rid of default list bullets */ + width: 100%; + padding: 0; + margin: 0; +} + +#presets li { + display: flex; + align-items: center; + line-height: 1.5em; + padding-left: 5px; +} + +#presets li:hover { + background-color: #DDECF4; +} + +/* List header simulates a dropdown box */ +#listHead { + display: flex; + flex-direction: column; + justify-content: center; + border: 1px solid black; + height: 1.5em; +} + +#listHeadPredefined { + display: list-item; +} + +/* "Predefined Colors ..." text */ +#listHeadText { + display: flex; + width: 100%; +} + +#listHeadColor { + display: none; + align-items: center; + width: 100%; +} + +#listHeadColor > div { + margin-left: 0; +} + +/* Dropdown caret */ +/* U+25BC : Black Down-Pointing Triangle, or */ +/* U+2228 : Logical OR */ +#dropdownCaret::before { + content: '\2228'; +} + +/* Right-align the dropdown caret */ +#dropdownCaret { + margin-left: auto; + font-weight: bold; + font-size: 0.64em; + padding-right: 0.64em; + color: #777; +} + +/* Dropdown positioning is relative to the bottom of the listHead */ +#listBodyPanel { + position: relative; +} + +/* Dropdown list displayed by JavaScript when listHead is clicked */ +#listBody { + display: none; /* JavaScript will set display: block */ + position: absolute; + width: 100%; + background-color: white; + height: 12.0em; + overflow-x: hidden; /* Truncate horizontally */ + overflow-y: auto; /* Vertical scroll bar if necessary */ + border: solid black 1px; + border-top: none; +} + +/* Display a color box before the start of the list item text */ +#listBody li > div, #listHeadColor > div { + display: inline-block; /* Color box appears on same line as text */ + margin: 0 0.5ch 0 0.25ch; + height: 1.25em; + width: 25%; +} + +/* Foreground/background color preview pane */ +#preview { + width: 80%; + margin: 0 auto; + margin-top: 1.5rem; + margin-bottom: 1.5rem; +} + +/* JavaScript sets 'color' to the foreground color */ +/* JavaScript sets 'background-color' and 'border' to the background color */ +#previewText { + width: 100%; + margin: 0; + font-size: 1.5rem; + text-align: center; + padding-top: 2rem; + padding-bottom: 2rem; + color: #00FF00; + border: 1px solid #000000; + background-color: #000000; +} + +/* Button panel contains OK and Cancel buttons */ +#buttonPanel { + grid-row: 2; + grid-column: 2; + margin-top: 1.5rem; + text-align: center; +} + +/* Make each button the same size */ +#buttonPanel button { + width: 12ch; + line-height: 1.5rem; + margin: 0.5rem; +} + +#buttonPanel button:hover { + background-color: #DDECF4; +} diff --git a/source/colors.html b/source/colors.html new file mode 100644 index 0000000..dcbc23d --- /dev/null +++ b/source/colors.html @@ -0,0 +1,90 @@ + + + + + + + Colors + + +
+
+
+ + +
+
+ + +
+
+
+ Color Selection +
+ +
+ + + + + + + + + + + + +
+ +
+ +
    + +
  • +
    +
    Predefined Colors ...
    +
    +
    dummy +
    + +
    +
  • +
+ +
+
    +
  • Predefined Colors ...
  • + +
  • black
  • +
  • silver
  • +
  • gray
  • +
  • white
  • +
  • maroon
  • +
  • red
  • +
  • purple
  • +
  • fuchsia
  • +
  • green
  • +
  • lime
  • +
  • olive
  • +
  • yellow
  • +
  • navy
  • +
  • blue
  • +
  • teal
  • +
  • aqua
  • +
+
+
+ +
+

Foreground Text

+
+
+
+
+ + +
+
+ + diff --git a/source/colors.js b/source/colors.js new file mode 100644 index 0000000..2abe8d0 --- /dev/null +++ b/source/colors.js @@ -0,0 +1,333 @@ +'use strict'; +{ // Enclosing block to keep function definitions out of global namespace + + // + // Main menu color selection settings dialog + // Invoked from VBMenu.js + // + + // Electron modules + const {remote, ipcRenderer} = require('electron') + + // Local modules + const VBConfig = require('./lib/VBConfig') + + // Declare DOM shortcuts; assign in onLoad(), called when the DOM is loaded + let fgSelect + let bgSelect + let rSlider + let gSlider + let bSlider + let rValue + let gValue + let bValue + let listHead + let listHeadColor + let listHeadPredefined + let listBody + let predefinedText + let previewText + let ok + let cancel + + // Read config data (to get FG and BG colors) + // This colors module only reads the config data, never writing directly + // When the FG or BG color is changed, an IPC message is sent to + // the Main Process using ipcRenderer.send() + // The Main Process will in turn send IPCs to the Renderer Processes + // so they can update the colors for any displayed connection tabs or + // floating tabs + const vbConfig = new VBConfig() + const config = vbConfig.init() + + // True if the Foreground radio button is selected, else false + let fg = true + + // Keep track of the current FG and BG colors + let fgColorString = config.foregroundColor + let bgColorString = config.backgroundColor + + // Save the initial colors in case the user clicks the Cancel button + let fgColorStringSave = fgColorString + let bgColorStringSave = bgColorString + + // No need to release any resources - closing the window takes care of that + function closeWindow() { + remote.getCurrentWindow().close() + } + + // Send an IPC to the main process for any change in FG/BG color + function sendFGChange(color) { + ipcRenderer.send('fgColorChanged', color) + } + + function sendBGChange(color) { + ipcRenderer.send('bgColorChanged', color) + } + + // Instruct the Main Process to flush its config data to disk + // Do this when the OK button is pressed so that any subsequent + // connection tabs will get the up-to-date colors when they read + // the config file during their initialization + function sendCommitColors() { + ipcRenderer.send('commitColors') + } + + // Convert between color byte values (0-255) and hex pairs (00-FF) + function byteToHex(byte) { + const hex = parseInt(byte, 10).toString(16) + return hex.length === 1 ? '0' + hex : hex + } + + function hexToByte(hex) { + let value = parseInt(hex, 16) + return Number.isNaN(value) ? 0 : value + } + + // Color strings are in the form "#RRGGBB" (hex pairs) + function rgbToHexString(r, g, b) { + return '#' + byteToHex(r) + byteToHex(g) + byteToHex(b) + } + + function hexStringToRGB(hexString) { + return { + r: hexToByte(hexString.substring(1, 3)), + g: hexToByte(hexString.substring(3, 5)), + b: hexToByte(hexString.substring(5, 7)) + } + } + + // We do try to prevent the user from entering non-numeric color values + // in onValueKeypress(); however, there's nothing to stop them pasting in + // an invalid value from the clipboard, or entering a value outside + // the valid byte range (0-255) + function validateRGB(value) { + let parsed = parseInt(value, 10) + if (Number.isNaN(parsed) || parsed < 0) { + parsed = 0 + } + else if (parsed > 255) { + parsed = 255 + } + return parsed + } + + // Set all 3 color sliders and values based on the current color strings + function setSliders() { + let rgb = null + if (fg) { + rgb = hexStringToRGB(fgColorString) + } + else { + rgb = hexStringToRGB(bgColorString) + } + rSlider.value = rValue.value = rgb.r + gSlider.value = gValue.value = rgb.g + bSlider.value = bValue.value = rgb.b + } + + // Set the foreground and background colors in the preview pane + function setFGColor(color) { + previewText.style.color = color + } + + function setBGColor(color) { + previewText.style.backgroundColor = color + } + + // Depending on whether the Foreground or Background radio button + // is selected, update the FG or BG color, and notify the Main Process + // of the change + function updateColor(r, g, b) { + if (fg) { + fgColorString = rgbToHexString(r, g, b) + setFGColor(fgColorString) + sendFGChange(fgColorString) + } + else { + bgColorString = rgbToHexString(r, g, b) + setBGColor(bgColorString) + sendBGChange(bgColorString) + } + } + + // Reset the color dropdown box any time the foreground or background + // radio button is pressed + function resetListHeader() { + predefinedText.style.display = 'block' + listHeadColor.style.display = 'none' + } + + // When the user clicks on one of the list items in the predefined + // colors list, place the selected color in the dropdown list header + function setListHeader(li) { + predefinedText.style.display = 'none' + listHeadColor.style.display = 'flex' + + // Remove existing children of #listHeadColor + // Death to the children + while (listHeadColor.firstChild) { + listHeadColor.removeChild(listHeadColor.firstChild) + } + + // Clone the selected list item, appending to #listHeadColor + // Long live the clones + const clone = li.cloneNode(true) + while (clone.hasChildNodes()) { + listHeadColor.appendChild(clone.removeChild(clone.firstChild)) + } + } + + // Click-handler for the foreground/background radio buttons + function onFGBGClick(e) { + fg = fgSelect.checked + setSliders() + resetListHeader() + } + + // Click-handler for the entire document + function onDocumentClick(e) { + // Hide the predefined styles list body + listBody.style.display = 'none' + } + + // Click-handler for the predefined styles list header + function onListHeadClick(e) { + // Prevent the document click-handler from receiving this click event + e.stopPropagation() + + // Toggle display/hide of the predefined styles list body + const style = listBody.style + style.display = style.display === 'block' ? 'none' : 'block' + } + + // Click-handler for the predefined styles list body + function onListBodyClick(e) { + // Find the list item that was clicked + const li = e.target.closest('LI') + if (li) { + // The list item's first DIV (if any) contains the item's color value + const div = li.querySelector('DIV') + if (div) { + // Extract the background-color inline style for the item's color + const color = div.style.backgroundColor + // Color values are returned as a string, e.g. "rgb(128, 255, 255)" + const m = /(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(color) + if (Array.isArray(m) && m.length === 4) { + // Update the foreground or background color value, and preview pane + updateColor(m[1], m[2], m[3]) + // Set the color sliders and values + setSliders() + } + } + // Put the selected color in the dropdown list header box + setListHeader(li) + } + } + + // When one of the color sliders is changed, update the FG/BG color + // and the associated color textbox + function onSliderChange(e) { + switch (e.target) { + case rSlider: + rValue.value = rSlider.value + break; + case gSlider: + gValue.value = gSlider.value + break; + case bSlider: + bValue.value = bSlider.value + break; + } + updateColor(rSlider.value, gSlider.value, bSlider.value) + } + + // Only allow digits in the input textbox + function onValueKeypress(e) { + if (e.key < "0" || e.key > "9") { + e.preventDefault() + return false + } + else { + return true + } + } + + // When one of the color textbox values changes, update the FG/BG color, + // and reposition the associated color slider + function onValueChange(e) { + switch (e.target) { + case rValue: + rSlider.value = rValue.value = validateRGB(rValue.value) + break; + case gValue: + gSlider.value = gValue.value = validateRGB(gValue.value) + break; + case bValue: + bSlider.value = bValue.value = validateRGB(bValue.value) + break; + } + updateColor(rSlider.value, gSlider.value, bSlider.value) + } + + // Event-handler for the OK button + function onOKClick(e) { + sendCommitColors() + closeWindow() + } + + // Event-handler for Cancel button + function onCancelClick(e) { + sendFGChange(fgColorStringSave) + sendBGChange(bgColorStringSave) + closeWindow() + } + + // Document load event-handler + function onLoad() { + // Assign DOM element shortcuts + fgSelect = document.getElementById('fgSelect') + bgSelect = document.getElementById('bgSelect') + rSlider = document.getElementById('r-slider') + gSlider = document.getElementById('g-slider') + bSlider = document.getElementById('b-slider') + rValue = document.getElementById('r-value') + gValue = document.getElementById('g-value') + bValue = document.getElementById('b-value') + listHead = document.getElementById('listHead') + listHeadColor = document.getElementById('listHeadColor') + listHeadPredefined = document.getElementById('listHeadPredefined') + listBody = document.getElementById('listBody') + predefinedText = document.getElementById('predefinedText') + previewText = document.getElementById('previewText') + ok = document.getElementById('okButton') + cancel = document.getElementById('cancelButton') + + // Register event-handlers + document .addEventListener('click', onDocumentClick) + fgSelect .addEventListener('click', onFGBGClick) + bgSelect .addEventListener('click', onFGBGClick) + rSlider .addEventListener('input', onSliderChange) + gSlider .addEventListener('input', onSliderChange) + bSlider .addEventListener('input', onSliderChange) + rValue .addEventListener('keypress', onValueKeypress) + gValue .addEventListener('keypress', onValueKeypress) + bValue .addEventListener('keypress', onValueKeypress) + rValue .addEventListener('change', onValueChange) + gValue .addEventListener('change', onValueChange) + bValue .addEventListener('change', onValueChange) + listHead .addEventListener('click', onListHeadClick) + listBody .addEventListener('click', onListBodyClick) + ok .addEventListener('click', onOKClick) + cancel .addEventListener('click', onCancelClick) + + // Set initial color values and sliders + setSliders() + setFGColor(fgColorString) + setBGColor(bgColorString) + } + + // Register the document load event-handler + addEventListener('load', onLoad) + +} diff --git a/source/float.css b/source/float.css new file mode 100644 index 0000000..49cc663 --- /dev/null +++ b/source/float.css @@ -0,0 +1,185 @@ +/* NOTE: THIS FILE WAS CREATED BY COPYING index.css + AND REMOVING PARTS THAT DON'T APPLY !!! */ + +@import './fontList.css'; + +html { + height: 100vh; /* Document occupies full viewport height */ +} + +body { + /* The List page uses this font family and size */ + /* The Connection pages' font families and sizes are configurable */ + font-family: 'Roboto', sans-serif; + font-size: 1rem; + + height: 100%; /* Body occupies the entire document height */ + margin: 0; /* Remove browser default margin from the body */ + cursor: default; /* Default to a pointer cursor */ + -webkit-user-select: none; /* Prevent unwanted selection highlighting */ +} + +select, button, input, option, pre { + font: inherit; /* Inherit all font properties from root font */ + color: currentColor; +} + +/* table component resets */ +table, caption, tbody, tfoot, thead, th, tr, td { + table-layout: fixed; + font-size: 100%; + font: inherit; + margin: 0; + border: 0; + padding: 0; + vertical-align: middle; +} + +/* table element reset */ +table { + border-collapse: collapse; +} + +/* input reset */ +input { + font: inherit; /* Inherits font-family AND font-size */ + color: inherit; + outline: none; + border: none; + padding: 0; +} + +.fullHeight { + height: 100%; +} + +/*****************************************************************************/ +/* #mainContainer contains everything on the page: header + content */ +/*****************************************************************************/ + +#mainContainer { + background-color: #1e2026; /* Same as BrowserWindow backgroundColor */ + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; +} + +/*****************************************************************************/ +/* Custom scrollbars */ +/*****************************************************************************/ + +::-webkit-scrollbar { + width: 10px; +} + +::-webkit-scrollbar:horizontal { + height: 10px; +} + +::-webkit-scrollbar-corner { + visibility: hidden; +} + +::-webkit-scrollbar-thumb { + background-color: #666; + border-radius: 4px; +} + +/********************************************************************/ +/* #tabContentContainer contains everything below the tab header, */ +/* and includes the device list page and device connection pages. */ +/********************************************************************/ + +#tabContentContainer { + /* Initially, no space is allocated for this item (flex-basis: 0) + #tabHeaderContainer initially occupies its content size + (its flex-basis is set to auto) + Then all remaining space is allocated to this item (flex-grow: 1) */ + flex: auto; + display: flex; + flex-direction: row; +} + +/*******************************************************/ +/* START OF CODE TO REMOVE WHEN CREATING float.css vvv */ +/*******************************************************/ + +/*******************************************************/ +/* ^^^ END OF CODE TO REMOVE WHEN CREATING float.css */ +/*******************************************************/ + +/*******************************************************************/ +/* The panel that encloses a device connection tab */ +/*******************************************************************/ + +/* + NOTE: ANY CHANGES HERE NEED TO BE MADE IN float.css ALSO !!!!! +*/ + +#connContainer { + flex: 1 1 100%; /* Expand to fill parent container */ + display: none; /* None or block */ + font-family: monospace; /* User-configurable */ + font-size: 1rem; /* User-configurable */ + background-color: black; /* User-configurable */ + color: #00DD00; /* User-configurable */ + -webkit-user-select: text; /* Override list page's 'none' setting */ +} + +#connContainer input { + background-color: inherit; +} + +.connPage { + display: none; /* none or table */ + height: 100%; +} + +.deviceOutputPanelScroll { + height: 100%; + padding: 0 3px; + overflow: auto; +} + +.deviceOutputPanel { + width: 100%; + color: inherit; + background-color: inherit; + word-break: break-all; + white-space: pre-wrap; /* pre-wrap => no horizontal scrollbars */ + /* pre => horizontal scrollbars */ +} + +.deviceInputPanel { + width: 100%; + color: inherit; + background-color: inherit; +} + +.faintBorder { + height: 1px; + opacity: 0.8; + background-color: currentcolor; +} + +.promptPanel { + width: 1.4ch; /* ">" prompt + right padding */ + -webkit-user-select: none; +} + +.promptPanel input { + -webkit-user-select: none; +} + +pre { + margin: 0; + display: inline; + white-space: inherit; /* not inherited by default for
 elements */
+}
+
+.deviceInputText {
+  width: 100%;
+  cursor: text;
+  padding: 3px 0;
+}
diff --git a/source/float.html b/source/float.html
new file mode 100644
index 0000000..c16aea2
--- /dev/null
+++ b/source/float.html
@@ -0,0 +1,75 @@
+
+
+  
+    
+    
+    
+    
+  
+  
+
+    
+    
+ + +
+ + + +
+ + + + + + + + +
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + +
+ +
+
+
+
+ + + +
+
+
+ +
+
+ + diff --git a/source/float.js b/source/float.js new file mode 100644 index 0000000..a847a3d --- /dev/null +++ b/source/float.js @@ -0,0 +1,28 @@ +'use strict'; +{ // <= Enclosing block to keep function names out of global namespace + + // Electron modules + const {remote} = require('electron') + + // Local modules + const VBFloatPanel = require('./lib/VBFloatPanel') + + // UI handler for the main dock, to be set up in the 'load' event handler + let vbFloatPanel = null + + // Event-handler that gets executed when the DOM is fully loaded + function onLoad() { + // Extract the ip address and port from the native window title + const ipAndPort = remote.getCurrentWindow().getTitle() + const ip = ipAndPort.substr(0, ipAndPort.indexOf(':')) + const port = parseInt(ipAndPort.substring(ipAndPort.indexOf(':') + 1), 10) + + // Create the floating dock panel that will hold the list panel and + // docked connection panels, etc. + vbFloatPanel = new VBFloatPanel(ip, port) + vbFloatPanel.init() + } + + // Run onload() when the document is fully loaded + addEventListener('load', onLoad) +} diff --git a/source/fontList.css b/source/fontList.css new file mode 100644 index 0000000..998a4ce --- /dev/null +++ b/source/fontList.css @@ -0,0 +1,62 @@ +/****************************************************************************/ +/* Sans Serif Fonts (non-configurable) */ +/****************************************************************************/ + +@font-face { + font-family: 'Segoe UI'; + src: local('Segoe UI'), url('./fonts/sans/Segoe UI/segoeui.ttf'); +} + +@font-face { + font-family: 'Roboto'; + src: local('Roboto'), url('./fonts/sans/Roboto/Roboto-Regular.ttf'); +} + +/****************************************************************************/ +/* Monospace Fonts (user-configurable) */ +/****************************************************************************/ + +@font-face { + font-family: 'Consolas'; + src: local('Consolas'), url('./fonts/mono/Consolas/consola.ttf'); +} + +@font-face { + font-family: 'Courier New'; + src: local('Courier New'), url('./fonts/mono/Courier New/cour.ttf'); +} + +@font-face { + font-family: 'Fira Mono'; + src: local('Fira Mono'), url('./fonts/mono/Fira Mono/FiraMono-Regular.ttf'); +} + +@font-face { + font-family: 'Inconsolata'; + src: url('./fonts/mono/Inconsolata/Inconsolata-Regular.ttf'); +} + +@font-face { + font-family: 'InputMono'; + src: url('./fonts/mono/InputMono/InputMono-Regular.ttf'); +} + +@font-face { + font-family: 'Liberation Mono'; + src: url('./fonts/mono/Liberation Mono/LiberationMono-Regular.ttf'); +} + +@font-face { + font-family: 'Lucida Console'; + src: url('./fonts/mono/Lucida Console/lucon.ttf'); +} + +@font-face { + font-family: 'Meslo LG M'; + src: url('./fonts/mono/Meslo LG M/MesloLGM-Regular.ttf'); +} + +@font-face { + font-family: 'Source Code Pro'; + src: url('./fonts/mono/Source Code Pro/SourceCodePro-Regular.ttf'); +} diff --git a/source/fonts.css b/source/fonts.css new file mode 100644 index 0000000..1f2ec10 --- /dev/null +++ b/source/fonts.css @@ -0,0 +1,120 @@ +@import './fontList.css'; + +body { + font-family: 'Roboto', sans-serif; +} + +html { + height: 100vh; /* Document occupies full viewport height */ +} + +select, button, input, option { + font: inherit; /* Inherit all font properties from root font */ +} + +body { + margin: 0; /* No space around the edge of the body canvas */ + height: 100%; /* So we can vertically center content in body */ + display: flex; /* To make it easy to center content in page */ + justify-content: center; /* Center content horizontally within body */ + align-items: center; /* Center content vertically within body */ + min-width: fit-content; /* Avoid disappearing content on resize */ + background-color: #F3F3F3; /* Configure page color if needed */ +} + +#contentPanel { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto auto; + padding: 0 1rem; + border: 2px solid #009900; + border-radius: 0.5rem; + background-color: #F3F3F3; /* Configure content panel color if needed */ +} + +#familyPanel { + grid-column: 1; grid-row: 1; +} + +#sizePanel { + grid-column: 2; grid-row: 1; + justify-self: end; +} + +#previewPanel { + grid-column: 1 / span 2; grid-row: 2; +} + +#buttonPanel { + grid-column: 1 / span 2; grid-row: 3; + justify-self: center; +} + +#familyPanel p, #sizePanel p { + color: #888; +} + +ul { + background-color: #E8E8E8; + border: 1px solid #bbb; + border-radius: 0.2rem; + list-style-type: none; + overflow-x: hidden; + overflow-y: auto; + cursor: default; + padding: 0; + height: min-content; + max-height: calc(10 * 1.4rem); /* 10 x li's line-height */ + -webkit-user-select: none; /* Disable user-selection of text in lists */ +} + +li { + display: flex; /* Center list item text vertically */ + align-items: center; + border: 1px solid #E8E8E8; + padding: 0 0 0 0.5rem; + margin: 0; + box-sizing: border-box; + line-height: calc(1.4rem - 2px); + height: 1.4rem; + background-color: #E8E8E8; +} + +:focus { + border: 1px dotted brown !important; +} + +.selected { + background-color: #61c0ff !important; /* Selected color trumps hover */ + border: 1px solid #61c0ff !important; +} + +li:hover, button:hover { + background-color: #d3efff; +} + +#sizeList { + width: 17ch; +} + +#familyList { + margin-right: 2.5rem; +} + +#fontPreview { + text-align: center; + margin: 0; + height: 6rem; /* Must use rem, not em, values to keep box same size */ + /* when the font size is changed */ + line-height: 6rem; /* Needed in order to vertically center the text */ + background-color: black; + color: lime; + border: 2px solid lime; + border-radius: 0.5rem; + margin: 1rem 0; +} + +button { + width: 13ch; + margin: 1rem 0.4rem 2rem; +} diff --git a/source/fonts.html b/source/fonts.html new file mode 100644 index 0000000..ae67dc4 --- /dev/null +++ b/source/fonts.html @@ -0,0 +1,62 @@ + + + + + + + Fonts + + +
+
+

Family

+
    + +
  • monospace
  • +
  • Consolas
  • +
  • Courier New
  • +
  • Fira Mono
  • +
  • Inconsolata
  • +
  • InputMono
  • +
  • Liberation Mono
  • +
  • Lucida Console
  • +
  • Meslo LG M
  • +
  • Source Code Pro
  • +
+
+
+

Size

+
    +
  • 8 pt
  • +
  • 9 pt
  • +
  • 10 pt
  • +
  • 11 pt
  • +
  • 12 pt
  • +
  • 13 pt
  • +
  • 14 pt
  • +
  • 15 pt
  • +
  • 16 pt
  • +
  • 17 pt
  • +
  • 18 pt
  • +
  • 19 pt
  • +
  • 20 pt
  • +
  • 21 pt
  • +
  • 22 pt
  • +
  • 23 pt
  • +
  • 24 pt
  • +
+
+
+

Courier New

+
+
+ + + +
+
+ + diff --git a/source/fonts.js b/source/fonts.js new file mode 100644 index 0000000..b66b882 --- /dev/null +++ b/source/fonts.js @@ -0,0 +1,309 @@ +'use strict'; +{ // <= Enclosing block to keep function names out of global namespace + + // + // Main menu font selection settings dialog + // Invoked from VBMenu.js + // + + // Electron modules + const {remote, ipcRenderer} = require('electron') + + // Local modules + const VBKeys = require('./lib/VBKeys') + const VBConfig = require('./lib/VBConfig') + + // Read config data (to get current font family and size) + // The fonts module only READS the config data, never writing directly + // When the font is changed, an IPC message is sent to + // the Main Process using ipcRenderer.send() + // The Main Process will in turn send IPCs to the Renderer Processes + // so they can update the fonts for any displayed connection tabs or + // floating tabs + const vbConfig = new VBConfig() + const config = vbConfig.init() + + // Declare DOM shortcuts; assign in onLoad(), called when the DOM is loaded + let familyList + let sizeList + let fontPreview + let okButton + let defaultsButton + let cancelButton + + // Save the initial fonts in case the user clicks the Cancel button + let fontFamilySave = config.fontFamily + let fontSizeSave = config.fontSize + + // No need to release any resources - closing the window takes care of that + function closeWindow() { + remote.getCurrentWindow().close() + } + + // Send an IPC to the main process for any change in font family + function sendFontFamilyChange() { + ipcRenderer.send('fontFamilyChanged', config.fontFamily) + } + + // Send an IPC to the main process for any change in font size + function sendFontSizeChange() { + ipcRenderer.send('fontSizeChanged', config.fontSize) + } + + // Instruct the Main Process to flush its config data to disk + // Do this when the OK button is pressed so that any newly-created + // connection tabs will get the up-to-date fonts when they read + // the config file during their initialization + function sendCommitFonts() { + ipcRenderer.send('commitFonts') + } + + // Remove ',' monospace from font family + function fontFamilyToDisplay(fontFamily) { + return fontFamily.split(',')[0] + } + + // Add monospace as a default if chosen font cannot be used + function fontDisplayToFamily(fontFamilyDisplay) { + if (fontFamilyDisplay !== 'serif') { + return fontFamilyDisplay + ', serif' + } + else { + return fontFamilyDisplay + } + } + + // Convert an internal format font size ("10pt") to display format ("10 pt") + function fontSizeToDisplay(fontSize) { + const ma = /(\d+)\s*pt/.exec(fontSize) + return ma ? ma[1] + ' pt' : '10 pt' + } + + // Convert a font from display format ("10 pt") to internal format ("10pt") + function fontDisplayToSize(fontSizeDisplay) { + return fontSizeDisplay.replace(/\s*/g, '') + } + + // Set the font preview panel colors + function setPreviewColors() { + fontPreview.style.color = config.foregroundColor + fontPreview.style.borderColor = config.foregroundColor + fontPreview.style.backgroundColor = config.backgroundColor + } + + // One-time initialization of the font family list typefaces + function setFontFamilyNames() { + const nodeList = familyList.querySelectorAll('li') + for (let i = 0; i < nodeList.length; i++) { + const li = nodeList[i] + li.style.fontFamily = li.textContent + } + } + + // Set the font family in the preview pane + function setPreviewFontFamily() { + fontPreview.style.fontFamily = config.fontFamily + fontPreview.textContent = config.fontFamily + } + + // Set the font size in the preview pane + function setPreviewFontSize(e) { + fontPreview.style.fontSize = config.fontSize + } + + // Highlight the selected font family list item + function setSelectedFontFamily() { + const nodeList = familyList.querySelectorAll('li') + for (let i = 0; i < nodeList.length; i++) { + if (nodeList[i].textContent === config.fontFamily) { + nodeList[i].click() + nodeList[i].scrollIntoView() + break + } + } + } + + // Highlight the selected font size list item + function setSelectedFontSize() { + const fontSizeDisplay = fontSizeToDisplay(config.fontSize) + const nodeList = sizeList.querySelectorAll('li') + for (let i = 0; i < nodeList.length; i++) { + if (nodeList[i].textContent === fontSizeDisplay) { + nodeList[i].click() + nodeList[i].scrollIntoView() + break + } + } + } + + // When the family list gets focus, focus on the selected family item + function onFamilyListFocus(e) { + if (e.target) { + const selected = e.target.querySelector('.selected') + if (selected) { + selected.focus() + } + } + } + + // When the size list gets focus, focus on the selected size item + function onSizeListFocus(e) { + if (e.target) { + const selected = e.target.querySelector('.selected') + if (selected) { + selected.focus() + } + } + } + + // Highlight the selected list box (familyList or sizeList) + function selectElement(element) { + // Remove 'selected' class from all list elements + const parentUL = element.closest('ul') + if (parentUL) { + const nodeList = parentUL.querySelectorAll('li') + for (let i = 0; i < nodeList.length; i++) { + nodeList[i].classList.remove('selected') + } + // Add 'selected' to clicked list element + element.classList.add('selected') + } + } + + // Event-handler for the font family list + function onFamilyListClick(e) { + if (e.target && e.target.nodeName === 'LI') { + // The target element should be an
  • element + const li = e.target + // The
  • element's textContent is the font family, e.g. "Courier New" + const value = li.textContent + if (value) { + // Update font family value, send to Main Process, mark as selected + config.fontFamily = value + sendFontFamilyChange() + selectElement(li) + setPreviewFontFamily() + } + } + } + + // Event handler for the font size list + function onSizeListClick(e) { + if (e.target && e.target.nodeName === 'LI') { + // The target element should be an
  • element + const li = e.target + // The
  • element's value is the font size, e.g. "10 pt" + const value = li.textContent + if (value) { + // Change "10 pt" to "10pt" + const size = fontDisplayToSize(value) + + // Update font family value, send to Main Process, mark as selected + config.fontSize = size + setPreviewFontSize() + sendFontSizeChange() + selectElement(li) + } + } + } + + // Key Up/Down handler for CSS custom list box + function onListKeydown(e) { + // Only handle keydown events on
  • elements + if (e.target && e.target.nodeName === 'LI') { + const li = e.target + let sibling = null + // Get the key value (only handle Up and Down) + const keyVal = VBKeys.keyVal(e) + switch (keyVal) { + case VBKeys.UP: + e.preventDefault() + // Click on the previous
  • 's descendant + sibling = li.previousElementSibling + if (sibling) { + // Generate a click event on the previous
  • element + sibling.focus() + sibling.click() + } + break + case VBKeys.DOWN: + e.preventDefault() + // Click on the next
  • 's descendant + sibling = li.nextElementSibling + if (sibling) { + // Generate a click event on the next
  • element + sibling.focus() + sibling.click() + } + break + } + } + } + + // Event-handler for the OK button + function onOKClick(e) { + sendCommitFonts() + closeWindow() + } + + // Event-handler for the Defaults button + function onDefaultsClick(e) { + config.fontFamily = config.fontFamilyDefault + config.fontSize = config.fontSizeDefault + setPreviewFontFamily() + setPreviewFontSize() + setSelectedFontFamily() + setSelectedFontSize() + sendFontFamilyChange() + sendFontSizeChange() + } + + // Event-handler for Cancel button + function onCancelClick(e) { + config.fontFamily = fontFamilySave + config.fontSize = fontSizeSave + sendFontFamilyChange() + sendFontSizeChange() + closeWindow() + } + + // Document load event-handler + function onLoad() { + // Assign DOM element shortcuts + familyList = document.getElementById('familyList') + sizeList = document.getElementById('sizeList') + fontPreview = document.getElementById('fontPreview') + okButton = document.getElementById('okButton') + defaultsButton = document.getElementById('defaultsButton') + cancelButton = document.getElementById('cancelButton') + + // Register event-handlers + familyList .addEventListener('click', onFamilyListClick) + familyList .addEventListener('keydown', onListKeydown) + familyList .addEventListener('focus', onFamilyListFocus) + sizeList .addEventListener('click', onSizeListClick) + sizeList .addEventListener('keydown', onListKeydown) + sizeList .addEventListener('focus', onSizeListFocus) + okButton .addEventListener('click', onOKClick) + defaultsButton .addEventListener('click', onDefaultsClick) + cancelButton .addEventListener('click', onCancelClick) + + // Set font list names to the corresponding fonts + setFontFamilyNames() + + // Set initial font values from config data + setPreviewFontFamily() + setPreviewFontSize() + + // Highlight the selected font values + setSelectedFontFamily() + setSelectedFontSize() + + // Set the font preview panel colors + setPreviewColors() + } + + // Register the document load event-handler, fired when DOM is fully loaded + addEventListener('load', onLoad) + +} diff --git a/source/fonts/mono/Consolas/consola.ttf b/source/fonts/mono/Consolas/consola.ttf new file mode 100644 index 0000000..b870671 Binary files /dev/null and b/source/fonts/mono/Consolas/consola.ttf differ diff --git a/source/fonts/mono/Courier New/cour.ttf b/source/fonts/mono/Courier New/cour.ttf new file mode 100644 index 0000000..cedbd9f Binary files /dev/null and b/source/fonts/mono/Courier New/cour.ttf differ diff --git a/source/fonts/mono/Fira Mono/FiraMono-Regular.ttf b/source/fonts/mono/Fira Mono/FiraMono-Regular.ttf new file mode 100644 index 0000000..5238c09 Binary files /dev/null and b/source/fonts/mono/Fira Mono/FiraMono-Regular.ttf differ diff --git a/source/fonts/mono/Inconsolata/Inconsolata-Regular.ttf b/source/fonts/mono/Inconsolata/Inconsolata-Regular.ttf new file mode 100644 index 0000000..bbc9647 Binary files /dev/null and b/source/fonts/mono/Inconsolata/Inconsolata-Regular.ttf differ diff --git a/source/fonts/mono/InputMono/InputMono-Regular.ttf b/source/fonts/mono/InputMono/InputMono-Regular.ttf new file mode 100644 index 0000000..c19c287 Binary files /dev/null and b/source/fonts/mono/InputMono/InputMono-Regular.ttf differ diff --git a/source/fonts/mono/LICENSE.txt b/source/fonts/mono/LICENSE.txt new file mode 100644 index 0000000..657aeaf --- /dev/null +++ b/source/fonts/mono/LICENSE.txt @@ -0,0 +1,93 @@ +Copyright (c) 2012-2013, The Mozilla Corporation and Telefonica S.A. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/source/fonts/mono/Liberation Mono/LiberationMono-Regular.ttf b/source/fonts/mono/Liberation Mono/LiberationMono-Regular.ttf new file mode 100644 index 0000000..1a39bc7 Binary files /dev/null and b/source/fonts/mono/Liberation Mono/LiberationMono-Regular.ttf differ diff --git a/source/fonts/mono/Lucida Console/lucon.ttf b/source/fonts/mono/Lucida Console/lucon.ttf new file mode 100644 index 0000000..3e26f95 Binary files /dev/null and b/source/fonts/mono/Lucida Console/lucon.ttf differ diff --git a/source/fonts/mono/Meslo LG M/MesloLGM-Regular.ttf b/source/fonts/mono/Meslo LG M/MesloLGM-Regular.ttf new file mode 100644 index 0000000..5e56307 Binary files /dev/null and b/source/fonts/mono/Meslo LG M/MesloLGM-Regular.ttf differ diff --git a/source/fonts/mono/Source Code Pro/SourceCodePro-Regular.ttf b/source/fonts/mono/Source Code Pro/SourceCodePro-Regular.ttf new file mode 100644 index 0000000..b2cff92 Binary files /dev/null and b/source/fonts/mono/Source Code Pro/SourceCodePro-Regular.ttf differ diff --git a/source/fonts/sans/LICENSE.txt b/source/fonts/sans/LICENSE.txt new file mode 100644 index 0000000..75b5248 --- /dev/null +++ b/source/fonts/sans/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/source/fonts/sans/Roboto/Roboto-Light.ttf b/source/fonts/sans/Roboto/Roboto-Light.ttf new file mode 100644 index 0000000..94c6bcc Binary files /dev/null and b/source/fonts/sans/Roboto/Roboto-Light.ttf differ diff --git a/source/fonts/sans/Roboto/Roboto-Regular.ttf b/source/fonts/sans/Roboto/Roboto-Regular.ttf new file mode 100644 index 0000000..8c082c8 Binary files /dev/null and b/source/fonts/sans/Roboto/Roboto-Regular.ttf differ diff --git a/source/fonts/sans/Segoe UI/segoeui.ttf b/source/fonts/sans/Segoe UI/segoeui.ttf new file mode 100644 index 0000000..7491c05 Binary files /dev/null and b/source/fonts/sans/Segoe UI/segoeui.ttf differ diff --git a/source/images/128x128.png b/source/images/128x128.png new file mode 100644 index 0000000..5c7fae5 Binary files /dev/null and b/source/images/128x128.png differ diff --git a/source/images/16x16.png b/source/images/16x16.png new file mode 100644 index 0000000..a779caa Binary files /dev/null and b/source/images/16x16.png differ diff --git a/source/images/24x24.png b/source/images/24x24.png new file mode 100644 index 0000000..50afe65 Binary files /dev/null and b/source/images/24x24.png differ diff --git a/source/images/256x256.png b/source/images/256x256.png new file mode 100644 index 0000000..07ac29a Binary files /dev/null and b/source/images/256x256.png differ diff --git a/source/images/32x32.png b/source/images/32x32.png new file mode 100644 index 0000000..ac79684 Binary files /dev/null and b/source/images/32x32.png differ diff --git a/source/images/48x48.png b/source/images/48x48.png new file mode 100644 index 0000000..3479288 Binary files /dev/null and b/source/images/48x48.png differ diff --git a/source/images/512x512.png b/source/images/512x512.png new file mode 100644 index 0000000..7b339b5 Binary files /dev/null and b/source/images/512x512.png differ diff --git a/source/images/64x64.png b/source/images/64x64.png new file mode 100644 index 0000000..e3c39cd Binary files /dev/null and b/source/images/64x64.png differ diff --git a/source/images/96x96.png b/source/images/96x96.png new file mode 100644 index 0000000..e225f93 Binary files /dev/null and b/source/images/96x96.png differ diff --git a/source/images/icon.icns b/source/images/icon.icns new file mode 100644 index 0000000..3b05964 Binary files /dev/null and b/source/images/icon.icns differ diff --git a/source/images/icon.ico b/source/images/icon.ico new file mode 100644 index 0000000..4309db1 Binary files /dev/null and b/source/images/icon.ico differ diff --git a/source/images/icon.png b/source/images/icon.png new file mode 100644 index 0000000..04aed54 Binary files /dev/null and b/source/images/icon.png differ diff --git a/source/index.css b/source/index.css new file mode 100644 index 0000000..8f71d7d --- /dev/null +++ b/source/index.css @@ -0,0 +1,503 @@ +/* NOTE: WHEN CHANGING THIS FILE, CHANGE float.css TOO!!! */ + +@import './fontList.css'; + +html { + height: 100vh; /* Document occupies full viewport height */ +} + +body { + /* The List page uses this font family and size */ + /* The Connection pages' font families and sizes are configurable */ + font-family: 'Roboto', sans-serif; + font-size: 1rem; + + height: 100%; /* Body occupies the entire document height */ + margin: 0; /* Remove browser default margin from the body */ + cursor: default; /* Default to a pointer cursor */ + -webkit-user-select: none; /* Prevent unwanted selection highlighting */ +} + +select, button, input, option, pre { + font: inherit; /* Inherit all font properties from root font */ + color: currentColor; +} + +/* table component resets */ +table, caption, tbody, tfoot, thead, th, tr, td { + table-layout: fixed; + font-size: 100%; + font: inherit; + margin: 0; + border: 0; + padding: 0; + vertical-align: middle; +} + +/* table element reset */ +table { + border-collapse: collapse; +} + +/* input reset */ +input { + font: inherit; /* Inherits font-family AND font-size */ + color: inherit; + outline: none; + border: none; + padding: 0; +} + +.fullHeight { + height: 100%; +} + +/*****************************************************************************/ +/* #mainContainer contains everything on the page: header + content */ +/*****************************************************************************/ + +#mainContainer { + background-color: #1e2026; /* Same as BrowserWindow backgroundColor */ + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; +} + +/*****************************************************************************/ +/* Custom scrollbars */ +/*****************************************************************************/ + +::-webkit-scrollbar { + width: 10px; +} + +::-webkit-scrollbar:horizontal { + height: 10px; +} + +::-webkit-scrollbar-corner { + visibility: hidden; +} + +::-webkit-scrollbar-thumb { + background-color: #666; + border-radius: 4px; +} + +/********************************************************************/ +/* #tabContentContainer contains everything below the tab header, */ +/* and includes the device list page and device connection pages. */ +/********************************************************************/ + +#tabContentContainer { + /* Initially, no space is allocated for this item (flex-basis: 0) + #tabHeaderContainer initially occupies its content size + (its flex-basis is set to auto) + Then all remaining space is allocated to this item (flex-grow: 1) */ + flex: auto; + display: flex; + flex-direction: row; +} + +/*******************************************************/ +/* START OF CODE TO REMOVE WHEN CREATING float.css vvv */ +/*******************************************************/ + +/********************************************************************/ +/* #tabHeaderContainer contains the list of all tab links */ +/********************************************************************/ + +#tabHeader { + /* Intially space is allocated for item's content size (flex-basis: auto) + The item remains at this size (flex-grow and flex-shrink set to zero) + All remaining space in #mainContainer will be allocated to + #tabContentContainer with initial flex-basis: 0 (no space allocated) + and flex-grow: 1 (take up all remaining space) */ + flex: 0 0 auto; + display: flex; + /* The individual tab headers are laid out horizontally in a single row + at the top of #mainContainer + A horizontal scroll bar beneath the tab headers allows tab headers + that overflow the content area + The background is set to the same color as an empty scrollbar track, + removing all signs of the scroll bar when it is not required, + yet preserving its space to avoid the rest of page jumping down when + the scroll bar is needed */ + flex-direction: row; + overflow-x: scroll; + padding-top: 5px; + padding-bottom: 0px; + background-color: #464D72; +} + +.tab { + flex: 0 0 auto; + display: flex; /* Set in VBDockPanel.js/createConnHeader */ + flex-direction: row; + align-items: center; + padding: 0px 2px 1px 1px; + cursor: pointer; + font-size: 0.9em; + background-color: #AAA; + border: 4px solid #464D72; + border-radius: 8px; +} + +.tab.selected { + background-color: #EEE; +} + +.tab:hover { + border: 4px solid #158615; + border-radius: 8px; +} + +.tabIp { + padding: 0 0.1em; +} + +.tabClose { + height: 1em; + width: 1em; + line-height: 1em; + font-size: 0.9em; + padding: 0.3em; + margin-left: 0.1em; + text-align: center; + background-color: #AAA; + color: #DDD; + border: 2px solid #888; + border-radius: 50%; +} + +.tabClose:hover { + background-color: #FFBCBC; + color: red; +} + +#tabStub { + display: none; +} + +#tabAdd { + display: flex; + flex-direction: row; + justify-content: center; + height: 1.5em; + padding: 4px; +} + +.tabConnDown { + color: red; +} + +/********************************************************************/ +/* The panel that encloses the device list */ +/********************************************************************/ + +/* Center the deviceListPageContent in this container */ +#deviceListPage { + width: 100%; + display: block; /* none or block */ +} + +#deviceListPageContent { + height: 32rem; + width: 34rem; + display: flex; + flex-direction: column; + margin: 0 auto; + padding: 5px; + border: 3px solid #464d72; + border: none; + border-radius: 10px; +} + +#deviceListPage *:focus { + /*outline: -webkit-focus-ring-color auto;*/ + outline: #009900 auto; + outline-width: thin; +} + +#deviceListPage input { + padding: 4px 2px 4px 2px; +} + +#deviceListPage select { + padding: 4px 2px 4px 0px; + box-sizing: border-box; +} + +#deviceListPage option { + padding: 0; +} + +#deviceListPage input[readonly], +#connectLabels, +#discoveredDevices td.del { + color: #6798a2; +} + +#deviceListPage input, +#deviceListPage select { + border: 1px solid #777; +} + +#deviceListPage input[readonly] { + background-color: #323645; +} + +#connectButton, +#deviceListPage input, +#deviceListPage select, +#deviceListPage option { + background-color: #2B2E3B; + color: #9feaf9; +} + +#connectButton:hover, +#connectToPort:hover, +#connectToIp:hover, +#discoveredDevices tr:hover, +#deviceListPage input:read-write:hover { + background-color: #3a486f; +} + +#connectToTable tr:last-child td { + border: 1px solid #777; +} + +#selectedDeviceList span { + color: #4d6975; +} + +/********************************************************************/ + +#connectButton { + flex: 0 0 auto; + display: block; + width: 10ch; + text-align: center; + margin: 0 auto; + margin-top: 19px; + margin-bottom: 10px; + padding: 5px 12px; + cursor: pointer; + border: 2px solid #707070; + border-radius: 4px; +} + +/*****************************************************************/ + +fieldset { + margin: 5px 5px 5px 5px; + color: #6798a2; + /*border: 1px solid #6798a266;*/ + border: 1px solid #98a20066; + border-radius: 3px; +} + +legend { + text-align: left; + padding-left: 5px; + padding-right: 5px; + color: #4d6975; +} + +/*****************************************************************/ + +fieldset#connectToGroup { + flex: 0 0 auto; +} + +#connectToTable { + width: 100%; + table-layout: fixed; + border-spacing: 0.25em 0.2em; + border-collapse: separate; +} + +#connectToTable select { + border: none; + cursor: pointer; + width: 100%; +} + +#connectToTable input, select { + box-sizing: border-box; + border: none; + width: 100%; +} + +#connectToTable td { + padding: 0; + margin: 0; + height: 2rem; +} + +#connectToPanel { + width: 100%; + flex: 1; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto; + grid-gap: 10px; + box-sizing: border-box; +} + +/********************************************************************/ + +fieldset#discoveredDevicesGroup { + flex: 1 1 100%; +} + +#discoveredDevices { + table-layout: fixed; + width: 100%; + border-spacing: 0; + border-collapse: collapse; + overflow: hidden; + cursor: pointer; + overflow-y: scroll; + color: #6798a2; +} + +#discoveredDevices tr:hover td.del { + color: maroon; +} + +#discoveredDevices td.del:hover { + color: red !important; +} + +#discoveredDevices tr.selectedDevice { + color: #9feaf9; +} + +#discoveredDevices th.ipAddr { + width: 15ch; +} + +#discoveredDevices th.del { + width: 3ch; +} + +#discoveredDevices td { + vertical-align: middle; + overflow: hidden; +} + +#discoveredDevices td.ipAddr { + padding-right: 5px; +} + +#discoveredDevices td.del { + padding: 0px 5px 3px 5px; + text-align: center; +} + +/********************************************************************/ + +fieldset#selectedDeviceGroup { + flex: 0 0 auto; +} + +#selectedDeviceList { + display: grid; + grid-template-columns: auto 1fr; + grid-column-gap: 20px; + grid-row-gap: 3px; +} + +#selectedDeviceList span { + align-self: center; + color: #4d6975; +} + +#selectedDeviceList input { + border: none; +} + +#serialNumber:focus, #modelNumber:focus, #modelName:focus { + outline: none; +} + +/*******************************************************/ +/* ^^^ END OF CODE TO REMOVE WHEN CREATING float.css */ +/*******************************************************/ + +/*******************************************************************/ +/* The panel that encloses a device connection tab */ +/*******************************************************************/ + +/* + NOTE: ANY CHANGES HERE NEED TO BE MADE IN float.css ALSO !!!!! +*/ + +#connContainer { + flex: 1 1 100%; /* Expand to fill parent container */ + display: none; /* None or block */ + font-family: monospace; /* User-configurable */ + font-size: 1rem; /* User-configurable */ + background-color: black; /* User-configurable */ + color: #00DD00; /* User-configurable */ + -webkit-user-select: text; /* Override list page's 'none' setting */ +} + +#connContainer input { + background-color: inherit; +} + +.connPage { + display: none; /* none or table */ + height: 100%; +} + +.deviceOutputPanelScroll { + height: 100%; + padding: 0 3px; + overflow: auto; +} + +.deviceOutputPanel { + width: 100%; + color: inherit; + background-color: inherit; + word-break: break-all; + white-space: pre-wrap; /* pre-wrap => no horizontal scrollbars */ + /* pre => horizontal scrollbars */ +} + +.deviceInputPanel { + width: 100%; + color: inherit; + background-color: inherit; +} + +.faintBorder { + height: 1px; + opacity: 0.8; + background-color: currentcolor; +} + +.promptPanel { + width: 1.4ch; /* ">" prompt + right padding */ + -webkit-user-select: none; +} + +.promptPanel input { + -webkit-user-select: none; +} + +pre { + margin: 0; + display: inline; + white-space: inherit; /* not inherited by default for
     elements */
    +}
    +
    +.deviceInputText {
    +  width: 100%;
    +  cursor: text;
    +  padding: 3px 0;
    +}
    diff --git a/source/index.html b/source/index.html
    new file mode 100644
    index 0000000..b154d87
    --- /dev/null
    +++ b/source/index.html
    @@ -0,0 +1,155 @@
    +
    +
    +  
    +    
    +    
    +    
    +    
    +  
    +  
    +
    +    
    +    
    + + +
    +
    + Rokus +
    + +
    + + X +
    +
    + + +
    + + +
    +
    +
    + Connect +
    +
    + Connect To + + + + + + + + + +
    IP AddressPort
    + + + +
    +
    +
    + Discovered Devices + + + + + + + + + + + + + + +
    +
    +
    + Selected Device +
    + Friendly Name + + Serial Number + + Model Number + + Model Name + +
    +
    +
    +
    + + + +
    + + + + + + + + +
    + +
    + + +
    +
    +
    + + + + + + + + + + + + + + + + +
    + +
    +
    +
    +
    + + + +
    +
    +
    + +
    +
    + + diff --git a/source/index.js b/source/index.js new file mode 100644 index 0000000..8b07a69 --- /dev/null +++ b/source/index.js @@ -0,0 +1,27 @@ +'use strict'; +{ // <= Enclosing block to keep function definitions out of global namespace + + // Local modules + const VBDockPanel = require('./lib/VBDockPanel') + + // UI handler for the main dock, to be set up in the 'load' event handler + let vbDockPanel = null + + // Event-handler that gets executed when the DOM is fully loaded + function onLoad() { + console.log('Node.js version:', process.versions.node) + console.log('Electron version:', process.versions.electron) + console.log('Chrome version:', process.versions.chrome) + console.log('Versions: %O', process.versions) + + // Create the dock panel that will hold the list panel and + // docked connection panels, etc. + vbDockPanel = new VBDockPanel() + + // Render the list panel and tab header in the Dock Panel + vbDockPanel.init() + } + + // Don't do anything until the document is fully loaded + addEventListener('load', onLoad) +} diff --git a/source/lib/VBConfig.js b/source/lib/VBConfig.js new file mode 100644 index 0000000..9ed37a0 --- /dev/null +++ b/source/lib/VBConfig.js @@ -0,0 +1,149 @@ +'use strict'; + +// VBConfig may be used from either the Main or Renderer Process +// Note that in each Renderer Process, the config data is read from disk +// at startup, maintained locally while the program runs, +// but NOT written to disk on shutdown +// Only the VBConfig object maintained by the Main Process is +// saved to disk upon shutdown +// Therefore, any config changes made in a Renderer that need to be +// persisted must be communicated to the Main process using IPCs + +const isRenderer = !!(process && process.type === 'renderer') +const electron = require('electron') +const {app} = isRenderer ? electron.remote : electron + +// Node.js modules +const fs = require('fs') +const path = require('path') + +class VBConfig { + + constructor() { + // Config object, contains the parsed data read from the config JSON file + this.config = null + } + + // Set up the config object by reading the config JSON file + init() { + // Filename for the config file + const fname = 'vb-config.json' + + // Use the user's appData folder for the config file + const defaultFolder = app.getPath('userData') + + // Actual folder used for the config file + let configFolder = '' + + // Full pathname of the config file + let configPath = '' + + // JSON data read from config file + let data = '' + + // Current app version if default config file is used + let version = '' + + // Determine whether the config folder exists + try { + const stats = fs.statSync(defaultFolder) + if (stats && stats.isDirectory()) { + configFolder = defaultFolder + } + } + catch (e) { + // It's okay if the userData folder is missing + // the first time the application is run + } + + // If config folder does not exist, attempt to create it + if (configFolder === '') { + try { + fs.mkdirSync(defaultFolder) + configFolder = defaultFolder + } + catch (e) { + console.log('VBConfig could not create userData folder:', e) + } + } + + // Append config filename to folder name + // Note that if we couldn't create the config folder, + // then the default program folder is used instead + configPath = path.join(configFolder, fname) + + // Attempt to read the config file (it won't exist the first time run) + try { + data = fs.readFileSync(configPath, 'utf8') + } + catch (e) { + data = '' + } + + // If the config JSON data was read, parse it into a config object + if (data) { + // Attempt to parse the JSON data in the config file + try { + this.config = JSON.parse(data) + } + catch (e) { + this.config = null + console.log('VBConfig. JSON.parse error:', e) + } + } + + // If no config data found, use the default config file + if (!this.config) { + try { + data = fs.readFileSync(path.join(__dirname, '..', fname), 'utf8') + version = app.getVersion() + } + catch (e) { + data = '' + console.log('VBConfig. Unable to read default config: Error:', e) + } + + if (data) { + // Attempt to parse the JSON data in the default config file + try { + this.config = JSON.parse(data) + // If default config file used, fill in the current package version + if (version) { + this.config.version = version + } + } + catch (e) { + this.config = null + console.log('VBConfig. JSON.parse error for default config:', e) + } + } + } + + // If we read the config data, save the pathname used in the config obj + // Otherwise it will be a fatal error, to be handled by the caller + if (this.config) { + this.config.configPath = configPath + } + + return this.config + } + + // Save the config object + // This is done during application shutdown in the Main Process + // Use a synchronous write so the application doesn't shut down + // before the config file is fully written + save() { + if (this.config) { + try { + fs.writeFileSync(this.config.configPath, + JSON.stringify(this.config, null, 2)) + } + catch (e) { + console.log('VBConfig. Unable to save config file:', e) + } + } + } + +} + +module.exports = VBConfig diff --git a/source/lib/VBConnPanel.js b/source/lib/VBConnPanel.js new file mode 100644 index 0000000..f94c048 --- /dev/null +++ b/source/lib/VBConnPanel.js @@ -0,0 +1,746 @@ +'use strict'; +/* + * Handle the connection panel and socket for the connection. + * Handles either a docked or a floated connection panel + */ + +// Node.js modules +const os = require('os') +const StringDecoder = require('string_decoder').StringDecoder + +// Electron modules +const {remote, ipcRenderer} = require('electron') + +// Local modules +const VBLog = require('./VBLog') +const VBKeys = require('./VBKeys') +const VBMenu = require('./VBMenu') +const VBSocket = require('./VBSocket') +const VBConfig = require('./VBConfig') +const VBHistory = require('./VBHistory') + +class VBConnPanel { + + constructor(isFloated, + ip, port, + connId, connUpCallback, connDownCallback) { + + // Instantiate a VBConfig object + // Note that in each Renderer Process, the config data is read from + // disk at startup, maintained locally while the program runs, + // but NOT written to disk on shutdown + // Only the VBConfig object maintained by the Main Process is + // saved to disk upon shutdown + // Therefore, any config changes made in a Renderer that need to be + // persisted must be communicated to the Main process using IPCs + this.vbConfig = new VBConfig() + + // Read the config data, getting a reference to the JSON config info + this.config = this.vbConfig.init() + + if (!this.config) { + ipcRenderer.send('fatal-error-dialog', 'Unable to read config file') + } + + // true if a floated tab + this.isFloated = isFloated + + // History object + this.vbHistory = new VBHistory() + + // IP address (string) + this.ip = ip + + // Port (integer) + this.port = port + + // Connection identifier (used to match context menu IPCs) + this.connId = connId + + // Callback used when a connection succeeds + this.connUpCallback = connUpCallback + + // Callback used when a connection terminates + this.connDownCallback = connDownCallback + + // Logging object + this.vbLog = new VBLog(this) + + // VBSocket object for connection with the client device + this.vbSocket = null + + // DOM element for connection panels' container + this.connContainer = null + + // DOM element for output field + this.deviceOutputPanel = null + + // DOM element for device output scroll panel + this.deviceOutputPanelScroll = null + + // DOM element for input field + this.deviceInputPanel = null + + // DOM element for the user's input field + this.deviceInputText = null + + // DOM element for the input prompt + this.promptText = null + + // DOM element for this connection's page + // (the .connPage class within the #tabContentContainer div) + this.connPage = null + + // Queue packets of socket Buffer data + // Packets will be dequeud and processed when the animation timer expires + this.packetList = new PacketList() + + // Use an animation timer to check the packet list for new data packets + this.animationTimer = null + + // Count the number of \n-terminated output lines + this.outputLineCount = 0 + + // Array to keep track of the number of lines in each data packet + this.linesPerPacket = [] + + // Keep track of whether the previously output line was terminated + this.lastOutputChar = '' + + // The last DOM element containing text (
     element)
    +    this.lastDOMTextNode = null
    +    this.lastDOMTextContent = ''
    +
    +    // Set up event handlers, making sure their contexts are set to
    +    // the current instance of this VBConnPanel class
    +    this.onAutoScrollChanged = this._onAutoScrollChanged.bind(this)
    +    this.onAutoWrapChanged = this._onAutoWrapChanged.bind(this)
    +    this.onFontFamilyChanged = this._onFontFamilyChanged.bind(this)
    +    this.onFontSizeChanged = this._onFontSizeChanged.bind(this)
    +    this.onBgColorChanged = this._onBgColorChanged.bind(this)
    +    this.onFgColorChanged = this._onFgColorChanged.bind(this)
    +    this.onShortcutsChanged = this._onShortcutsChanged.bind(this)
    +  }
    +
    +  init() {
    +
    +    // Set up the connection's UI DOM elements
    +    this.setupConnectionUI()
    +
    +    // Application-wide event-handlers [from app menu and/or context menu]
    +    // Each connection in the Renderer process handles each of these events
    +
    +    ipcRenderer.on('autoScrollChanged', this.onAutoScrollChanged)
    +    ipcRenderer.on('autoWrapChanged',   this.onAutoWrapChanged)
    +    ipcRenderer.on('fontFamilyChanged', this.onFontFamilyChanged)
    +    ipcRenderer.on('fontSizeChanged',   this.onFontSizeChanged)
    +    ipcRenderer.on('bgColorChanged',    this.onBgColorChanged)
    +    ipcRenderer.on('fgColorChanged',    this.onFgColorChanged)
    +    ipcRenderer.on('shortcutsChanged',  this.onShortcutsChanged)
    +
    +    // Set up a VBSocket object for the connection
    +    this.vbSocket = this.doCreateSocket()
    +
    +  }
    +
    +  // De-allocate resources to avoid memory leaks
    +  terminate() {
    +    // Destroy the socket
    +    this.doTerminateSocket()
    +
    +    // Cancel the animation timer to stop checking the packet list
    +    if (this.animationTimer !== null) {
    +      try {
    +        this.cancelAnimationFrame(this.animationTimer)
    +      }
    +      catch (ex) {
    +        console.log('Exception in cancelAnimationFrame', ex)
    +      }
    +      this.animationTimer = null
    +    }
    +
    +    // Clear the packet queue
    +    this.packetList = null
    +
    +    // Terminate logging
    +    this.vbLog.close()
    +    this.vbLog = null
    +
    +    // Remove IPC event listeners
    +    ipcRenderer.removeListener('autoScrollChanged', this.onAutoScrollChanged)
    +    ipcRenderer.removeListener('autoWrapChanged', this.onAutoWrapChanged)
    +    ipcRenderer.removeListener('fontFamilyChanged', this.onFontFamilyChanged)
    +    ipcRenderer.removeListener('fontSizeChanged', this.onFontSizeChanged)
    +    ipcRenderer.removeListener('bgColorChanged', this.onBgColorChanged)
    +    ipcRenderer.removeListener('fgColorChanged', this.onFgColorChanged)
    +    ipcRenderer.removeListener('shortcutsChanged', this.onShortcutsChanged)
    +
    +    // Deallocate DOM resources
    +    while (this.connPage.firstChild) {
    +      this.connPage.removeChild(this.connPage.firstChild)
    +    }
    +    if (this.connPage.parentNode) {
    +      this.connPage.parentNode.removeChild(this.connPage)
    +    }
    +    this.connPage = null
    +  }
    +
    +  doCreateSocket() {
    +
    +    // Set up a VBSocket object for the connection
    +    const vbSocket = new VBSocket(this.ip, this.port)
    +
    +    // Register socket callbacks
    +    vbSocket.registerDataCallback(this.socketDataCallback.bind(this))
    +    vbSocket.registerConnectCallback(this.socketConnectCallback.bind(this))
    +    vbSocket.registerCloseCallback(this.socketCloseCallback.bind(this))
    +    vbSocket.registerErrorCallback(this.socketErrorCallback.bind(this))
    +    vbSocket.registerTimeoutCallback(this.socketTimeoutCallback.bind(this))
    +
    +    // Create the socket and connect to the ip and port
    +    vbSocket.setupConnectionSocket()
    +
    +    return vbSocket
    +
    +  }
    +
    +  doTerminateSocket() {
    +
    +    this.vbSocket.terminate()
    +    this.vbSocket = null
    +
    +  }
    +
    +  setupConnectionUI() {
    +
    +    //-------------------------------------
    +    // Set up html for a new connection ...
    +    //-------------------------------------
    +
    +    // Create html panel for a new connection by cloning the stub
    +    this.connPage = document.getElementById('conn-stub').cloneNode(true)
    +
    +    // Make sure we don't have duplicate ids
    +    this.connPage.setAttribute('id', 'connPage-' + this.connId)
    +
    +    // Add the context-menu (right-click) handler for the connection tab
    +    this.connPage.addEventListener('contextmenu', e => {
    +      // Disable the system right-click functionality (possibly not necessary)
    +      e.preventDefault()
    +      // Display the context menu
    +      this.doContextMenu()
    +    })
    +
    +    // Save DOM references
    +    this.connContainer = document.getElementById('connContainer')
    +    this.deviceOutputPanelScroll = this.connPage
    +                                  .querySelector('.deviceOutputPanelScroll')
    +    this.deviceOutputPanel = this.connPage.querySelector('.deviceOutputPanel')
    +    this.deviceInputPanel = this.connPage.querySelector('.deviceInputPanel')
    +    this.deviceInputText = this.connPage.querySelector('.deviceInputText')
    +    this.promptText = this.connPage.querySelector('.promptText')
    +
    +    // Add the new connection panel to the tab container
    +    connContainer.appendChild(this.connPage)
    +
    +    // Set font from the config values
    +    this.setFontFamily(this.config.fontFamily)
    +    this.setFontSize(this.config.fontSize)
    +
    +    // Set colors from the config values
    +    this.setFgColors(this.config.foregroundColor)
    +    this.setBgColors(this.config.backgroundColor)
    +
    +    // Set wrapping from current config state
    +    this.deviceOutputPanel.style.whiteSpace = this.config.autoWrap ? 'pre-wrap'
    +                                                                   : 'pre';
    +
    +    // Make the new connection html panel visible
    +    this.connPage.style.display = 'table'
    +
    +    this.connContainer.style.display = 'block'
    +
    +    this.deviceInputText.addEventListener('keydown', e => {
    +      this.onKeydown.call(this, e)
    +    })
    +
    +    document.addEventListener('keydown', e => {
    +      this.onConnKeydown.call(this, e)
    +    })
    +
    +    // DOM event-handler to scroll to end on window resize
    +    // Throttle the event-handling to half-second intervals
    +    let resizeTimeout = null
    +    addEventListener('resize', e => {
    +      if (resizeTimeout) {
    +        clearTimeout(resizeTimeout)
    +      }
    +      resizeTimeout = setTimeout(() => this.doScrollToEnd(), 500)
    +    })
    +
    +    // Put the cursor in the input box
    +    this.deviceInputText.focus()
    +  }
    +
    +  // Make the connection panel visible
    +  displayConnTab() {
    +    // Display this connection's tab
    +    this.connPage.style.display = 'table'
    +
    +    // Set the keyboard focus to the tab's input field
    +    this.deviceInputText.focus()
    +
    +    // Scroll to end, if required
    +    this.doScrollToEnd()
    +  }
    +
    +  // Append a packet of output data to the deviceOutputPanel display area
    +  // A single packet may contain multiple lines (\n)
    +  // It appears that using tables rather than 
    or
    +  // elements for the output data is astonishingly faster
    +  appendPacket(packet) {
    +    // Log the data
    +    this.vbLog.write(packet)
    +
    +    // Count number of \n characters in this data packet
    +    const ma = packet.match(/\n/g)
    +    const lineCount = ma ? ma.length : 0
    +
    +    // Keep track of how many total lines we have
    +    this.outputLineCount += lineCount
    +
    +    // Chrome renderng optimization - update display while hidden
    +    const disp = this.connPage.style.display
    +    this.connPage.style.display = 'none'
    +
    +    // Remove packets until line count does not exceed our limit,
    +    // but only if autoScroll is set, otherwise if the user has turned off
    +    // autoScroll and paged back to look at a particular area, then the
    +    // display may jump around as earlier data is pruned
    +    if (this.config.autoScroll) {
    +      while (this.outputLineCount > this.config.maxOutputNodes) {
    +        let linesInPacket = this.linesPerPacket.shift()
    +        if (this.deviceOutputPanel.firstChild) {
    +          this.deviceOutputPanel.removeChild(this.deviceOutputPanel.firstChild)
    +        }
    +        this.outputLineCount -= linesInPacket
    +      }
    +    }
    +
    +    // If the previously-written output line was not \n-terminated,
    +    // then append this packet to the previous ,
    +    // otherwise create a new  for the packet
    +    // Add the new packet to the output pane
    +    if (this.lastOutputChar !== '\n' && this.lastDOMTextNode) {
    +      this.lastDOMTextContent += packet
    +      this.lastDOMTextNode.nodeValue = this.lastDOMTextContent
    +      const prevPacketLines = this.linesPerPacket.pop()
    +      this.linesPerPacket.push(prevPacketLines + lineCount)
    +    }
    +    else {
    +      const tr = document.createElement('TR')
    +      const td = document.createElement('TD')
    +      const pre = document.createElement('PRE')
    +      this.lastDOMTextContent = packet.slice(0)
    +      this.lastDOMTextNode = document.createTextNode(packet)
    +      pre.appendChild(this.lastDOMTextNode)
    +      td.appendChild(pre)
    +      tr.appendChild(td)
    +      this.deviceOutputPanel.appendChild(tr)
    +      this.linesPerPacket.push(lineCount)
    +    }
    +
    +    this.lastOutputChar = packet.slice(-1)
    +
    +    this.connPage.style.display = disp
    +
    +    this.doScrollToEnd()
    +  }
    +
    +  // Called whenever any data is received from the socket
    +  // Because of how sockets work, transmissions from the device may be
    +  // split across packets
    +  // A StreamDecoder is used to ensure that when data is returned to
    +  // the caller, there are no split multi-byte UTF-8 characters
    +  // However lines ending in \r\n may be split across packets;
    +  // however, using 
     elements for the data takes care of that
    +  packetDequeue() {
    +    const data = this.packetList.remove()
    +    if (data !== '') {
    +      this.appendPacket(data)
    +    }
    +
    +    // Only re-start the animation timer if there is more data in the queue
    +    if (this.packetList.isEmpty()) {
    +      this.animationTimer = null
    +    }
    +    else {
    +      this.animationTimer = window.requestAnimationFrame(
    +                                  (timestamp) => this.packetDequeue())
    +    }
    +  }
    +
    +  // Put the Buffer received from the socket onto a packet queue
    +  // If the animation frame timer is not running then start it
    +  // so that packet-handled is throttled to not exceed animation
    +  // frame limits
    +  socketDataCallback(buffer) {
    +    this.packetList.add(buffer)
    +    if (this.animationTimer === null) {
    +      this.animationTimer = window.requestAnimationFrame(
    +                                  (timestamp) => this.packetDequeue())
    +    }
    +  }
    +
    +  socketConnectCallback() {
    +    this.connUpCallback(this.connId)
    +  }
    +
    +  socketCloseCallback() {
    +    this.connDownCallback(this.connId)
    +  }
    +
    +  socketErrorCallback() {
    +    this.connDownCallback(this.connId)
    +  }
    +
    +  socketTimeoutCallback() {
    +    this.connDownCallback(this.connId)
    +  }
    +
    +  // Clear screen context menu command or Alt/C key
    +  clearScreen() {
    +    while (this.deviceOutputPanel.firstChild) {
    +      this.deviceOutputPanel.removeChild(this.deviceOutputPanel.firstChild)
    +    }
    +    // Zero the output line totals
    +    this.outputLineCount = 0
    +    this.linesPerPacket = []
    +    this.lastDOMTextContent = ''
    +    this.lastDOMTextNode = null
    +  }
    +
    +  // Clear line context menu commane or Esc key
    +  clearLine() {
    +    this.deviceInputText.value = ''
    +  }
    +
    +  // connPage keydown handler
    +  // This handler is specifically to handle to clear line/clean screen keys
    +  // if the cursor is not positioned in the deviceInputText field
    +  // It is attached to the document therefore it is necessary to check
    +  // whether this is the currently displayed tab or not
    +  onConnKeydown(e) {
    +    if (this.connPage && this.connPage.style.display !== 'none') {
    +      const keyVal = VBKeys.keyVal(e)
    +      switch (keyVal) {
    +        case VBKeys.ALTC:
    +          this.clearScreen()
    +          break
    +        case VBKeys.ESCAPE:
    +          this.clearLine()
    +          break
    +      }
    +    }
    +  }
    +
    +  // deviceInputText keydown event listener
    +  onKeydown(e) {
    +    // Get the user's input text
    +    const input = this.deviceInputText.value
    +
    +    // Get the input key value
    +    const keyVal = VBKeys.keyVal(e)
    +
    +    // Handle command history and tab completion
    +    const histNext = this.vbHistory.keydown(keyVal, input)
    +
    +    switch (keyVal) {
    +
    +      // Check if enter key pressed
    +      case VBKeys.ENTER:
    +        // Write the input data to the TCP socket
    +        if (this.vbSocket) {
    +          this.vbSocket.write(input + '\r\n')
    +        }
    +
    +        // Echo user input to the output display
    +        this.appendPacket(input + '\r\n')
    +
    +        // Clear the input field
    +        this.clearLine()
    +        break
    +
    +      // Check if break key (ctrl-c) pressed
    +      case VBKeys.CTRLC:
    +        // Echo "Break!!!" to the output display
    +        this.appendPacket('Break!!!\r\n')
    +
    +        // Write ETX character to TCP socket to signal a break
    +        if (this.vbSocket) {
    +          this.vbSocket.write('\x03')
    +        }
    +        break
    +
    +      // Check if clear line key (esc) pressed
    +      case VBKeys.ESCAPE:
    +        this.clearLine()
    +        break
    +
    +      // Check if clear screen (alt-c) pressed
    +      case VBKeys.ALTC:
    +        this.clearScreen()
    +        break
    +
    +      // Check for shortcut keys
    +      case VBKeys.CTRL0:
    +        this.insertShortcutText(this.config.shortcutList['Ctrl-0'])
    +        break
    +      case VBKeys.CTRL1:
    +        this.insertShortcutText(this.config.shortcutList['Ctrl-1'])
    +        break
    +      case VBKeys.CTRL2:
    +        this.insertShortcutText(this.config.shortcutList['Ctrl-2'])
    +        break
    +      case VBKeys.CTRL3:
    +        this.insertShortcutText(this.config.shortcutList['Ctrl-3'])
    +        break
    +      case VBKeys.CTRL4:
    +        this.insertShortcutText(this.config.shortcutList['Ctrl-4'])
    +        break
    +      case VBKeys.CTRL5:
    +        this.insertShortcutText(this.config.shortcutList['Ctrl-5'])
    +        break
    +      case VBKeys.CTRL6:
    +        this.insertShortcutText(this.config.shortcutList['Ctrl-6'])
    +        break
    +      case VBKeys.CTRL7:
    +        this.insertShortcutText(this.config.shortcutList['Ctrl-7'])
    +        break
    +      case VBKeys.CTRL8:
    +        this.insertShortcutText(this.config.shortcutList['Ctrl-8'])
    +        break
    +      case VBKeys.CTRL9:
    +        this.insertShortcutText(this.config.shortcutList['Ctrl-9'])
    +        break
    +
    +      }
    +
    +    // Update the input field if navigating through the history buffer
    +    if (histNext !== null) {
    +      // The call to history.keydown() gave us the next history item
    +      this.deviceInputText.value = histNext
    +      // Make sure the caret stays at the end of the line
    +      e.preventDefault()
    +    }
    +
    +  }
    +
    +  // Insert shortcut text replace the current highlighted input text,
    +  // or inserting at the current caret position if no text highlighted
    +  insertShortcutText(shortcutText) {
    +    const oldText   = this.deviceInputText.value
    +    const selStart  = this.deviceInputText.selectionStart
    +    const selEnd    = this.deviceInputText.selectionEnd
    +    const preText   = oldText.substring(0, selStart)
    +    const postText  = oldText.substring(selEnd)
    +    this.deviceInputText.value          = preText + shortcutText + postText
    +    this.deviceInputText.selectionStart = selStart + shortcutText.length
    +    this.deviceInputText.selectionEnd   = this.deviceInputText.selectionStart
    +  }
    +
    +  // Context (right-click) menu for a connection
    +  // This handler is attached the connection's input/output panel
    +  doContextMenu() {
    +    // Set up the connection's context menu handler
    +    const menu = VBMenu.createContextMenu(this.connId, this.ip, this.port)
    +
    +    // Set the content menu items appropriately
    +    menu.items[VBMenu.RESUME_LOGGING].enabled = this.vbLog.canResume()
    +    menu.items[VBMenu.PAUSE_LOGGING].enabled  = this.vbLog.canPause()
    +    menu.items[VBMenu.FLOAT_TAB].visible      = !this.isFloated
    +    menu.items[VBMenu.UNFLOAT_TAB].visible    = this.isFloated
    +    menu.items[VBMenu.AUTO_SCROLL].checked    = this.config.autoScroll
    +    menu.items[VBMenu.AUTO_WRAP].checked      = this.config.autoWrap
    +
    +    // Display the pop-up menu on the currenty-focused BrowserWindow
    +    menu.popup()
    +  }
    +
    +  // Context menu event listener for 'Log file ...'
    +  onLogFile() {
    +    this.vbLog.logFile(this.config)
    +  }
    +
    +  // Context menu event listener for 'Resume logging'
    +  onLogResume() {
    +    this.vbLog.resume()
    +  }
    +
    +  // Context menu event listener for 'Pause logging'
    +  onLogPause() {
    +    this.vbLog.pause()
    +  }
    +
    +  // Context menu event listener for re-connect
    +  onReConnect() {
    +    // To re-connect to a device, simply destroy the existing VBSocket
    +    // object, and thus the underlying Node.js socket object, then
    +    // create a new VBSocket
    +    // Assume that when the VBSocket object is destroyed, a
    +    // [FIN, ACK] packet will be sent to the device
    +    if (this.vbSocket) {
    +      this.vbSocket.terminate()
    +    }
    +    this.vbSocket = this.doCreateSocket()
    +  }
    +
    +  // Close tab event listener (for app menu, context menu, and close button)
    +  onCloseTab() {
    +    this.terminate()
    +  }
    +
    +  // Main menu/context menu event listener for Clear Screen
    +  onClearScreen() {
    +    this.clearScreen()
    +  }
    +
    +  // Main menu/context menu event listener for Clear Line
    +  onClearLine() {
    +    this.clearLine()
    +  }
    +
    +  // Event listener for Auto Scroll checkbox
    +  _onAutoScrollChanged(e, state) {
    +    this.config.autoScroll = state
    +
    +    // Scroll to end if auto-scroll has been turned on
    +    if (state) {
    +      this.doScrollToEnd()
    +    }
    +  }
    +
    +  // Event listener for Auto Wrap checkbox
    +  _onAutoWrapChanged(e, state) {
    +    this.config.autoWrap = state
    +
    +    // Wrap or nowrap as appropriate
    +    this.doWrap(state)
    +
    +    // Scroll to end if auto-scroll in effect, since turning on
    +    // wrapping may result in more output lines displayed
    +    if (this.config.autoScroll) {
    +      this.doScrollToEnd()
    +    }
    +  }
    +
    +  setFgColors(color) {
    +    this.connContainer.style.color = color
    +  }
    +
    +  setBgColors(color) {
    +    this.connContainer.style.backgroundColor = color
    +  }
    +
    +  _onFgColorChanged(e, color) {
    +    this.setFgColors(color)
    +  }
    +
    +  _onBgColorChanged(e, color) {
    +    this.setBgColors(color)
    +  }
    +
    +  setFontFamily(family) {
    +    this.connContainer.style.fontFamily = family
    +  }
    +
    +  setFontSize(size) {
    +    this.connContainer.style.fontSize = size
    +  }
    +
    +  _onFontFamilyChanged(e, family) {
    +    this.setFontFamily(family)
    +  }
    +
    +  _onFontSizeChanged(e, size) {
    +    this.setFontSize(size)
    +  }
    +
    +  _onShortcutsChanged(e, shortcutsJson) {
    +    this.config.shortcutList = JSON.parse(shortcutsJson)
    +  }
    +
    +  // Scroll to the end
    +  doScrollToEnd() {
    +    if (this.config.autoScroll) {
    +      this.deviceOutputPanelScroll.scrollTop =
    +                              this.deviceOutputPanelScroll.scrollHeight
    +    }
    +  }
    +
    +  // Set each .deviceOutputPanel element to pre-wrap (wrap) if
    +  // autoWrap is true, else pre (nowrap) if autoWrap is false
    +  doWrap(state) {
    +    for (let item of document.getElementsByClassName('deviceOutputPanel')) {
    +      item.style.whiteSpace = this.config.autoWrap ? 'pre-wrap' : 'pre';
    +    }
    +  }
    +
    +}
    +
    +/****************************************************************************/
    +
    +// Single linked-list of Buffer objects received by the socket
    +// Raw binary Buffer objects containing data received from the socket
    +// are stored, rather than UTF-8 character-encoded data
    +// The buffers are decoded as they are dequeued
    +class PacketList {
    +  constructor() {
    +    this.decoder = new StringDecoder('utf8')
    +    this.head = null
    +    this.tail = null
    +  }
    +
    +  // Return true if there are no more Buffer objects queued
    +  isEmpty() {
    +    return this.head === null
    +  }
    +
    +  // Add a new Buffer node (enqueue) to the head of the linked list
    +  add(packet) {
    +    const node = new PacketNode(packet)
    +    if (this.head === null) {
    +      this.head = node
    +    }
    +    else {  // this.tail !== null
    +      this.tail.next = node
    +    }
    +    this.tail = node
    +  }
    +
    +  // Remove a Buffer node (dequeue) from the head of the linked list
    +  remove() {
    +    if (this.head !== null) {
    +      const node = this.head
    +      this.head = node.next
    +      if (this.head === null) {
    +        this.tail = null
    +      }
    +      return this.decoder.write(node.data)
    +    }
    +    else {
    +      return ''
    +    }
    +
    +  }
    +}
    +
    +// PacketList node
    +class PacketNode {
    +  constructor(packet) {
    +    this.data = packet
    +    this.next = null
    +  }
    +}
    +
    +module.exports = VBConnPanel
    diff --git a/source/lib/VBDiscover.js b/source/lib/VBDiscover.js
    new file mode 100644
    index 0000000..18d0119
    --- /dev/null
    +++ b/source/lib/VBDiscover.js
    @@ -0,0 +1,185 @@
    +'use strict';
    +
    +// Node.js modules
    +const http = require('http')      // ECP requests
    +const dgram = require('dgram')    // SSDP M-SEARCH and NOTIFY
    +
    +// RegEx to extract ip addr/serial number from M-SEARCH and NOTIFY responses
    +const reIpAddr = /\r\nLocation\s*:\s*(?:.*?:\/\/)?([^:\/\r\n]+)/i
    +const reSerialNumber = /\r\nUSN:\s*uuid:roku:ecp:\s*([A-Z0-9]+)/i
    +
    +// Use a regular expression to extract a field from some data,
    +// returning an empty string if the field is not found
    +function extract(re, data) {
    +  const m = re.exec(data)
    +  return Array.isArray(m) && m.length === 2 ? m[1] : ''
    +}
    +
    +// Extract device details from a device's ECP response
    +// Not terribly efficient, but it doesn't need to be
    +// In case there was an error getting an ECP response,
    +// return the serial number from the M-SEARCH/NOTIFY response
    +function parseDeviceDetails(ipAddr, sn, data) {
    +  return {
    +    ipAddr:       ipAddr,
    +    serialNumber: sn || extract(/(.*?)<\/serialNumber>/i, data),
    +    friendlyName: extract(/(.*?)<\/friendlyName>/i, data),
    +    modelNumber:  extract(/(.*?)<\/modelNumber>/i, data),
    +    modelName:    extract(/(.*?)<\/modelName>/i, data)
    +  }
    +}
    +
    +// Send an ECP request to the device to get its details
    +// Invoke the callback to pass the device details back to the caller
    +function deviceDiscovered(ipAddr, serialNumber, discoveryCallback) {
    +  const bufferList = []
    +  const req = http.request({host: ipAddr, port: 8060, family: 4}, (res) => {
    +    res.on('data', (chunk) => {
    +      bufferList.push(chunk)
    +    })
    +    res.on('end', () => {
    +      const response = Buffer.concat(bufferList).toString()
    +      const details = parseDeviceDetails(ipAddr, serialNumber, response)
    +      if (details.serialNumber) {
    +        discoveryCallback(details)
    +      }
    +    })
    +  })
    +
    +  // A 'socket' event is emitted after a socket is assigned to the request
    +  // Handle this event to set a timeout on the socket connection
    +  // This is instead of setting the timeout when http.request() is called,
    +  // which would only be emitted after the socket is assigned and is connected,
    +  // and would not detect a timeout while trying to establish the connection
    +  req.on('socket', (socket) => {
    +    socket.setTimeout(10000)
    +    socket.on('timeout', () => {
    +      console.log('deviceDiscovered socket timeout')
    +      // A timeout does not abort the connection; it has to be done manually
    +      // This will cause a createHangUpError error to be emitted on the request
    +      req.abort()
    +    })
    +  })
    +
    +  // Even if there is an error on the ECP request, invoke the
    +  // discoveryCallback with the known ip address and serial number
    +  req.on('error', (error) => {
    +    const details = parseDeviceDetails(ipAddr, serialNumber, '')
    +    if (details.serialNumber) {
    +      discoveryCallback(details)
    +    }
    +    console.log('deviceDiscovered error: %O', error)
    +  })
    +
    +  // The ECP request has an empty body
    +  req.write('')
    +
    +  // Send the ECP request
    +  req.end()
    +}
    +
    +// Send an SSDP M-SEARCH discovery request
    +function ssdpSearchRequest(discoveryCallback) {
    +  const ssdpRequest = new Buffer(
    +                      'M-SEARCH * HTTP/1.1\r\n' +
    +                      'HOST: 239.255.255.250:1900\r\n' +
    +                      'MAN: "ssdp:discover"\r\n' +
    +                      'ST: roku:ecp\r\n' +
    +                      'MX: 3\r\n' +
    +                      '\r\n')
    +
    +  const searchSocket = dgram.createSocket('udp4')
    +
    +  searchSocket.on('message', (msg, rinfo) => {
    +    const ssdpResponse = msg.toString()
    +    const serialNumber = extract(reSerialNumber, ssdpResponse)
    +    const ipAddr = extract(reIpAddr, ssdpResponse)
    +    // Only add devices that have an ip address and serial number
    +    // This will trigger an ECP request to get the device details
    +    if (ipAddr && serialNumber) {
    +      deviceDiscovered(ipAddr, serialNumber, discoveryCallback)
    +    }
    +  })
    +
    +  // Send the M-SEARCH request to the SSDP multicast group
    +  searchSocket.send(ssdpRequest, 1900, '239.255.255.250')
    +}
    +
    +// Listen for SSDP discovery NOTIFY responses
    +// These should be received whenever a device connects to the network
    +function ssdpNotify(discoveryCallback) {
    +  let notifySocket = dgram.createSocket('udp4')
    +
    +  notifySocket.on('message', (msg, rinfo) => {
    +    const ssdpResponse = msg.toString()
    +    const serialNumber = extract(reSerialNumber, ssdpResponse)
    +    const ipAddr = extract(reIpAddr, ssdpResponse)
    +
    +    // Only add devices that have an ip address AND Roku serial number,
    +    // to avoid sending ECP requests to non-Roku devices.
    +    if (ipAddr && serialNumber) {
    +      deviceDiscovered(ipAddr, serialNumber, discoveryCallback)
    +    }
    +  })
    +
    +  // If binding fails, an 'error' event is generated
    +  // However, in some cases, an exception may be thrown,
    +  // hence the 'try-catch' in the bind() function
    +  notifySocket.on('error', (e) => {
    +    console.log('notifySocket error: %O', e)
    +  })
    +
    +  // SSDP NOTIFY responses are directed to port 1900
    +  notifySocket.bind(1900, () => {
    +    try {
    +      // Prevent receipt of local SSDP M-SEARCH requests
    +      notifySocket.setMulticastLoopback(false)
    +
    +      // Join the SSDP multicast group so we can receive SSDP NOTIFY responses
    +      notifySocket.addMembership('239.255.255.250')
    +    }
    +    catch (e) {
    +      console.log('notifySocket.bind exception: %O', e)
    +    }
    +  })
    +
    +  // If the network connection drops, then no further NOTIFY responses
    +  // will be received on the bound port
    +  // Since there is no indication of a network connection failure,
    +  // after a predetermined timeout, close then re-establish the connection
    +  setTimeout( () => {
    +    try {
    +      notifySocket.close( () => {ssdpNotify(discoveryCallback)} )
    +    }
    +    catch (e) {
    +      console.log('Exception when trying to close socket: %O', e)
    +    }
    +  }, 5 * 60 * 1000 )
    +}
    +
    +// The SSDP protocol, which uses UDP datagrams, is inherently flaky
    +// M-SEARCH responses are not guaranteed to be received.
    +// To make allowances for this, send out multiple M-SEARCH requests
    +function ssdpSearch(discoveryCallback) {
    +  setTimeout(ssdpSearchRequest, 0, discoveryCallback)
    +  setTimeout(ssdpSearchRequest, 15000, discoveryCallback)
    +  setTimeout(ssdpSearchRequest, 30000, discoveryCallback)
    +}
    +
    +class VBDiscover {
    +
    +  // Initiate SSDP discovery
    +  static discover(discoveryCallback) {
    +    ssdpSearch(discoveryCallback)
    +    ssdpNotify(discoveryCallback)
    +  }
    +
    +  // Attempt to acquire device details from a user-entered, non-discovered
    +  // device, for which the serial number is unknown
    +  static ecp(ipAddr, discoveryCallback) {
    +    deviceDiscovered(ipAddr, '', discoveryCallback)
    +  }
    +
    +}
    +
    +module.exports = VBDiscover
    diff --git a/source/lib/VBDockPanel.js b/source/lib/VBDockPanel.js
    new file mode 100644
    index 0000000..217726e
    --- /dev/null
    +++ b/source/lib/VBDockPanel.js
    @@ -0,0 +1,396 @@
    +'use strict';
    +
    +/*
    + * Handle the panel containing the tab header, list panel and conn panels
    + *
    + */
    +
    +// Electron modules
    +const {ipcRenderer} = require('electron')
    +
    +// Local modules
    +const VBConfig = require('./VBConfig')
    +const VBListPanel = require('./VBListPanel')
    +const VBConnPanel = require('./VBConnPanel')
    +
    +class VBDockPanel {
    +
    +  constructor() {
    +
    +    // Instantiate a VBConfig object
    +    // Note that in each Renderer Process, the config data is read from
    +    // disk at startup, maintained locally while the program runs,
    +    // but NOT written to disk on shutdown
    +    // Only the VBConfig object maintained by the Main Process is
    +    // saved to disk upon shutdown
    +    // Therefore, any config changes made in a Renderer that need to be
    +    // persisted must be communicated to the Main process using IPCs
    +    this.vbConfig = new VBConfig()
    +
    +    // Read the config data, getting a reference to the JSON config info
    +    this.config = this.vbConfig.init()
    +
    +    if (!this.config) {
    +      ipcRenderer.send('fatal-error-dialog', 'Unable to read config file')
    +    }
    +
    +    console.log('Using config file:', this.config.configPath)
    +
    +    // The List Panel allows the user to choose a device to connect to
    +    // The onConnect callback is called whenever there is a new
    +    // connection to be made
    +    this.listPanel = new VBListPanel(this.onConnect.bind(this))
    +
    +    // Keep track of the next connId to be assigned
    +    this.nextConnId = 0
    +
    +    // Keep track of all connections
    +    // The connection map is indexed on connId,
    +    // with values of type ConnInfo
    +    this.connInfoList = new Map()
    +
    +    // Keep track of the currently displayed connection object
    +    this.selectedConn = null
    +
    +    // DOM shortcuts
    +    this.deviceListPage = null
    +    this.tabAdd = null
    +    this.tabStub = null
    +    this.tabHeader = null
    +    this.tabContentContainer = null
    +    this.connectButton = null
    +    this.connContainer = null
    +  }
    +
    +  // Initialize the List Panel
    +  // When the List Panel detects that a new connection should be made,
    +  // it invokes the connect callback
    +  init() {
    +
    +    this.listPanel.init()
    +
    +    // Assign DOM shortcuts
    +    this.deviceListPage       = document.getElementById('deviceListPage')
    +    this.tabAdd               = document.getElementById('tabAdd')
    +    this.tabStub              = document.getElementById('tabStub')
    +    this.tabHeader            = document.getElementById('tabHeader')
    +    this.tabContentContainer  = document.getElementById('tabContentContainer')
    +    this.connectButton        = document.getElementById('connectButton')
    +    this.connContainer        = document.getElementById('connContainer')
    +
    +    // Initially, the only tab header button will be the one for the List Panel
    +    this.createAddTabHeader()
    +
    +    // An unFloatTab IPC will be received from a floated tab connection
    +    // to instruct the Dock Panel to create a new connection to that
    +    // ip and port in the dock
    +    ipcRenderer.on('unFloatTab',  (e, ip, port) => this.unFloat(ip, port))
    +
    +    // Connection-specific event-handlers [from app menu or context menu]
    +
    +    ipcRenderer.on('closeTab',    (e, connId) =>
    +                                        this.doConn(connId, 'closeTab'))
    +    ipcRenderer.on('floatTab',    (e, connId) =>
    +                                        this.doConn(connId, 'floatTab'))
    +    ipcRenderer.on('logFile',     (e, connId) =>
    +                                        this.doConn(connId, 'logFile'))
    +    ipcRenderer.on('logResume',   (e, connId) =>
    +                                        this.doConn(connId, 'logResume'))
    +    ipcRenderer.on('logPause',    (e, connId) =>
    +                                        this.doConn(connId, 'logPause'))
    +    ipcRenderer.on('reConnect',   (e, connId) =>
    +                                        this.doConn(connId, 'reConnect'))
    +    ipcRenderer.on('clearScreen', (e, connId) =>
    +                                        this.doConn(connId, 'clearScreen'))
    +    ipcRenderer.on('clearLine',   (e, connId) =>
    +                                        this.doConn(connId, 'clearLine'))
    +
    +  }
    +
    +  // When a menu item pertaining to a connection is clicked,
    +  // dispatch to the appropriate connection object
    +  doConn(connId, eventName) {
    +    let conn = null
    +
    +    // A connId of -1 is used when an application menu item is selected,
    +    // in which case the currently-selected connection is used
    +    if (connId < 0 && this.selectedConn) {
    +      conn = this.selectedConn
    +    }
    +    else {
    +      // Get the connection object reference from the connId
    +      const connInfo = this.connInfoList.get(connId)
    +      if (connInfo) {
    +        conn = connInfo.conn
    +      }
    +    }
    +
    +    // Check that the connection id was assigned a connection object
    +    if (conn) {
    +      // If the connection no longer exists, event handlers will throw
    +      try {
    +        // Handle the close event and float event here
    +        // Route all other events to the conn object
    +        switch (eventName) {
    +          case 'closeTab':    return this.onConnTabClose(conn)
    +          case 'floatTab':    return this.onFloatTab(connId)
    +          case 'logFile':     return conn.onLogFile()
    +          case 'logResume':   return conn.onLogResume()
    +          case 'logPause':    return conn.onLogPause()
    +          case 'reConnect':   return conn.onReConnect()
    +          case 'clearScreen': return conn.onClearScreen()
    +          case 'clearLine':   return conn.onClearLine()
    +        }
    +      }
    +      catch(e) {
    +        console.log('Exception in doConn', e)
    +      }
    +    }
    +  }
    +
    +  // Callback function passed to the VBListPanel constructor
    +  // When the list panel detects that a new connection is to be made,
    +  // it will invoke this callback to create the Conn Panel
    +  onConnect(ip, port) {
    +    // Assign a connection id
    +    const connId = this.nextConnId
    +
    +    // Create a new connection panel
    +    const conn = new VBConnPanel(false, // isFloated=false
    +                                 ip, port,
    +                                 connId,
    +                                 this.onConnUp.bind(this),
    +                                 this.onConnDown.bind(this))
    +
    +    // Keep track of the new connection
    +    this.connInfoList.set(connId, new ConnInfo(conn))
    +    this.nextConnId = this.nextConnId + 1
    +
    +    const mapEntry = this.connInfoList.get(connId)
    +
    +    // Create a tab header button for the connection
    +    this.createConnHeader(ip, port, conn, connId)
    +
    +    // Initiate a new connection, creating the connection UI elements
    +    conn.init()
    +
    +    // Display the connection's tab, and hide all other tabs,
    +    // including the device list tab
    +    this.selectTab(mapEntry.headerPanel, conn)
    +  }
    +
    +  // Called when an IPC is received from a floated tab that is being unfloated
    +  // (re-docked) so that the Dock Panel can create a new connection tab
    +  // in the dock
    +  unFloat(ip, port) {
    +    this.onConnect(ip, port)
    +  }
    +
    +  // Create the tab header for the list screen, clicked for a new connection
    +  createAddTabHeader() {
    +    this.tabAdd.addEventListener('click', e => {
    +      this.onAddTabClick(e)
    +    })
    +  }
    +
    +  // Create the tab header for a connection, clicked to select that tab
    +  createConnHeader(ip, port, conn, connId) {
    +    // Clone the tab stub (class="tab")
    +    const headerClone = this.tabStub.cloneNode(true)
    +
    +    // Set the id attribute to the connId
    +    headerClone.setAttribute('id', 'conn-' + connId)
    +
    +    // Find the  tag that will contain the ip:port
    +    const tabIp = headerClone.querySelector('.tabIp')
    +
    +    this.connInfoList.get(connId).tabIp = tabIp
    +
    +    // Remove any data from the  tag used to contain ip:port
    +    while (tabIp.firstChild) {
    +      tabIp.removeChild(tabIp.firstChild)
    +    }
    +
    +    // Fill in the clone's ip address
    +    tabIp.appendChild(document.createTextNode(ip + ':' + port))
    +
    +    // Add onClick event listener for the tab header,
    +    // referencing the connection page that is being added.
    +    headerClone.addEventListener('click', (e) => {
    +      this.onConnTabClick(e, conn)
    +    })
    +
    +    const closeButton = headerClone.querySelector('.tabClose')
    +
    +    this.connInfoList.get(connId).closeButton = closeButton
    +
    +    // Add onClick event listener for the tab close ("X") button,
    +    // referencing the connection page that is being added.
    +    // To make sure a click event on the close button does not
    +    // also cause the click event for the tab header to fire,
    +    // call stopPropagation()
    +    closeButton.addEventListener('click', (e) => {
    +      e.stopPropagation()
    +      this.onConnTabClose(conn)
    +    })
    +
    +    // Add the context-menu (right-click) handler for the tab header
    +    headerClone.addEventListener('contextmenu', (e) => {
    +      // Disable the system right-click functionality (possibly not necessary)
    +      e.preventDefault()
    +      // Display the context menu
    +      conn.doContextMenu()
    +    })
    +
    +    // Add the tab either before the first existing tab, or after the last
    +    let headerPanel
    +    if (this.config.insertTabsAtEnd) {
    +      headerPanel = this.tabHeader.appendChild(headerClone)
    +      // Scroll to the end of the tab item list
    +      this.tabHeader.scrollLeft = this.tabHeader.scrollWidth
    +    }
    +    else {
    +      headerPanel = this.tabHeader.insertBefore(headerClone,
    +                                                    this.tabAdd.nextSibling)
    +      // Scroll to the start of the tab item list
    +      this.tabHeader.scrollLeft = 0
    +    }
    +    this.connInfoList.get(connId).headerPanel = headerPanel
    +
    +    // Make the connection header visible
    +    headerClone.style.display = 'flex'
    +
    +    // Update selectedConn with the new connection object
    +    this.selectedConn = conn
    +  }
    +
    +  // Click event handler for a connection's tab item header
    +  onConnTabClick(e, conn) {
    +    // Remove "selected" from all other tabs' classes
    +    // Select this tab's class to include "selected"
    +    // Set all other (or current) tabs' display to 'none'
    +    // Set this tab's display to 'block'.
    +    if (e.target) {
    +      this.selectTab(e.target, conn)
    +    }
    +  }
    +
    +  // Click event handler invoked when the tab close ("X") button is clicked
    +  onConnTabClose(conn) {
    +    // Reset selectedConn
    +    this.selectedConn = null
    +
    +    // Remove the conn's tab header from the UI
    +    const headerPanel = this.connInfoList.get(conn.connId).headerPanel
    +    //tabHeader.removeChild(headerPanel)
    +    headerPanel.remove()
    +
    +    // Set the Add tab header to selected and display the Add tab
    +    this.tabAdd.click()
    +
    +    // Scroll to the start of the tab item list
    +    this.tabHeader.scrollLeft = 0
    +
    +    // Terminate the connection, which will in turn destroy the socket
    +    conn.terminate()
    +
    +    // Remove the reference to the connection from the connection list
    +    this.connInfoList.delete(conn.connId)
    +  }
    +
    +  onConnUp(connId) {
    +    // Remove the tabConnDown class from the tab's header
    +    const connInfo = this.connInfoList.get(connId)
    +    if (connInfo) {
    +      connInfo.tabIp.classList.remove('tabConnDown')
    +    }
    +  }
    +
    +  onConnDown(connId) {
    +    // Add the tabConnDown class from the tab's header
    +    const connInfo = this.connInfoList.get(connId)
    +    if (connInfo) {
    +      connInfo.tabIp.classList.add('tabConnDown')
    +    }
    +  }
    +
    +  selectTab(target, conn) {
    +    // De-select all tabs
    +    for (let tab of this.tabHeader.getElementsByClassName('selected')) {
    +      tab.classList.remove('selected')
    +    }
    +
    +    // Select this tab
    +    target.closest('.tab').classList.add('selected')
    +
    +    // Hide all tab pages (including list page) except this connection tab
    +    for (let page of this.tabContentContainer.getElementsByClassName('page')) {
    +      page.style.display = 'none'
    +    }
    +
    +    // Make sure the connContainer is visible
    +    this.connContainer.style.display = 'block'
    +
    +    // Update selectedConn
    +    this.selectedConn = conn
    +
    +    // Make sure the connection panel for this particular connection is visible
    +    conn.displayConnTab()
    +  }
    +
    +  // Click event handler for the new-connection tab item header
    +  onAddTabClick(e) {
    +    // Remove "selected" from all other tabs' classes
    +    // Select this tab's class to include "selected"
    +    // Set all other (or current) tabs' display to 'none'
    +    // Set this tab's display to 'block'
    +    if (e.target) {
    +      // Reset selectedConn
    +      this.selectedConn = null
    +
    +      // Remove 'selected' class from any currently-selected tab
    +      for (let tab of this.tabHeader.getElementsByClassName('selected')) {
    +        tab.classList.remove('selected')
    +      }
    +
    +      // Add 'selected' class to the Add tab
    +      this.tabAdd.classList.add('selected')
    +
    +      // Hide the connection tabs
    +      for (let page of this.tabContentContainer.
    +                            getElementsByClassName('page')) {
    +        page.style.display = 'none'
    +      }
    +
    +      // Hide the connection tabs' container
    +      this.connContainer.style.display = 'none'
    +
    +      // Display the Add tab
    +      this.deviceListPage.style.display = 'block'
    +
    +      // Set the keyboard focus to the Connect button
    +      this.connectButton.focus()
    +    }
    +  }
    +
    +  // Context menu event handler for float tab
    +  onFloatTab(connId) {
    +    const connInfo = this.connInfoList.get(connId)
    +
    +    // Get the Main Process to create the new floating tab BrowserWindow
    +    ipcRenderer.send('float-tab', connInfo.conn.ip, connInfo.conn.port)
    +
    +    // Close the docked connection tab
    +    connInfo.closeButton.click()
    +  }
    +
    +}
    +
    +function ConnInfo(conn) {
    +  this.conn = conn
    +  this.tabIp = null
    +  this.headerPanel = null
    +  this.closeButton = null
    +}
    +
    +module.exports  = VBDockPanel
    diff --git a/source/lib/VBFloatPanel.js b/source/lib/VBFloatPanel.js
    new file mode 100644
    index 0000000..604563b
    --- /dev/null
    +++ b/source/lib/VBFloatPanel.js
    @@ -0,0 +1,126 @@
    +'use strict';
    +
    +/*
    + * Handle the panel containing the tab header, list panel and conn panels
    + *
    + */
    +
    +// Electron modules
    +const {ipcRenderer, remote} = require('electron')
    +
    +// Node.js modules
    +const path = require('path')
    +
    +// Local modules
    +const VBConnPanel = require('./VBConnPanel')
    +
    +// Performs a similar function to the DockPanel, just with a single connection
    +class VBFloatPanel {
    +
    +  constructor(ip, port) {
    +
    +    this.ip = ip
    +    this.port = port
    +    this.connId = 1
    +
    +    // Create a new connection panel
    +    this.conn = new VBConnPanel(true, // isFloated
    +                                ip, port,
    +                                this.connId,
    +                                this.onConnUp.bind(this),
    +                                this.onConnDown.bind(this))
    +
    +  }
    +
    +  init() {
    +
    +    // Initialize the connection panel
    +    this.conn.init()
    +
    +    // Connection-specific event-handlers [from context menu]
    +
    +    ipcRenderer.on('closeTab',     (e, connId) => this.doConn('closeTab'))
    +    ipcRenderer.on('logFile',      (e, connId) => this.doConn('logFile'))
    +    ipcRenderer.on('logResume',    (e, connId) => this.doConn('logResume'))
    +    ipcRenderer.on('logPause',     (e, connId) => this.doConn('logPause'))
    +    ipcRenderer.on('reConnect',    (e, connId) => this.doConn('reConnect'))
    +    ipcRenderer.on('unFloatTab',   (e, connId) => this.doConn('unFloatTab'))
    +    ipcRenderer.on('clearScreen',  (e, connId) => this.doConn('clearScreen'))
    +    ipcRenderer.on('clearLine',    (e, connId) => this.doConn('clearLine'))
    +
    +  }
    +
    +  // When a menu item pertaining to a connection is clicked,
    +  // dispatch to the appropriate connection object
    +  doConn(eventName) {
    +
    +    // Check that the connection id was assigned a connection object
    +    if (this.conn) {
    +      // If the connection no longer exists, event handlers will throw
    +      try {
    +        // Handle the close event here; route all others to the conn object
    +        switch (eventName) {
    +          case 'closeTab':    return this.onConnTabClose()
    +          case 'unFloatTab':  return this.onUnFloatTab()
    +          case 'logFile':     return this.conn.onLogFile()
    +          case 'logResume':   return this.conn.onLogResume()
    +          case 'logPause':    return this.conn.onLogPause()
    +          case 'reConnect':   return this.conn.onReConnect()
    +          case 'clearScreen': return this.conn.onClearScreen()
    +          case 'clearLine':   return this.conn.onClearLine()
    +        }
    +      }
    +      catch(e) {
    +        console.log('Exception in VBFloatPanel.doConn', e)
    +      }
    +    }
    +  }
    +
    +  // The context menu will cause an unFloatTab IPC to be sent to
    +  // the Float Panel, in response to which the connection should
    +  // be terminated and the Float Panel should quit.
    +  // The Dock Panel will establish a new connection in the dock
    +  // in response to a separate IPC sent to the Dock Panel
    +  onUnFloatTab() {
    +    // Close the FloatPanel
    +    this.terminate()
    +  }
    +
    +  // Context-menu 'close-handler'
    +  onConnTabClose() {
    +    this.terminate()
    +  }
    +
    +  // Close the float panel
    +  terminate() {
    +    // Terminate the connection and dispose of the socket object
    +    this.conn.terminate()
    +
    +    // Dereference the conn panel object
    +    this.conn = null
    +
    +    // Close the window, freeing any remaining resources
    +    remote.getCurrentWindow().close()
    +  }
    +
    +  // When the connection is up, the window title should be ip:port
    +  onConnUp() {
    +    const win = remote.getCurrentWindow()
    +    win.setTitle(this.ip + ':' + this.port)
    +  }
    +
    +  // When the connection is down, append [Not Connected] to window title
    +  onConnDown() {
    +    const win = remote.getCurrentWindow()
    +    win.setTitle(this.ip + ':' + this.port + ' [Not Connected]')
    +  }
    +
    +}
    +
    +function ConnInfo(conn) {
    +  this.conn = conn
    +  this.tabIp = null
    +  this.headerPanel = null
    +}
    +
    +module.exports  = VBFloatPanel
    diff --git a/source/lib/VBHistory.js b/source/lib/VBHistory.js
    new file mode 100644
    index 0000000..6d0c464
    --- /dev/null
    +++ b/source/lib/VBHistory.js
    @@ -0,0 +1,201 @@
    +'use strict';
    +
    +const VBKeys = require('./VBKeys')
    +
    +class VBHistory {
    +
    +  constructor() {
    +    // List of previous commands entered. Every time the Enter key is pressed,
    +    // an entry is added to the end of the history buffer
    +    this.historyBuffer = new Array()
    +
    +    // The history index always indicates the index in the history buffer
    +    // where the next entry will be written (if at the end of the buffer),
    +    // or the index of the last item viewed when using the up/down keys
    +    // to navigate through the history buffer
    +    this.historyIndex = 0
    +
    +    // A kludgy way of keeping track of whether the user enters a
    +    // previously-selected history item when navigating the history buffer
    +    this.prevText = ''
    +
    +    // A flag set when text is entered while navigating through
    +    // the history buffer, if a previously-selected history item is entered
    +    this.redo = false
    +
    +    // When navigating command history using the Tab/Shift-Tab keys,
    +    // keeps track of where we are in the history buffer
    +    // Resets when the Enter key is pressed
    +    this.tabIndex = -1
    +
    +    // When a sequence of Tab/Shift-Tab presses is used for command completion,
    +    // keeps track of what the user had entered before the first tab press
    +    this.tabText = ''
    +
    +    // Used to detect whether user changes the command line while
    +    // cycling through tab completions
    +    this.prevTabText = ''
    +
    +  }
    +
    +  emptyString(s) {
    +    return s.length === 0 || !s.trim()
    +  }
    +
    +  doEnter(lineIn) {
    +    if (!this.emptyString(lineIn)) {
    +      if (this.historyIndex === this.historyBuffer.length) {
    +        // Not going through history, add text to end, set index to past end
    +        this.redo = false
    +        ++this.historyIndex
    +      }
    +      else if (lineIn === this.prevText) {
    +        // Going through history, picked a prior history item,
    +        // don't change index
    +        this.redo = true
    +      }
    +      else {
    +        // Going through history, changed a prior history item
    +        this.redo = false
    +        this.historyIndex = this.historyBuffer.length + 1
    +      }
    +      this.historyBuffer.push(lineIn)
    +    }
    +    // Reset for tab-completion
    +    this.tabIndex = -1
    +    this.tabText = ''
    +    this.prevTabText = ''
    +    return null
    +  }
    +
    +  doUp(lineIn) {
    +    let lineOut = null
    +    if (this.historyBuffer.length > 0) {
    +        if (!this.redo && this.historyIndex > 0) {
    +            --this.historyIndex
    +        }
    +        this.redo = false
    +        this.prevText = this.historyBuffer[this.historyIndex]
    +        lineOut = this.historyBuffer[this.historyIndex]
    +    }
    +    return lineOut
    +  }
    +
    +  doDown(lineIn) {
    +    let lineOut = null
    +    if (this.historyBuffer.length > 0) {
    +      if (this.historyIndex < this.historyBuffer.length - 1) {
    +        ++this.historyIndex
    +        lineOut = this.historyBuffer[this.historyIndex]
    +        this.prevText = this.historyBuffer[this.historyIndex]
    +      }
    +      this.redo = false
    +    }
    +    return lineOut
    +  }
    +
    +  doPgUp(lineIn) {
    +    let lineOut = null
    +    if (this.historyBuffer.length > 0) {
    +      this.redo = false
    +      this.historyIndex = 0
    +      lineOut = this.historyBuffer[0]
    +    }
    +    return lineOut
    +  }
    +
    +  doPgDown(lineIn) {
    +    let lineOut = null
    +    if (this.historyBuffer.length > 0) {
    +      this.redo = false
    +      this.historyIndex = this.historyBuffer.length - 1
    +      lineOut = this.historyBuffer[this.historyIndex]
    +    }
    +    return lineOut
    +  }
    +
    +  doTab(lineIn) {
    +    let lineOut = null
    +    if (!this.emptyString(lineIn) && this.historyBuffer.length > 0) {
    +      // If we are just starting a sequence of Tab completions,
    +      // use the current input text and history index
    +      if (this.tabIndex === -1) {
    +        this.tabIndex = this.historyIndex
    +        this.tabText = lineIn
    +      }
    +      // Detect whether the user changed the command line while
    +      // cycling through tab completions
    +      else if (lineIn !== this.prevTabText) {
    +        this.tabText = lineIn
    +      }
    +      // Search the history buffer cyclically
    +      for (let i of this.historyBuffer) {
    +        // If we've reached the start of the history buffer,
    +        //resume from the end of the buffer
    +        if (--this.tabIndex < 0) {
    +          this.tabIndex = this.historyBuffer.length - 1
    +        }
    +        // Check if the item being examined starts with the input string
    +        if (this.historyBuffer[this.tabIndex].startsWith(this.tabText)) {
    +          lineOut = this.historyBuffer[this.tabIndex]
    +          // We found a match; we're done
    +          this.prevTabText = this.historyBuffer[this.tabIndex]
    +          break
    +        }
    +      }
    +    }
    +    return lineOut
    +  }
    +
    +  doShiftTab(lineIn) {
    +    let lineOut = null
    +    if (!this.emptyString(lineIn) && this.historyBuffer.length > 0) {
    +      // If we are just starting a sequence of Tab completions,
    +      // use the current input text and history index
    +      if (this.tabIndex === -1) {
    +        this.tabIndex = this.historyIndex
    +        this.tabText = lineIn
    +      }
    +      // Detect whether the user changed the command line
    +      // while cycling through tab completions
    +      else if (lineIn !== this.prevTabText) {
    +        this.tabText = lineIn
    +      }
    +      // Search the history buffer cyclically
    +      for (let i of this.historyBuffer) {
    +        // If we've reached the start of the history buffer,
    +        // resume from the end of the buffer
    +        if (++this.tabIndex >= this.historyBuffer.length - 1) {
    +          this.tabIndex = 0
    +        }
    +        // Check if the item being examined starts with the input string
    +        if (this.historyBuffer[this.tabIndex].startsWith(this.tabText)) {
    +          lineOut = this.historyBuffer[this.tabIndex]
    +          // We found a match; we're done
    +          this.prevTabText = this.historyBuffer[this.tabIndex]
    +          break
    +        }
    +      }
    +    }
    +    return lineOut
    +  }
    +
    +  keydown(keyVal, lineIn) {
    +
    +    switch (keyVal) {
    +      case VBKeys.ENTER:    return this.doEnter(lineIn)
    +      case VBKeys.UP:       return this.doUp(lineIn)
    +      case VBKeys.DOWN:     return this.doDown(lineIn)
    +      case VBKeys.PGUP:     return this.doPgUp(lineIn)
    +      case VBKeys.PGDN:     return this.doPgDown(lineIn)
    +      case VBKeys.TAB:      return this.doTab(lineIn)
    +      case VBKeys.SHTAB:    return this.doShiftTab(lineIn)
    +    }
    +
    +    return null
    +
    +  }
    +
    +}
    +
    +module.exports = VBHistory
    diff --git a/source/lib/VBIcons.js b/source/lib/VBIcons.js
    new file mode 100644
    index 0000000..41ac4e0
    --- /dev/null
    +++ b/source/lib/VBIcons.js
    @@ -0,0 +1,22 @@
    +'use strict';
    +
    +// Node.js modules
    +const path = require('path')
    +
    +// Electron modules
    +const {nativeImage} = require('electron')
    +
    +class VBIcons {
    +}
    +
    +VBIcons.icon = null
    +
    +if (process.platform === 'win32') {
    +  VBIcons.icon = path.join(__dirname, '..', 'images', 'icon.ico')
    +}
    +else {
    +  VBIcons.icon = nativeImage.createFromPath(
    +                             path.join(__dirname, '..', 'images', 'icon.png'))
    +}
    +
    +module.exports  = VBIcons
    diff --git a/source/lib/VBKeys.js b/source/lib/VBKeys.js
    new file mode 100644
    index 0000000..d0efe38
    --- /dev/null
    +++ b/source/lib/VBKeys.js
    @@ -0,0 +1,68 @@
    +'use strict';
    +
    +class VBKeys {
    +
    +  static keyVal(e) {
    +
    +    const key     = e.code
    +
    +    const alt     = e.altKey.valueOf()
    +    const ctrl    = e.ctrlKey.valueOf()
    +    const meta    = e.metaKey.valueOf()
    +    const shift   = e.shiftKey.valueOf()
    +
    +    const noSACM  = !(shift || alt || ctrl || meta)
    +
    +    // Keys without modifiers
    +    if (noSACM) {
    +      if (key === 'Enter')      return this.ENTER
    +      if (key === 'Escape')     return this.ESCAPE
    +      if (key === 'ArrowUp')    return this.UP
    +      if (key === 'ArrowDown')  return this.DOWN
    +      if (key === 'PageUp')     return this.PGUP
    +      if (key === 'PageDown')   return this.PGDN
    +      if (key === 'Tab')        return this.TAB
    +      return this.OTHER
    +    }
    +
    +    // Keys with modifiers
    +    if (key === 'Tab'  &&  shift && !alt && !ctrl && !meta) return this.SHTAB
    +    if (key === 'KeyC' && !shift && !alt &&  ctrl && !meta) return this.CTRLC
    +    if (key === 'KeyC' && !shift &&  alt && !ctrl && !meta) return this.ALTC
    +
    +    // Shortcut keys
    +    if ((key >= 'Digit0' && key <= 'Digit9') &&
    +        (!shift && !alt && ctrl && !meta)) {
    +      return e.key - '0' + this.CTRL0
    +    }
    +
    +    return this.OTHER
    +
    +  }
    +
    +}
    +
    +VBKeys.OTHER    = 0
    +VBKeys.ENTER    = 1
    +VBKeys.ESCAPE   = 2
    +VBKeys.UP       = 3
    +VBKeys.DOWN     = 4
    +VBKeys.PGUP     = 5
    +VBKeys.PGDN     = 6
    +VBKeys.TAB      = 7
    +VBKeys.DEL      = 8
    +VBKeys.SHTAB    = 9
    +VBKeys.CTRLC    = 10
    +VBKeys.ALTC     = 11
    +VBKeys.CTRL0    = 20
    +VBKeys.CTRL1    = 21
    +VBKeys.CTRL2    = 22
    +VBKeys.CTRL3    = 23
    +VBKeys.CTRL4    = 24
    +VBKeys.CTRL5    = 25
    +VBKeys.CTRL6    = 26
    +VBKeys.CTRL7    = 27
    +VBKeys.CTRL8    = 28
    +VBKeys.CTRL9    = 29
    +
    +module.exports  = VBKeys
    diff --git a/source/lib/VBListPanel.js b/source/lib/VBListPanel.js
    new file mode 100644
    index 0000000..b98f1b5
    --- /dev/null
    +++ b/source/lib/VBListPanel.js
    @@ -0,0 +1,508 @@
    +'use strict';
    +
    +/*
    + * Handle the panel containing the discovered device list
    + */
    +
    +// Electron modules
    +const {ipcRenderer} = require('electron')
    +
    +// Local modules
    +const VBKeys = require('./VBKeys')
    +const VBConfig = require('./VBConfig')
    +const VBDiscover = require('./VBDiscover')
    +
    +class VBListPanel {
    +
    +  constructor(connectCallback) {
    +
    +    // Instantiate a VBConfig object
    +    this.vbConfig = new VBConfig()
    +
    +    // Connection callback
    +    this.connectCallback = connectCallback
    +
    +    // snTable is a Map() using Serial Number as the key
    +    // The value of the Map() is an SnEntry
    +    // snTable is ONLY updated when an ECP response is received
    +    this.snTable = new Map()
    +
    +    // Keep track of deleted device SNs so they are not re-added on discovery
    +    this.deletedDeviceList = []
    +
    +    // Shortcuts to DOM elements
    +    this.deviceListPage     = document.getElementById('deviceListPage')
    +    this.tabAdd             = document.getElementById('tab-Add')
    +    this.connectButton      = document.getElementById('connectButton')
    +    this.connectToIp        = document.getElementById('connectToIp')
    +    this.connectToPort      = document.getElementById('connectToPort')
    +    this.discoveredDevices  = document.getElementById('discoveredDevices')
    +    this.deviceTableBody    = document.getElementById('deviceTableBody')
    +    this.friendlyName       = document.getElementById('friendlyName')
    +    this.serialNumber       = document.getElementById('serialNumber')
    +    this.modelNumber        = document.getElementById('modelNumber')
    +    this.modelName          = document.getElementById('modelName')
    +
    +  }
    +
    +  // Create the list panel UI components and initiate device discovery
    +  init() {
    +    // Read the config data, getting a reference to the JSON config info
    +    this.config = this.vbConfig.init()
    +
    +    if (!this.config) {
    +      ipcRenderer.send('fatal-error-dialog', 'Unable to read config file')
    +      return
    +    }
    +
    +    // Restore the saved list of devices (from the config object)
    +    this.snTable = this.restoreDeviceList()
    +
    +    // Fill in the last connected device and port
    +    this.connectToIp.value = this.config.lastConnectedIp
    +    this.connectToPort.value = this.config.lastConnectedPort
    +
    +    // Update the UI based on the restored device list
    +    this.updateUIDevices()
    +
    +    // Add Connect button event listener
    +    this.connectButton.addEventListener('click',
    +      e => this.onConnectClick(e)
    +    )
    +
    +    // Clicking a device list entry will select it and display its details
    +    this.discoveredDevices.addEventListener('click',
    +      e => this.onDeviceListClick(e)
    +    )
    +
    +    // Double-clicking a device list entry will connect to it
    +    this.discoveredDevices.addEventListener('dblclick',
    +      e => this.onDeviceListDblClick(e)
    +    )
    +
    +    // The friendly name field for a device entry can be changed by the user
    +    this.friendlyName.addEventListener('input',
    +      e => this.onFriendlyNameChanged(e)
    +    )
    +
    +    // Prevent text selection on double-clicking a device list entry
    +    this.discoveredDevices.addEventListener('mousedown', e => {
    +      e.preventDefault()
    +      return false
    +    })
    +
    +    // Set the initial keyboard focus to the Connect button
    +    this.connectButton.focus()
    +
    +    // Add an event handler to the device list page to generate a click event
    +    // on the Connect button whenever the Enter key is pressed
    +    this.deviceListPage.addEventListener('keydown', e => {
    +      if (VBKeys.keyVal(e) === VBKeys.ENTER) {
    +        // Check whether a device list row is focused; if so, select that item
    +        const elem = document.activeElement
    +        if (elem.tagName === 'TR' && elem.id.startsWith('sn-')) {
    +          elem.click()
    +        }
    +        this.connectButton.click()
    +      }
    +    })
    +
    +    // Initiate SSDP discovery
    +    // Make sure not to start discovery until List panel set up
    +    VBDiscover.discover(this.onDiscoveredDevice.bind(this))
    +  }
    +
    +  // Convert the snTable Map() object into a JSON string
    +  snTableToJSONString() {
    +    return JSON.stringify([...this.snTable])
    +  }
    +
    +  // Read the saved list of discovered devices to populate the SN table
    +  restoreDeviceList() {
    +    try {
    +      // When setting up config must convert JSON stringified array list to Map
    +      return new Map(JSON.parse(this.config.snTable))
    +    }
    +    catch (e) {
    +      console.log('Exception restoring device list:', e)
    +      return new Map()
    +    }
    +  }
    +
    +  getDeviceDetailsForIP(ip) {
    +    for (let snEntry of this.snTable.values()) {
    +      if (snEntry.ipAddr === ip) {
    +        return snEntry
    +      }
    +    }
    +    return null
    +  }
    +
    +  // The device table is sorted then used to update the UI
    +  // This function should only be called when there is
    +  // a change to the device table
    +  updateUIDevices() {
    +
    +    // Sort the device table by ip address
    +    const sorted = [...this.snTable.values()].sort((a, b) => a.ip32 - b.ip32)
    +
    +    // Remove all discovered device entries from display
    +    while (this.deviceTableBody.firstChild) {
    +      this.deviceTableBody.removeChild(this.deviceTableBody.firstChild)
    +    }
    +
    +    // Add discovered device entries from the new sorted table
    +    for (let entry of sorted) {
    +      const tr = document.createElement('TR')
    +
    +      // Needs to have tabIndex set in order to receive keydown events
    +      tr.tabIndex = 0
    +
    +      // Give the row an id based on the device serial number
    +      tr.id = 'sn-' + entry.serialNumber
    +
    +      tr.addEventListener('keydown',
    +        e => this.onDeviceListKeydown(e)
    +      )
    +
    +      // IP address
    +      const tdIp = document.createElement('TD')
    +      tdIp.appendChild(document.createTextNode(entry.ipAddr))
    +      tdIp.classList.add('ipAddr')
    +
    +      // Friendly name
    +      const tdFn = document.createElement('TD')
    +      tdFn.appendChild(document.createTextNode(entry.friendlyName))
    +
    +      // Delete button (use a circle with an "x" in the middle)
    +      const tdDel = document.createElement('TD')
    +      tdDel.appendChild(document.createTextNode('\u24e7'))
    +      tdDel.classList.add('del')
    +      tdDel.addEventListener('click', e => {
    +        // Don't want this registering as a click to select the device
    +        e.stopPropagation()
    +        // Add the delete device event-handler
    +        this.deleteDevice(entry.serialNumber)
    +      })
    +
    +      tr.appendChild(tdIp)
    +      tr.appendChild(tdFn)
    +      tr.appendChild(tdDel)
    +
    +      this.deviceTableBody.appendChild(tr)
    +    }
    +
    +    // Fill in the selected device details for the last selected device
    +    this.displaySelectedDevice(this.connectToIp.value)
    +  }
    +
    +  // Fill in information for the selected device
    +  // Use the Serial Number table, keyed on serial number
    +  displaySelectedDevice(ip) {
    +    const device = this.getDeviceDetailsForIP(ip)
    +
    +    // Update Selected Device details fields
    +    if (device) {
    +      this.friendlyName.value  = device.friendlyName
    +      this.serialNumber.value  = device.serialNumber
    +      this.modelNumber.value   = device.modelNumber
    +      this.modelName.value     = device.modelName
    +    }
    +    else {
    +      this.friendlyName.value  = ' '
    +      this.serialNumber.value  = ' '
    +      this.modelNumber.value   = ' '
    +      this.modelName.value     = ' '
    +    }
    +
    +    // If we found the device in the discovered device list,
    +    // mark it as selected, marking all other devices as not selected
    +    const trList = this.discoveredDevices.getElementsByTagName('tr')
    +    for (let i = 0; i < trList.length; i++) {
    +      const tr = trList[i]
    +      if (tr) {
    +        if (device && 'sn-' + device.serialNumber === tr.id) {
    +          tr.classList.add('selectedDevice')
    +        }
    +        else {
    +          tr.classList.remove('selectedDevice')
    +        }
    +      }
    +    }
    +    return device
    +  }
    +
    +  // Delete a device from the discovered device list
    +  // Deleted devices will not be re-added when a discovery response is received
    +  // The deleted device list is not persisted across sessions
    +  deleteDevice(serialNumber) {
    +    // Add device to deleted device list if not already there
    +    // Can't think of a case where this would not be true,
    +    // but handle it anyway
    +    if (this.deletedDeviceList.indexOf(serialNumber) === -1) {
    +      this.deletedDeviceList.push(serialNumber)
    +    }
    +    this.snTable.delete(serialNumber)
    +    this.updateUIDevices()
    +    // Send an IPC to the Main Process so it can update its copy
    +    // of the discoveredDeviceList, which will be persisted to disk on shutdown
    +    ipcRenderer.send('device-list', this.snTableToJSONString())
    +  }
    +
    +  // Update a serial number table entry
    +  // Return true if the updated entry differs from the previous entry
    +  updateSnEntry(snEntry, device) {
    +    const oldIpAddr       = snEntry.ipAddr
    +    const oldFriendlyName = snEntry.friendlyName
    +    const oldModelName    = snEntry.modelName
    +    const oldModelNumber  = snEntry.modelNumber
    +
    +    // If the IP address has changed, calculate the 32-bit ip value
    +    if (snEntry.ipAddr !== device.ipAddr) {
    +      snEntry.ipAddr = device.ipAddr
    +      snEntry.ip32 = ipAddrTo32(device.ipAddr)
    +    }
    +
    +    // Don't update the friendly name unless it was previously blank,
    +    // because the user could have updated the friendly name manually
    +    if (snEntry.friendlyName === '') {
    +      snEntry.friendlyName = device.friendlyName
    +    }
    +
    +    // Don't update the table with blank values for model name/number,
    +    // which would happen if an SSDP Notify was received,
    +    // but no response could be obtained
    +    if (device.modelName !== '') {
    +      snEntry.modelName = device.modelName
    +    }
    +
    +    if (device.modelNumber !== '') {
    +      snEntry.modelNumber = device.modelNumber
    +    }
    +
    +    // Return true if any field has changed in value (SN does not change)
    +    return !( snEntry.ipAddr       === oldIpAddr        &&
    +              snEntry.friendlyName === oldFriendlyName  &&
    +              snEntry.modelName    === oldModelName     &&
    +              snEntry.modelNumber  === oldModelNumber )
    +  }
    +
    +  // Called when an ECP response has been received from a device
    +  // Search for an entry containing the serial number
    +  // If an entry with the serial number is found,
    +  // check whether there is another entry with the same IP address;
    +  // if so, delete it
    +  // Update the IP address, but don't update the existing fields
    +  // unless they are blank [The user may have changed them. e.g. friendlyName]
    +  // If serial number was not found, create a new entry,
    +  // also check whether that ip address exists for another entry,
    +  // and if so delete the other entry
    +  onDiscoveredDevice(details) {
    +    let changed = false
    +
    +    // Don't include devices in the deleted devices table
    +    if (this.deletedDeviceList.indexOf(details.serialNumber) === -1) {
    +      // Check whether the serial number is already in the table
    +      if (this.snTable.has(details.serialNumber)) {
    +        // Update an existing snTable entry
    +        const snEntry = this.snTable.get(details.serialNumber)
    +        const updated = this.updateSnEntry(snEntry, details)
    +        this.snTable.set(details.serialNumber, snEntry)
    +        if (updated) {
    +          changed = true
    +        }
    +      }
    +      else {
    +        // New serial number -- add new device
    +        this.snTable.set(details.serialNumber, new SnEntry(details))
    +        changed = true
    +      }
    +
    +      // Check whether the ip address was previously assigned
    +      // to another device; if so, delete the old entry
    +      for (let snItem in this.snTable) {
    +        // Ignore the current entry
    +        if (snItem.serialNumber !== details.serialNumber) {
    +          if (snItem.ipAddr === details.ipAddr) {
    +            this.snTable.delete(snItem)
    +            changed = true
    +            break
    +          }
    +        }
    +      }
    +
    +      // If the device table has changed, update the UI
    +      if (changed) {
    +        this.updateUIDevices()
    +
    +        // Send an IPC to the Main Process so it can update its copy of the
    +        // discoveredDeviceList, which will be persisted to disk on shutdown
    +        ipcRenderer.send('device-list', this.snTableToJSONString())
    +      }
    +    }
    +  }
    +
    +  // The Connect button has been clicked
    +  // Establish a new connection with the specified device
    +  onConnectClick(e) {
    +    // Invalid ip address will be returned with ip32 property of zero
    +    const ip = validIpAddr(this.connectToIp.value)
    +
    +    // Invalid port will be returned as zero
    +    const port = validPort(this.connectToPort.value)
    +
    +    // If the ip address and port are valid, attempt to establish a connection
    +    if (ip && port) {
    +      // Fill in the selected device details
    +      if (!this.displaySelectedDevice(ip)) {
    +        // If the IP does not exist in the discovered device table,
    +        // send off an ECP request to get its details
    +        VBDiscover.ecp(ip, this.onDiscoveredDevice.bind(this))
    +      }
    +
    +      // The connect callback executes in the DockPanel's context
    +      this.connectCallback(ip, port)
    +
    +      // Send IPC to main process so its config object can be updated
    +      // to record the last connected device and port
    +      ipcRenderer.send('update-last-connected-device', ip, port.toString())
    +    }
    +  }
    +
    +  // If a discovered device list entry is clicked, then fill in
    +  // the 'Connect To' ip address and discovered device details
    +  onDeviceListClick(e) {
    +    let ip = ''
    +    const tr = e.target.closest('tr')
    +    if (tr !== null) {
    +      const elem = tr.querySelector('td.ipAddr')
    +      if (elem !== null) {
    +        tr.focus()
    +        ip = elem.firstChild.nodeValue
    +      }
    +    }
    +    if (ip) {
    +      // Fill in Connect To ip address
    +      this.connectToIp.value = ip
    +
    +      // Fill in the selected device details
    +      this.displaySelectedDevice(ip)
    +    }
    +  }
    +
    +  // If a discovered device list entry is double-clicked,
    +  // then connect to that device
    +  // Note that a 'click' event will have been raised just before this event
    +  onDeviceListDblClick(e) {
    +    this.connectButton.click()
    +  }
    +
    +  // Key Up/Down handler for discovered devices list
    +  onDeviceListKeydown(e) {
    +    // Only handle keydown events on  elements
    +    if (e.target && e.target.nodeName === 'TR') {
    +      const tr = e.target
    +      let sibling = null
    +      // Get the key value (only handle Up and Down)
    +      const keyVal = VBKeys.keyVal(e)
    +      switch (keyVal) {
    +        case VBKeys.UP:
    +          e.preventDefault()
    +          // Click on the previous 
  • 's descendant + sibling = tr.previousElementSibling + if (sibling) { + // Generate a click event on the previous
  • element + sibling.focus() + sibling.click() + } + break + case VBKeys.DOWN: + e.preventDefault() + // Click on the next
  • 's descendant + sibling = tr.nextElementSibling + if (sibling) { + // Generate a click event on the next
  • element + sibling.focus() + sibling.click() + } + break + } + } + } + + onFriendlyNameChanged(e) { + const tr = this.discoveredDevices.querySelector('.selectedDevice') + // Get the serial number from the id attribute + if (tr) { + // The id is of the form "id-serialNumber" + const sn = tr.id.substring(3) + if (sn) { + // Get the device table entry for that serial number + const snEntry = this.snTable.get(sn) + // Only update if the friendly name has changed + if (snEntry && snEntry.friendlyName !== this.friendlyName.value) { + // Update the discovered device table with the new friendly name + snEntry.friendlyName = this.friendlyName.value + this.snTable.set(sn, snEntry) + // Update the discovered device list with the new friendly name + this.updateUIDevices() + // Send an IPC to the Main Process so it can update its copy of the + // discoveredDeviceList, which will be persisted to disk on shutdown + ipcRenderer.send('device-list', this.snTableToJSONString()) + } + } + } + } + +} + +// Validate and return an ip address string +// Return an empty string if the ip address string is invalid +function validIpAddr(ipAddr) { + let ipAddrReturn = ipAddr.trim() + if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ipAddrReturn)) { + ipcRenderer.send('error-dialog', 'Invalid IP address') + ipAddrReturn = '' + } + return ipAddrReturn +} + +// Validate a string representation of a port number. +// A port number must be between 1 and 65535. +// Return the port number if valid. +// Return zero for an invalid port number +function validPort(port) { + let portReturn = parseInt(port, 10) + if (!(!isNaN(portReturn) && portReturn > 0 && portReturn < 65536)) { + ipcRenderer.send('error-dialog', 'Invalid Port') + portReturn = 0 + } + return portReturn +} + +// Convert an ip address of the form nnn.nnn.nnn.nnn to a +// 32-bit (unsigned) integer -- used for sorting device list +function ipAddrTo32(ipAddr) { + let ip32 = 0 + const ma = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ipAddr) + if (Array.isArray(ma) && ma.length == 5) { + // Note - don't use JavaScript's bit shift operators + // (they coerce operands to SIGNED 32-bit integers; we need unsigned) + ip32 = ((ma[1] * 256 + ma[2]) * 256 + ma[3]) * 256 + ma[4] + } + return ip32 +} + +// The Serial Number table is a Map() object with a key of Serial Number +// The value of the Map() object has the following structure +// Note that ip32 is used to establish the sort order +function SnEntry(device) { + this.serialNumber = device.serialNumber + this.ipAddr = device.ipAddr + this.friendlyName = device.friendlyName + this.modelName = device.modelName + this.modelNumber = device.modelNumber + this.ip32 = ipAddrTo32(device.ipAddr) +} + +module.exports = VBListPanel diff --git a/source/lib/VBLog.js b/source/lib/VBLog.js new file mode 100644 index 0000000..d421c11 --- /dev/null +++ b/source/lib/VBLog.js @@ -0,0 +1,162 @@ +'use strict'; + +// Node.js modules +const os = require('os') +const fs = require('fs') +const path = require('path') + +// Electron modules +const {remote, ipcRenderer} = require('electron') +const {app, dialog} = remote + +class VBLog { + + constructor(conn) { + // Connection object for this log stream + this.conn = conn + + // Log file writeable stream + this.logStream = null + + // Flag indicating whether logging is in progress + this.logging = false + } + + // Display a dialog box for specifying a log file + doDialog(config, dirname, filename) { + + dialog.showSaveDialog({ + title: 'Log File', + defaultPath: path.join(dirname, filename) + }, (pathname) => { + if (pathname) { + this.logStream = fs.createWriteStream(pathname, { + flags: 'a', + defaultEncoding: 'utf8' + }).on('error', (err) => { + console.log('Log write error:', err) + this.logging = false + this.logStream = null + }).on('close', () => { + this.logging = false + this.logStream = null + }) + this.logging = true + this.write('Logging started' + os.EOL) + // Update the Renderer's config object + config.logfilePath = pathname + // Send IPC to Main to update its config object + ipcRenderer.send('update-log-file', pathname) + } + }) + } + + // Display a log file dialog after closing any existing open log file + doLog(config, dirname, filename) { + // If we already have a log file in use, then close its stream + if (this.logStream) { + this.logStream.end(() => { + this.logStream = null + this.doDialog(config, dirname, filename) + }) + } + else { + this.doDialog(config, dirname, filename) + } + } + + // Specify a new log file + logFile(config) { + let filename = '' + let dirname = '' + // If there is already a logfile path in the config object, then use it + if (config.logfilePath) { + filename = path.basename(config.logfilePath) + dirname = path.dirname(config.logfilePath) + this.doLog(config, dirname, filename) + } + else { + // By default, log files will be stored in the user's + // Documents folder in the VioletBugLogs subfolder + const documentsPath = app.getPath('documents') + // Check if the user's documents directory exists + fs.stat(documentsPath, (err, stats) => { + if (!err && stats.isDirectory()) { + dirname = path.join(documentsPath, 'VioletBugLogs') + // Check if the VioletBugLogs directory exists + fs.stat(dirname, (err, stats) => { + if (!err && stats.isDirectory()) { + // VioletBugLogs directory already exists + this.doLog(config, dirname, filename) + } + else { + // If VioletBugLogs does not exist then create it + fs.mkdir(dirname, (err) => { + if (!err) { + // Created new VioletBugLogs directory + this.doLog(config, dirname, filename) + } + else { + // Unable to create VioletBugLogs directory + dirName = '' + this.doLog(config, dirName, filename) + } + }) + } + }) + } + else { + // User's documents directory does not exist + this.doLog(config, dirName, filename) + } + }) + } + + } + + // Return true if there is a logStream open, otherwise false + isLogStream() { + return this.logStream != null + } + + // Return if 'Resume logging' menu item can be enabled + canResume() { + return !!(this.logStream && !this.logging) + } + + // Return if 'Pause logging' menu item can be enabled + canPause() { + return !!(this.logStream && this.logging) + } + + // Resume logging + resume() { + if (this.logStream) { + this.logging = true + } + } + + // Pause logging + pause() { + this.logging = false + } + + // Close the log stream (when the connection ends) + close() { + if (this.logStream) { + this.logStream.end() + } + this.logStream = null + this.logging = false + } + + // Write data to the log file (async) + write(data) { + if (this.conn && this.logStream && this.logging) { + this.logStream.write(data) + } + } + +} + +module.exports = VBLog diff --git a/source/lib/VBMenu.js b/source/lib/VBMenu.js new file mode 100644 index 0000000..e703c41 --- /dev/null +++ b/source/lib/VBMenu.js @@ -0,0 +1,623 @@ +'use strict'; + +// Electron modules + +const isRenderer = !!(process && process.type === 'renderer') + +const electron = require('electron') + +const { + app, + getGlobal, + ipcMain, + shell, + Menu, + MenuItem, + BrowserWindow +} = isRenderer ? electron.remote : electron + +// Node.js modules +const path = require('path') + +// Local modules +const VBIcons = require('./VBIcons') + +// Get a Main Process global variable depending on whether we are +// running in the Main Process or Renderer Process +function g(item) { + return isRenderer ? getGlobal(item) : global[item] +} + +class VBMenu { + + static platform() { + // TODO: Debugging only ... + //return 'darwin' + + return process.platform + } + + // Executes in the main process to set up the application's main menu + static createAppMenu() { + // Reference the global config object in the Main process + const config = g('vbConfig').config + + // Reference the main BrowserWindow object + let mainWindow = g('mainWindow') + + // Reference the global floatList so we can send IPCs to all windows + const floatList = g('floatList') + + // Some Linux distributions don't show the full menu, only the first item, + // so show a menu that has all the other menus as submenus + function linuxMenu() { + return { + label: app.getName(), + submenu: [ + fileMenu(), + settingsMenu(), + windowMenu(), + helpMenu() + ] + } + } + + // macOS has some very specific menu items + function darwinMenu() { + const name = app.getName() + return { + label: name, + submenu: [{ + label: 'About ' + name, + role: 'about' + },{ + type: 'separator' + },{ + label: 'Services', + role: 'services', + submenu: [] + },{ + type: 'separator' + },{ + label: 'Hide ' + name, + accelerator: 'Command+H', + role: 'hide' + },{ + label: 'Hide Others', + accelerator: 'Command+Alt+H', + role: 'hideothers' + },{ + label: 'Show All', + role: 'unhide' + },{ + type: 'separator' + },{ + label: 'Quit ' + name, + accelerator: 'Command+Q', + click(menuItem, browserWindow, event) { + app.quit() + } + } + ] + } + } + + function fileMenu() { + return { + label: 'File', + submenu: [{ + label: 'Exit', + accelerator: 'CmdOrCtrl+Q', + click(menuItem, browserWindow, event) { + app.quit() + } + } + ] + } + } + + function settingsMenu() { + return { + label: 'Settings', + submenu: [{ + label: 'Auto scroll', + type: 'checkbox', + checked: config.autoScroll, + click(menuItem, browserWindow, event) { + const checked = menuItem.checked + // Update main process config object + config.autoScroll = checked + // Flush config to disk + g('saveConfigObject')() + // Make sure VioletBug and Settings menus in sync + VBMenu.setAppMenuSettings(checked, undefined) + // Notify the renderer process of the change + mainWindow.webContents.send('autoScrollChanged', checked) + floatList.forEach( + win => win.webContents.send('autoScrollChanged', checked) + ) + } + },{ + label: 'Auto wrap', + type: 'checkbox', + checked: config.autoWrap, + click(menuItem, browserWindow, event) { + const checked = menuItem.checked + // Update main process config object + config.autoWrap = checked + // Flush config to disk + g('saveConfigObject')() + // Make sure VioletBug and Settings menus in sync + VBMenu.setAppMenuSettings(undefined, checked) + // Notify the renderer process of the change + mainWindow.webContents.send('autoWrapChanged', checked) + floatList.forEach( + win => win.send('autoWrapChanged', checked) + ) + } + },{ + label: 'Fonts ...', + click(menuItem, browserWindow, event) { + // Flush the current config to disk, so font window can read it + g('saveConfigObject')() + + // Display the fonts dialog window + let fontsWindow = new BrowserWindow({ + //width: 480, + //height: 600, + title: 'Fonts', + icon: VBIcons.icon, + show: false, + parent: browserWindow, + modal: true, + backgroundColor: '#FFF', + webPreferences: { + defaultEncoding: 'UTF-8', + experimentalFeatures: true + } + }) + fontsWindow.setMenu(null) + fontsWindow.loadURL('file://' + + path.join(__dirname, '..', 'fonts.html')) + fontsWindow.once('ready-to-show', () => { + fontsWindow.show() + }) + fontsWindow.on('closed', () => { + fontsWindow = null + }) + } + },{ + label: 'Colors ...', + click(menuItem, browserWindow, event) { + // Flush the current config to disk, so color window can read it + g('saveConfigObject')() + + // Display the colors dialog window + let colorsWindow = new BrowserWindow({ + //width: 520, + //height: 520, + title: 'Colors', + icon: VBIcons.icon, + show: false, + parent: browserWindow, + modal: true, + backgroundColor: '#FFF', + webPreferences: { + defaultEncoding: 'UTF-8', + experimentalFeatures: true + } + }) + colorsWindow.setMenu(null) + colorsWindow.loadURL('file://' + + path.join(__dirname, '..', 'colors.html')) + colorsWindow.once('ready-to-show', () => { + colorsWindow.show() + }) + colorsWindow.on('closed', () => { + colorsWindow = null + }) + } + },{ + label: 'Shortcuts ...', + click(menuItem, browserWindow, event) { + // Flush the current config to disk, so font window can read it + g('saveConfigObject')() + + // Display the fonts dialog window + let shortcutsWindow = new BrowserWindow({ + //width: 640, + //height: 575, + title: 'Shortcuts', + icon: VBIcons.icon, + show: false, + parent: browserWindow, + modal: true, + backgroundColor: '#FFF', + webPreferences: { + defaultEncoding: 'UTF-8', + experimentalFeatures: true + } + }) + shortcutsWindow.setMenu(null) + shortcutsWindow.loadURL('file://' + + path.join(__dirname, '..', 'shortcuts.html')) + shortcutsWindow.once('ready-to-show', () => { + shortcutsWindow.show() + }) + shortcutsWindow.on('closed', () => { + shortcutsWindow = null + }) + } + },{ + type: 'separator' + },{ + label: "Right-click in a connection's window for more options", + sublabel: "(log, float, re-connect, close, clear screen, etc)" + } + ] + } + } + + function windowMenu() { + return { + label: 'Window', + role: 'window', + submenu: [ { + label: 'Zoom In', + accelerator: 'CmdOrCtrl+=', + click(menuItem, browserWindow, event) { + browserWindow.webContents.getZoomLevel(zoom => { + if (zoom < 7) { + config.zoomLevel = ++zoom + browserWindow.webContents.setZoomLevel(zoom) + } + }) + } + },{ + label: 'Zoom Out', + accelerator: 'CmdOrCtrl+-', + click(menuItem, browserWindow, event) { + browserWindow.webContents.getZoomLevel(zoom => { + if (zoom > -7) { + config.zoomLevel = --zoom + browserWindow.webContents.setZoomLevel(zoom) + } + }) + } + },{ + label: 'Zoom Reset', + click(menuItem, browserWindow, event) { + config.zoomLevel = 0 + browserWindow.webContents.setZoomLevel(0) + } + },{ + type: 'separator' + },{ + label: 'Minimize', + accelerator: 'CmdOrCtrl+M', + role: 'minimize' + },{ + label: 'Bring All to Front', + visible: VBMenu.platform() === 'darwin', + role: 'front' + },{ + role: 'togglefullscreen' + },{ + label: 'Close All', + accelerator: 'CmdOrCtrl+W', + role: 'close' + }, + ] + } + } + + function helpMenu() { + return { + label: 'Help', + submenu: [{ + label: 'VioletBug Release Notes', + click(menuItem, browserWindow, event) { + shell.openExternal( + 'https://github.com/belltown/violetbug/releases') + } + },{ + label: 'VioletBug Web Page', + click(menuItem, browserWindow, event) { + shell.openExternal('http://belltown-roku.tk/VioletBug') + } + },{ + label: 'About VioletBug', + click(menuItem, browserWindow, event) { + const aboutWindow = new BrowserWindow({ + width: 320, + height: 200, + title: 'About VioletBug', + icon: VBIcons.icon, + show: true, + webPreferences: { + defaultEncoding: 'UTF-8', + } + }) + aboutWindow.setMenu(null) + aboutWindow.loadURL('file://' + + path.join(__dirname, '..', 'about.html')) + } + },{ + type: 'separator' + },{ + label: 'Toggle Developer Tools', + accelerator: VBMenu.platform() === 'darwin' ? 'Alt+Command+I' + : 'Ctrl+Shift+I', + click(menuItem, browserWindow, event) { + if (browserWindow) { + browserWindow.webContents.toggleDevTools() + } + } + } + ] + } + } + + // Keep this code in sync with the code below in settingsMenuIndex() + const template = [] + // [0] ... + if (VBMenu.platform() === 'darwin') { + template.push(darwinMenu()) + } + else if (VBMenu.platform() === 'linux') { + template.push(linuxMenu()) + } + + // [1] ... + template.push(fileMenu()) + + // [2] ... + template.push(settingsMenu()) + + // [3] ... + template.push(windowMenu()) + + // [4] ... + template.push(helpMenu()) + + return Menu.buildFromTemplate(template) + + } + + // Keep in sync with the above code at the end of createAppMenu() + static settingsMenuIndex() { + if (VBMenu.platform() === 'darwin' || VBMenu.platform() === 'linux') { + return 2 + } + else { + // win32 does not have the initial "VioletBug" menu + return 1 + } + } + + // Whenever the context menu is used to change the AutoScroll or AutoWrap + // options, the application menu must be updated to reflect their + // checked statuses + static setAppMenuSettings(autoScroll, autoWrap) { + const appMenu = Menu.getApplicationMenu() + const SETTINGS_INDEX = VBMenu.settingsMenuIndex() + + if (typeof autoScroll !== 'undefined') { + // Linux "VioletBug" menu + if (VBMenu.platform() === 'linux') { + appMenu.items[0].submenu.items[1].submenu.items[0].checked = autoScroll + } + // App Settings menu + appMenu.items[SETTINGS_INDEX].submenu.items[0].checked = autoScroll + } + + if (typeof autoWrap !== 'undefined') { + // Linux "VioletBug" menu + if (VBMenu.platform() === 'linux') { + appMenu.items[0].submenu.items[1].submenu.items[1].checked = autoWrap + } + // App Settings menu + appMenu.items[SETTINGS_INDEX].submenu.items[1].checked = autoWrap + } + } + + // Executes in the Renderer Process to set up an individual + // connection's context menu + // Note that the click event handlers execute in the Main Process context + static createContextMenu(connId, ip, port) { + // Reference the global config object in the Main Process + const config = g('vbConfig').config + + // Reference to main window in the Main Process + let mainWindow = g('mainWindow') + + // Reference the global webContentsList so we can send IPCs to all windows + let floatList = g('floatList') + + // Create a Menu remote object + const menu = new Menu() + + let index = 0 + + menu.append(new MenuItem({ + label: 'Log file ...', + click(menuItem, browserWindow, event) { + browserWindow.webContents.send('logFile', connId) + } + })) + index++ + + menu.append(new MenuItem({ + label: 'Resume logging', + enabled: false, + click(menuItem, browserWindow, event) { + browserWindow.webContents.send('logResume', connId) + } + })) + VBMenu.RESUME_LOGGING = index++ + + menu.append(new MenuItem({ + label: 'Pause logging', + enabled: false, + click(menuItem, browserWindow, event) { + browserWindow.webContents.send('logPause', connId) + } + })) + VBMenu.PAUSE_LOGGING = index++ + + menu.append(new MenuItem({type: 'separator'})) + index++ + + menu.append(new MenuItem({ + label: 'Re-connect', + click(menuItem, browserWindow, event) { + browserWindow.webContents.send('reConnect', connId) + } + })) + index++ + + menu.append(new MenuItem({ + label: 'Close tab', + click(menuItem, browserWindow, event) { + browserWindow.webContents.send('closeTab', connId) + } + })) + index++ + + menu.append(new MenuItem({ + label: 'Float tab', + // Set visible to false if floating tab + visible: true, + click(menuItem, browserWindow, event) { + // Send IPC to renderer process (DockPanel), which will remove + // the docked connection tab, and send an IPC to the Main Process + // to create a new Floating connection window + browserWindow.webContents.send('floatTab', connId) + } + })) + VBMenu.FLOAT_TAB = index++ + + menu.append(new MenuItem({ + label: 'Unfloat tab', + // Set visible to true if docked tab + visible: false, + click(menuItem, browserWindow, event) { + // Send IPC to DockPanel window so it can create new connection in dock + // and remove the floating tab from its list + mainWindow.webContents.send('unFloatTab', ip, port) + + // Send IPC to renderer of FloatPanel window so it can terminate + browserWindow.webContents.send('unFloatTab', connId) + } + })) + VBMenu.UNFLOAT_TAB = index++ + + menu.append(new MenuItem({type: 'separator'})) + index++ + + menu.append(new MenuItem({ + label: 'Auto scroll', + type: 'checkbox', + checked: config.autoScroll, + click(menuItem, browserWindow, event) { + const checked = menuItem.checked + // Update main process config object + config.autoScroll = checked + // Flush config to disk + g('saveConfigObject')() + // Update main menu auto scroll flags (in main process) + VBMenu.setAppMenuSettings(checked, undefined) + // Notify the renderer process of the change + // Note: send IPC to main window NOT the window where the context + // menu was invoked, otherwise if the context menu is invoked + // from a floating window, the change will not get propagated + // to the main dock + mainWindow.webContents.send('autoScrollChanged', checked) + floatList.forEach( + win => win.webContents.send('autoScrollChanged', checked) + ) + } + })) + VBMenu.AUTO_SCROLL = index++ + + menu.append(new MenuItem({ + label: 'Auto wrap', + type: 'checkbox', + checked: config.autoWrap, + click(menuItem, browserWindow, event) { + const checked = menuItem.checked + // Update main process config object + config.autoWrap = checked + // Flush config to disk + g('saveConfigObject')() + // Update main menu auto wrap flags (in main process) + VBMenu.setAppMenuSettings(undefined, checked) + // Notify the renderer process of the change + // Note: send IPC to main window NOT the window where the context + // menu was invoked, otherwise if the context menu is invoked + // from a floating window, the change will not get propagated + // to the main dock + mainWindow.webContents.send('autoWrapChanged', checked) + floatList.forEach( + win => win.webContents.send('autoWrapChanged', checked) + ) + } + })) + VBMenu.AUTO_WRAP = index++ + + menu.append(new MenuItem({type: 'separator'})) + index++ + + menu.append(new MenuItem({ + label: 'Clear screen', + accelerator: 'Alt+C', + click(menuItem, browserWindow, event) { + browserWindow.webContents.send('clearScreen', connId) + } + })) + index++ + + menu.append(new MenuItem({ + label: 'Clear line', + accelerator: 'Esc', + click(menuItem, browserWindow, event) { + browserWindow.webContents.send('clearLine', connId) + } + })) + index++ + + menu.append(new MenuItem({type: 'separator'})) + index++ + + menu.append(new MenuItem({ + label: 'Cut', + accelerator: 'CmdOrCtrl+X', + role: 'cut' + })) + index++ + + menu.append(new MenuItem({ + label: 'Copy', + accelerator: 'CmdOrCtrl+C', + role: 'copy' + })) + index++ + + menu.append(new MenuItem({ + label: 'Paste', + accelerator: 'CmdOrCtrl+V', + role: 'paste' + })) + index++ + + return menu + + } + + static clearApplicationMenu () { + Menu.setApplicationMenu(null) + } + +} + +module.exports = VBMenu diff --git a/source/lib/VBSocket.js b/source/lib/VBSocket.js new file mode 100644 index 0000000..101fb8b --- /dev/null +++ b/source/lib/VBSocket.js @@ -0,0 +1,162 @@ +'use strict'; + +// Node.js modules +const net = require('net') + +// Electron modules +const {ipcRenderer} = require('electron') + +class VBSocket { + + constructor(ip, port) { + // IP address (string) + this.ip = ip + + // Port (integer) + this.port = port + + // Node.js Socket object + this.socket = null + + // Callbacks into VBConnPanel object + this.socketDataCallback = null + this.socketConnectCallback = null + this.socketCloseCallback = null + this.socketErrorCallback = null + this.socketTimeoutCallback = null + } + + registerDataCallback(callback) { + this.socketDataCallback = callback + } + + registerConnectCallback(callback) { + this.socketConnectCallback = callback + } + + registerCloseCallback(callback) { + this.socketCloseCallback = callback + } + + registerErrorCallback(callback) { + this.socketErrorCallback = callback + } + + registerTimeoutCallback(callback) { + this.socketTimeoutCallback = callback + } + + terminate() { + if (this.socket) { + this.socket.destroy() + } + this.socketDataCallback = null + this.socketConnectCallback = null + this.socketCloseCallback = null + this.socketErrorCallback = null + this.socketTimeoutCallback = null + this.socket = null + } + + write(data) { + if (this.socket) { + this.socket.write(data) + } + } + + // Establish a connection with the specifed ip address and port + setupConnectionSocket(reConnect) { + + // Create a full-duplex TCP socket for the connection + this.socket = net.connect({host: this.ip, port: this.port}) + + // Note that the 'data' listener is registered from within the + // 'connect' listener as we don't want any data received until + // the UI needed to display it is fully set up + this.socket.on('connect', () => { + this.onSocketConnect() + }) + + this.socket.on('close', (had_error) => { + this.onSocketClose(had_error) + }) + + this.socket.on('error', (error) => { + this.onSocketError(error) + }) + + this.socket.on('timeout', () => { + this.onSocketTimeout() + }) + + } + + onSocketData(buffer) { + // Pass a Buffer object (NOT a UTF-8 encoded string) back to the caller + // This is because Buffers are stored outside of the V-8 heap space, + // thus minimizing the memory used by the user process, + // which we need to do because the user queues the Buffer objects + // until the next animation frame timeout occurs + // If a lot of data is received faster than the user can process it, + // the the memory in the user processes could potentially grow quite large + if (this.socketDataCallback) { + this.socketDataCallback(buffer) + } + + } + + onSocketConnect() { + if (this.socketConnectCallback) { + this.socketConnectCallback() + } + + // The 'data' event handler must be added AFTER the DOM is set up + this.socket.on('data', (buffer) => { + this.onSocketData(buffer) + }) + + } + + onSocketClose(had_error) { + if (this.socket) { + this.socket = null + } + + if (this.socketCloseCallback) { + this.socketCloseCallback() + } + } + + onSocketError(error) { + console.log('Connect Event: error: %O', error) + + ipcRenderer.send('error-dialog', + `Connection Error ${this.ip}:${this.port}`, + error.message) + + if (this.socket) { + this.socket = null + } + + if (this.socketErrorCallback) { + this.socketErrorCallback() + } + } + + onSocketTimeout() { + ipcRenderer.send('error-dialog', + `Connection Timeout ${this.ip}:${this.port}`) + + if (this.socket) { + this.socket.destroy() + this.socket = null + } + + if (this.socketTimeoutCallback) { + this.socketTimeoutCallback() + } + } + +} + +module.exports = VBSocket diff --git a/source/main.js b/source/main.js new file mode 100644 index 0000000..4fad83b --- /dev/null +++ b/source/main.js @@ -0,0 +1,485 @@ +'use strict'; +{ // <= Enclosing block to keep function names out of global namespace + + // + // Electron Main Process (main.js) + // + + // Electron modules + const { + app, + ipcMain, + BrowserWindow, + dialog, + Menu + } = require('electron') + + // Node.js modules + const path = require('path') + + // Local modules + const VBMenu = require('./lib/VBMenu') + const VBIcons = require('./lib/VBIcons') + const VBConfig = require('./lib/VBConfig') + + // Keep a global reference to the main window object + global.mainWindow = null + + // Store the reference for each window so we can send config changes + // Add to this list when a new BrowserWindow is created for a floated tab + // Remove from the list when the BrowserWindow no longer exists + global.floatList = [] + + // Config VBConfig object will be instantiated after 'ready' event received, + // so that any error dialog it displays will be rendered correctly + global.vbConfig = null + + // config data reference, points to VBConfig object's parsed JSON config data + let config = null + + function readConfigFile() { + global.vbConfig = new VBConfig() + + // Read the config data and store reference to it + let config = global.vbConfig.init() + + if (!config) { + fatalErrorDialog('Unable to read config file') + return null + } + + // Turn the snTables and floaties arrays into Maps + configArraysToMaps(config) + + return config + } + + // Create Maps to store the device table and floaties table + // Note that snTable and floaties are stored in config as JSON arrays, + // since JSON cannot depict Maps, so convert them here from arrays to Maps + function configArraysToMaps(configData) { + try { + configData.snTable = new Map(JSON.parse(configData.snTable)) + } + catch (e) { + configData.snTable = new Map() + } + + try { + configData.floaties = new Map(JSON.parse(configData.floaties)) + } + catch (e) { + configData.floaties = new Map() + } + } + + // Convert the Maps to arrays before storing in config file, + // necessary because JSON cannot be used to serialize Maps directly + function configMapsToArrays(configData) { + configData.snTable = JSON.stringify([...configData.snTable]) + configData.floaties = JSON.stringify([...configData.floaties]) + } + + // The config object contains a couple of Map() objects + // These cannot directly be converted to a JSON object, + // so convert them to arrays first, using the spread operator, + // then stringify and put the strings in the config object before saving + // Make global so can be called from VBMenu + global.saveConfigObject = function() { + configMapsToArrays(config) + global.vbConfig.save() + configArraysToMaps(config) + } + + function errorDialog(message, detail = ' ') { + dialog.showMessageBox({ + type: "error", + title: "VioletBug -- Error", + message: message, + detail: detail, + buttons: ["OK"] + }) + } + + function fatalErrorDialog(message, detail = ' ') { + dialog.showErrorBox('Fatal Error -- ' + message, detail) + app.quit() + } + + // Create a browser window after Electron has finished initializing + // ('ready' event) + function createWindow() { + // Create a new BrowserWindow ('renderer') + global.mainWindow = new BrowserWindow({ + x: config.x, + y: config.y, + width: config.width, + height: config.height, + title: 'VioletBug', + icon: VBIcons.icon, + // Don't show the window until the menu bar and html have been loaded + show: false, + // Electron docs state: Note that even for apps that use ready-to-show + // event, it is still recommended to set backgroundColor to make app + // feel more native + // Use the same color value in index.css and float.css for + // #mainContainer background-color + backgroundColor: '#1e2026', + webPreferences: { + // Override the default ISO-8859-1 encoding + defaultEncoding: 'UTF-8', + // Use Electron's default font size + //defaultFontSize: 14, + + // Monospace font size set from config file + //defaultMonospaceFontSize: 14, + + // Make sure that requestAnimationFrame still runs even when + // window is minimized, as it is used to trigger updates + // to device output panel + backgroundThrottling: false, + + // Needed for CSS 'grid' support + experimentalFeatures: true + } + }) + + // Create the main application menu + Menu.setApplicationMenu(VBMenu.createAppMenu()) + + // Display the Developer Tools (not in production release) + //global.mainWindow.webContents.openDevTools() + + // Load the app's index.html file from disk + global.mainWindow.loadURL(`file://${__dirname}/index.html`) + + // The 'resize' event is emitted when the window's dimensions are changed + global.mainWindow.on('resize', () => { + if (!global.mainWindow.isMaximized()) { + [config.width, config.height] = global.mainWindow.getSize() + } + }) + + // The 'move' event is emitted when the window is repositioned + global.mainWindow.on('move', () => { + if (!global.mainWindow.isMaximized()) { + [config.x, config.y] = global.mainWindow.getPosition() + } + }) + + // Main window maximized + global.mainWindow.on('maximize', () => { + config.windowState = 'Maximized' + }) + + // Main window unmaximized + global.mainWindow.on('unmaximize', () => { + config.windowState = 'Normal' + }) + + // Main window minimized + global.mainWindow.on('minimize', () => { + config.windowState = 'Minimized' + }) + + // Main window un-minimized + global.mainWindow.on('restore', () => { + config.windowState = 'Normal' + }) + + // The 'closed' event is emitted when the main window is closed + global.mainWindow.on('closed', () => { + // Persist the global config object to disk + global.saveConfigObject() + + // Make sure all child windows close + // Don't quit the app until the app window-all-closed event fires, + // which will cause the app to close (except on macOS) + //app.quit() + + // Dereference any remaining floating windows + // Close the floating windows + // They will be dereferenced (on macOS) when their 'closed' + // events are handled + try { + // We can't iterate through floatList directly when closing windows, + // since the act of closing a floating tab removes the corresponding + // window entry from floatList, so make a copy first. + let closeList = [] + floatList.forEach( + win => closeList.push(win) + ) + closeList.forEach( + win => win.close() + ) + } + catch (ex) { + console.log('Exception closing floated window', ex) + } + + // Remove the reference to the main window; + // On macOS, 'activate' can be used to recreate the window + global.mainWindow = null + }) + + // Show and give focus to the window when it's ready to show + global.mainWindow.once('ready-to-show', () => { + if (config.windowState === 'Maximized') { + global.mainWindow.maximize() + } + else { + config.windowState = 'Normal' + } + if (config.zoomLevel) { + global.mainWindow.webContents.setZoomLevel(config.zoomLevel) + } + global.mainWindow.show() + }) + + } + + // A floating tab is created here in the Main Process in response to + // an IPC from the Renderer Process handling the DockPanel + function onFloatTab(e, ip, port) { + + // Default window bounds if no bounds found in config file + let x = config.x + 20 + let y = config.y + 20 + let width = config.width - 20 + let height = config.height - 20 + + // Bounds are stored in the config file as a Map keyed on ip:port string + const key = ip + ':' + port + const bounds = config.floaties.get(key) + + // Set the window bounds from the config file, if present + if (bounds) { + x = bounds.x + y = bounds.y + width = bounds.width + height = bounds.height + } + + // Create a Browser Window for the floated tab + let floatWindow = new BrowserWindow({ + x: x, + y: y, + width: width, + height: height, + title: ip + ':' + port + ' [Not Connected]', + icon: VBIcons.icon, + // Don't show the window until the menu bar and html have been loaded + show: false, + // Electron docs state: Note that even for apps that use ready-to-show + // event, it is still recommended to set backgroundColor to make app + // feel more native + backgroundColor: '#ffffff', + webPreferences: { + // Override the default ISO-8859-1 encoding + defaultEncoding: 'UTF-8', + + // Font sizes used by the floating tab are determined from config file + //defaultFontSize: 14, + //defaultMonospaceFontSize: 14, + + // Make sure that requestAnimationFrame still runs even when + // window is minimized, as it is used to trigger updates + // to device output panel + backgroundThrottling: false, + + // Needed for CSS 'grid' support + experimentalFeatures: true + } + }) + + // Disable the menu bar + floatWindow.setMenu(null) + + // Display the Developer Tools + // Remove for release version (not in production release) + //floatWindow.webContents.openDevTools() + + // Load the app's index.html file from disk + floatWindow.loadURL(`file://${__dirname}/float.html`) + + // Store the window reference for the floating tab + global.floatList.push(floatWindow) + + // Show and give focus to the window on the ready-to-show event, + // and register floated window handles + floatWindow.once('ready-to-show', () => { + const ipPort = ip + ':' + port + + // Wait until the window is ready to show before showing it + + // The 'resize' event is emitted when the window size is changed + floatWindow.on('resize', () => { + config.floaties.set(ipPort, floatWindow.getBounds()) + }) + + // The 'move' event is emitted when the window is repositioned + floatWindow.on('move', () => { + config.floaties.set(ipPort, floatWindow.getBounds()) + }) + + if (config.zoomLevel) { + floatWindow.webContents.setZoomLevel(config.zoomLevel) + } + + floatWindow.show() + }) + + floatWindow.once('closed', () => { + // Remove the webContents for the floating tab from the + // webContentList in the Main Process + const index = global.floatList.indexOf(floatWindow) + if (index >= 0) { + global.floatList.splice(index, 1) + } + }) + + } + + // Set the Application User Model ID (AUMID) to the appId of the application + // (Windows only) + app.setAppUserModelId('tk.belltown-roku.violetbug') + + // The 'ready' event is emitted when Electron has finished initialization + app.on('ready', () => { + if (global.mainWindow === null) { + config = readConfigFile() + if (config) { + createWindow() + } + } + }) + + // The 'activate' event is emitted when the application is activated, + // which usually happens when the user clicks on the applications’s dock icon + app.on('activate', () => { + // On OS X it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (global.mainWindow === null) { + config = readConfigFile() + if (config) { + createWindow() + } + } + }) + + // Quit the application when the last window is closed + app.on('window-all-closed', () => { + // On macOS it is common for applications and their menu bar + // to stay active until the user quits explicitly with Cmd + Q + if (process.platform !== 'darwin') { + // Try to close all windows + app.quit() + } + }) + + // Error dialog IPC from the Renderer process + ipcMain.on('error-dialog', (e, message, detail = ' ') => { + errorDialog(message, detail) + }) + + // Fatal error dialog IPC from the Renderer process + ipcMain.on('fatal-error-dialog', (e, message, detail = ' ') => { + fatalErrorDialog(message, detail) + }) + + // Config update IPCs from the Renderer process + ipcMain.on('update-last-connected-device', (e, ip, port) => { + config.lastConnectedIp = ip + config.lastConnectedPort = port + }) + + // Keep track of the last log file path used + ipcMain.on('update-log-file', (e, pathname) => { + config.logfilePath = pathname + }) + + // Every time there is a change to the device list (e.g. a device is + // added or deleted), the ListPanel sends a JSON-stringified copy of + // the whole SN table in an IPC, which will be persisted to disk just + // before the Main Process shuts down + ipcMain.on('device-list', (e, deviceListJSON) => { + config.snTable = new Map(JSON.parse(deviceListJSON)) //~~~~ + }) + + // Whenever a tab is floated from the DockPanel and IPC is sent + // to the Main Process to create the floating tab's BrowserWindow + ipcMain.on('float-tab', onFloatTab) + + // Remove a webContents from the webContentsList when a + // BrowserWindow (e.g. a floating tab) is closed + // There is no event for that, but we use an IPC from the Floated Dock + // to the main process instead. + ipcMain.on('unfloat-tab', (e, window, webContents) => { + // Close the window + window.close() + }) + + // If the foreground color has changed, store new color in config, + // and broadcast to renderer processes + ipcMain.on('fgColorChanged', (e, color) => { + config.foregroundColor = color + global.mainWindow.webContents.send('fgColorChanged', color) + floatList.forEach( + win => win.webContents.send('fgColorChanged', color) + ) + }) + + // If the background color has changed, store new color in config, + // and broadcast to renderer processes + ipcMain.on('bgColorChanged', (e, color) => { + config.backgroundColor = color + global.mainWindow.webContents.send('bgColorChanged', color) + floatList.forEach( + win => win.webContents.send('bgColorChanged', color) + ) + }) + + // Save the config after committing changes to fg/bg colors + ipcMain.on('commitColors', (e) => { + // Persist the global config object to disk + global.saveConfigObject() + }) + + + // If the font family has changed, store the new font family in config, + // and broadcast to renderer processes + ipcMain.on('fontFamilyChanged', (e, family) => { + config.fontFamily = family + global.mainWindow.webContents.send('fontFamilyChanged', family) + floatList.forEach( + win => win.webContents.send('fontFamilyChanged', family) + ) + }) + + // If the font size has changed, store the new font size in config, + // and broadcast to renderer processes + ipcMain.on('fontSizeChanged', (e, size) => { + config.fontSize = size + global.mainWindow.webContents.send('fontSizeChanged', size) + floatList.forEach( + win => win.webContents.send('fontSizeChanged', size) + ) + }) + + // Save the config after committing changes to the font + ipcMain.on('commitFonts', (e) => { + global.saveConfigObject() + }) + + // If the shortcuts have been changed, store in config, + // and broadcast to renderer processes + ipcMain.on('shortcutsChanged', (e, shortcutsJson) => { + config.shortcutList = JSON.parse(shortcutsJson) + global.saveConfigObject() + global.mainWindow.webContents.send('shortcutsChanged', shortcutsJson) + floatList.forEach( + win => win.webContents.send('shortcutsChanged', shortcutsJson) + ) + }) + +} diff --git a/source/package.json b/source/package.json new file mode 100644 index 0000000..06921d9 --- /dev/null +++ b/source/package.json @@ -0,0 +1,76 @@ +{ + "name": "violetbug", + "productName": "VioletBug", + "version": "0.0.1", + "private": true, + "description": "VioletBug -- A Roku Debugger Graphical Interface", + "main": "main.js", + "scripts": { + "start": "electron .", + "test": "echo If it works, it works", + + "build-win": "electron-packager . violetbug --platform=win32 --arch=x64 --out ../builds/win --overwrite --icon=images/icon.ico --win32metadata.CompanyName=Belltown --win32metadata.ProductName=VioletBug --win32metadata.FileDescription='VioletBug Roku Debugger' --app-copyright='Copyright © 2017 Belltown'", + "build-linux": "electron-packager . violetbug --platform=linux --arch=x64 --out ../builds/linux --overwrite --icon=images/icon.png --app-copyright='Copyright © 2017 Belltown'", + "build-mac": "electron-packager . violetbug --platform=darwin --arch=x64 --out ../builds/mac --overwrite --icon=images/icon.icns --app-bundle-id=tk.belltown-roku.violetbug --app-category-type=public.app-category.developer-tools --app-copyright='Copyright © 2017 Belltown'", + + "zip-win": "mkdir -p ../dist && cd ../builds/win && zip -r -q ../../dist/violetbug-win * && cd ../../source", + "zip-linux": "mkdir -p ../dist && cd ../builds/linux && zip -r -q --symlinks ../../dist/violetbug-linux * && cd ../../source", + "zip-mac": "mkdir -p ../dist && cd ../builds/mac/violetbug-darwin-x64 && zip -r -q --symlinks ../../../dist/violetbug-mac violetbug.app && cd ../../../source", + "zip-on-linux": "npm run zip-win && npm run zip-linux && npm run zip-mac", + + "dist-appimage": "build --linux AppImage", + "dist-deb": "build --linux deb", + "dist-rpm": "build --linux rpm", + "dist-freebsd": "build --linux freebsd", + "dist-tarxz": "build --linux tar.xz", + "dist-win": "build --win" + }, + "build": { + "appId": "tk.belltown-roku.violetbug", + "directories": { + "buildResources": "images", + "output": "../dist/" + }, + "nsis": { + "oneClick": true + }, + "win": {}, + "linux": { + "target": ["AppImage", "deb", "rpm", "freebsd", "tar.xz"], + "category": "Development", + "vendor": "Belltown", + "synopsis": "Roku Debugger Graphical Interface", + "description": "A cross-platform Roku Debugger graphical interface", + "icon": "." + } + }, + "keywords": [ + "PurpleBug", + "VioletBug", + "Roku", + "debugger", + "debug", + "debugger", + "Telnet" + ], + "copyright": "Copyright © 2017 Belltown", + "license": "MIT", + "author": { + "name": "Belltown", + "email": "nospam@gmail.com", + "url": "http://belltown-roku.tk/VioletBug" + }, + "homepage": "http://belltown-roku.tk/VioletBug", + "repository": { + "type": "git", + "url": "https://github.com/belltown/VioletBug" + }, + "bugs": { + "url": "https://github.com/belltown/VioletBug/issues" + }, + "devDependencies": { + "electron": "1.4.15", + "electron-builder": "13.9.0", + "electron-packager": "8.5.1" + } +} diff --git a/source/shortcuts.css b/source/shortcuts.css new file mode 100644 index 0000000..a2dcaf0 --- /dev/null +++ b/source/shortcuts.css @@ -0,0 +1,82 @@ +@import './fontList.css'; + +body { + font-family: 'Roboto', sans-serif; +} + +html { + height: 100vh; /* Document occupies full viewport height */ +} + +select, button, input, option { + font: inherit; /* Inherit all font properties from root font */ +} + +body { + margin: 0; /* No space around the edge of the body canvas */ + height: 100%; /* So we can vertically center content in body */ + min-width: fit-content; /* Avoid disappearing content on resize */ + background-color: #F3F3F3; /* Configure page color if needed */ +} + +/* Used to center #contentPanel horizontally and vertically */ +#contentContainer { + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +#contentPanel { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + padding: 2em; +} + +#helpPanel { + border: 1px solid black; + border-radius: 5px; + background-color: #CCC; + padding: 1.5em 2em; + text-align: center; + line-height: 2em; +} + +#shortcutList { + list-style: none; + margin: 2em 0; + width: 100%; + display: flex; + flex-direction: column; + padding: 0 1rem; + background-color: #F3F3F3; /* Configure content panel color if needed */ +} + +#shortcutList li { + display: flex; + flex-direction: row; + align-items: center; + line-height: 1.5rem; + margin: 1px 0; +} + +#shortcutList span { + flex: 0 0 auto; + width: 7ch; +} + +#shortcutList input { + flex: 1 1 auto; + border: 1px solid #CCECFF; +} + +#buttonPanel { + margin: 0 auto; +} + +button { + width: 13ch; + margin: 0 1.5rem; +} diff --git a/source/shortcuts.html b/source/shortcuts.html new file mode 100644 index 0000000..dd34186 --- /dev/null +++ b/source/shortcuts.html @@ -0,0 +1,27 @@ + + + + + + + Shortcuts + + +
    +
    +
    Shortcuts can be used in the Debugger input.
    + The shortcut text will be inserted at the current caret position. +
    +
      + +
    +
    + + +
    +
    +
    + + diff --git a/source/shortcuts.js b/source/shortcuts.js new file mode 100644 index 0000000..5448045 --- /dev/null +++ b/source/shortcuts.js @@ -0,0 +1,105 @@ +'use strict'; +{ // <= Enclosing block to keep function names out of global namespace + + // + // Main menu font selection settings dialog + // Invoked from VBMenu.js + // + + // Electron modules + const {remote, ipcRenderer} = require('electron') + + // Local modules + const VBConfig = require('./lib/VBConfig') + + // Read config data (to get current shortcuts) + // The shortcuts module only READS the config data, never writing directly + // When a shortcut is changed, an IPC message is sent to + // the Main Process using ipcRenderer.send() + // The Main Process will in turn send IPCs to the Renderer Processes + // so they can update their shortcuts for any displayed connection tabs or + // floating tabs + const vbConfig = new VBConfig() + const config = vbConfig.init() + + // Declare DOM shortcuts; assign in onLoad(), called when the DOM is loaded + let shortcutList + let okButton + let cancelButton + + // Save the initial shortcuts in case the user clicks the Cancel button + let shortcutListSave = JSON.stringify(config.shortcutList) + + // No need to release any resources - closing the window takes care of that + function closeWindow() { + remote.getCurrentWindow().close() + } + + // Send the changed shortcuts to the Main Process when the + // OK button is pressed so that any newly-created connection + // tabs will get the up-to-date shortcuts when they read + // the config file during their initialization + function sendChangedShortcuts() { + config.shortcutList = {} + const nodeList = shortcutList.querySelectorAll('li') + for (let i = 0; i < nodeList.length; i++) { + const li = nodeList[i] + const span = li.querySelector('span') + const input = li.querySelector('input') + config.shortcutList[span.textContent] = input.value + } + ipcRenderer.send('shortcutsChanged', JSON.stringify(config.shortcutList)) + } + + function sendSavedShortcuts() { + ipcRenderer.send('shortcutsChanged', shortcutListSave) + } + + // Initialize the shortcut list display from the config object + // The shortcuts are stored in an associative array in config, + // which is not guaranteed to be sorted, so sort then here + function setShortcuts() { + for (let key of Object.keys(config.shortcutList).sort()) { + const li = document.createElement('LI') + const span = document.createElement('SPAN') + const input = document.createElement('INPUT') + span.appendChild(document.createTextNode(key)) + input.value = config.shortcutList[key] + input.spellcheck = false + li.appendChild(span) + li.appendChild(input) + shortcutList.appendChild(li) + } + } + + // Event-handler for the OK button + function onOKClick(e) { + sendChangedShortcuts() + closeWindow() + } + + // Event-handler for Cancel button + function onCancelClick(e) { + sendSavedShortcuts() + closeWindow() + } + + // Document load event-handler + function onLoad() { + // Assign DOM element shortcuts + shortcutList = document.getElementById('shortcutList') + okButton = document.getElementById('okButton') + cancelButton = document.getElementById('cancelButton') + + // Register event-handlers + okButton.addEventListener('click', onOKClick) + cancelButton.addEventListener('click', onCancelClick) + + // Set shortcuts from the initial config object + setShortcuts() + } + + // Register the document load event-handler, fired when DOM is fully loaded + addEventListener('load', onLoad) + +} diff --git a/source/vb-config.json b/source/vb-config.json new file mode 100644 index 0000000..a783ac4 --- /dev/null +++ b/source/vb-config.json @@ -0,0 +1,41 @@ +{ + "version": "0.0.0", + "configPath": "vb-config.json", + "x": 10, + "y": 10, + "width": 720, + "height": 640, + "windowState": "Normal", + "zoomLevel": 0, + "seenHelp": false, + "maxOutputLines": 10000, + "logfilePath": "", + "lastConnectedIp": "", + "lastConnectedPort": "8085", + "autoScroll": true, + "autoWrap": true, + "insertTabsAtEnd": true, + "maxOutputNodes": 10000, + "foregroundColor": "#00DD00", + "backgroundColor": "#000000", + "shortcutList" : + { + "Ctrl-0": "", + "Ctrl-1": "", + "Ctrl-2": "", + "Ctrl-3": "", + "Ctrl-4": "", + "Ctrl-5": "", + "Ctrl-6": "", + "Ctrl-7": "", + "Ctrl-8": "", + "Ctrl-9": "" + }, + "fontFamilyDefault": "Courier New", + "fontSizeDefault": "11pt", + "fontFamily": "Courier New", + "fontSize": "11pt", + "snTable": "[]", + "floaties": "[]", + "floatiesInTaskbar": true +} diff --git a/source/violetbug.desktop b/source/violetbug.desktop new file mode 100644 index 0000000..d0b30d7 --- /dev/null +++ b/source/violetbug.desktop @@ -0,0 +1,14 @@ +# This file assumes that VioletBug is installed in /opt/violetbug-linux-x64/ +# This file should be placed in ~/.local/share/applications/violetbug.desktop +[Desktop Entry] +Version=1.0 +Name=VioletBug +GenericName=Roku Debugger +Terminal=false +Type=Application +Categories=Utility;Debugger;Development; +Comment=A Roku Debugger Graphical Interface +Exec=/opt/violetbug-linux-x64/violetbug +Icon=/opt/violetbug-linux-x64/resources/app/images/icon.png +StartupNotify=false +StartupWMClass=VioletBug