+
+
+
+ |
+ ||||
+
+
|
+
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 @@ +
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.
+violetbug-mac.zip
from https://github.com/belltown/violetbug/releases/latestVioletBug 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.
+VioletBug Setup x.y.z.exe
from https://github.com/belltown/violetbug/releases/latestMore info
, then Run anyway
violetbug-win.zip
from https://github.com/belltown/violetbug/releases/latestvioletbug-win-zip
to C:\Program Files
(Explorer, right-click, Extract All ...)violetbug.exe
executable in C:\Program Files\violetbug-win32-x64
to run VioletBugAllow access
if prompted to allow firewall accessCompiled binaries and installers for various linux distributions are provided at https://github.com/belltown/violetbug/releases/latest
+Download violetbug-linux.zip
from https://github.com/belltown/violetbug/releases/latest, e.g:
Unzip to the appropriate folder, e.g. /opt
Run the application:
+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.
Download one of the following installers:
+AppImage files should run on any linux distribution that supports AppImage:
+chmod u+x violetbug*.AppImage
./violetbug*.AppImage
.AppImage
file. Nothing else gets installed. Run the file from any location; delete it to uninstall.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
.
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:
+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:
+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.
+To build your own version of VioletBug from the source code, see Build Instructions.
+Contact belltown through the Roku Forums
+File a GitHub issue at https://github.com/belltown/violetbug/issues
+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 @@ + + + + + + +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 + + +++ + 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++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
+
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 @@ + + + + + + ++ + + + + + + ++ + 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 (++ + ++ Rokus ++ ++ + X +++ + ++++ + + ++++ Connect ++ + + ++ ++ ++
++ ++ + ++++ +
++ ++ + ++ +
++ + + ++ + + + ++ + ++ + ++ + + ++ + +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 thanor+ // 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 + + +++ + 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++Shortcuts can be used in the Debugger input.+
+ The shortcut text will be inserted at the current caret position. ++ +
+ +