Давно уже было желание «пощупать» Electron. Правда желание это постоянно ставилось под сомнение, что связано с использованием Skype в работе, тормоза которого, как бы твердят «нужно вырвать руки тем, кто использует WebView на десктопе». И вот, выпал случай, ну или рабочая необходимость, приобщиться к тем, кому стоило бы «вырвать руки».
Исходники: https://github.com/namedudko/itpharma-vnc-viewer
Установщик (Windows 32bit): https://github.com/namedudko/itpharma-vnc-viewer/releases/download/1.0.3/ITPharma.VNC.Viewer.v1.0.3.Setup.exe
Портативная версия: https://github.com/namedudko/itpharma-vnc-viewer/releases/download/1.0.3/win-32bit.zip
Итак, у нас, условно, имеется несколько сотен ПК на Linux и Windows. На всех них используется удаленный доступ по протоколу VNC, в качестве унификации. Сам протокол, естественно, закрыт VPN и Firewall. На все эти машины необходим удаленный доступ.
Т.е., в принципе, его можно обеспечить любым VNC-клиентом. Но проблема в том, что не было найдено в свободном доступе ни одного User Friendly интерфейса/оболочки для хранения настроек подключений. У нас же есть необходимость предоставить доступ в т.ч. людям довольно далеким от администрирования, т.е. так чтобы «нажал кнопочку и увидел экран».
При этом, соединения должны где-то храниться и как-то синхронизироваться, т.к. периодически парк ПК обновляется. Ну а в связи с тем, что основной мой стек это все-таки веб-технологии, было принято решение как-нибудь в выходной заняться изучением Electron, и, в качестве изучения, написать таковую оболочку.
Перед началом разработки нужно понимать, что цель написать в WebView весь клиент VNC не ставится (хотя такого рода проекты на базе websocket и существуют). Потому необходимо было выбрать подходящий клиент. Пошарив по «этим вашим интернетам», исходя из удобства управления (например, в TightVNC настройки каждого соединения хранятся в файле, что не добавляет адекватности к разработке), чистоты лицензирования (ну нет у меня желания связываться с двойным лицензированием или проприетарными лицензиями, к тому же вам теперь использовать данное ПО проще) выбор пал на UltraVNC.
Изучение API UltraVNC Viewer долго времени не заняло, т.к. это простые аргументы при запуске исполняемого файла из командной строки. Собственно, под Linux писать данный софт пока задачи не стоит (а при необходимости можно в сборку по Linux включить другой исполняемый файл/VNC Client с минимальным допилом), поэтому UltraVNC оказался вполне достаточен.
Для начала работы с Electron я скачал пример приложения, его и переделаю.
1 2 3 4 5 6 7 8 9 |
# Clone the repository $ git clone https://github.com/electron/electron-quick-start # Go into the repository $ cd electron-quick-start # Install dependencies $ npm install # Run the app $ npm start |
Удивительно, но все запустилось из коробки.
Как и при любой разработке UI, необходимо для начала составить макет. Верхняя часть будет представлять из себя Меню, состоящее из двух разделов:
Настройки (для хранения различных настроек) и Прочее (для всего остального). Приложение нельзя назвать слишком объемным, так что этого будет достаточно.
Под меню будет располагаться основной интерфейс программы. Который, в свою очередь, будет состоять из бегунка групп, строки поиска и набора подключений. Итоговый результат видно на скриншоте ниже.
Первое что нужно переделать это preload.js (скрипт, который исполняется перед загрузкой приложения). Для хранения данных изначально хотел использовать sqlite, но потом вспомнил о своем решении на базе Redux-Cluster которое вполне может закрыть эту задачу, без лишних «танцев с бубном». Более того, на тот момент, я с Electron был знаком на уровне «знаю что он такой есть», а данная библиотека позволяет не только создавать дамп данных на диске и загружаться с него, но и связывать данные в нескольких процессах на базе IPC/Socket/Websocket (в последнем случае требуется еще одна библиотека Redux-Cluster-Ws).
Хранилище Redux было огранизовано по принципу:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
{ names: {}, connections:{}, groups: {}, settings: { pathtype: "file", path: path.join(process.resourcesPath, 'connections.json'), viewonly: false, quickoption: "3", compresslevel: "6", quality: "6", colorsettings: "-full" } } |
, где
Для проброса хранилища Redux, можно просто добавить его к объекту window, после чего он будет доступен по ссылке из index.html
Для логгирования ошибок, в этом же скрипте открываю поток записи
1 2 |
let logger = fs.createWriteStream(path.join(process.resourcesPath, 'errors.log'), {flags:"a+"}); |
Как видно, для определения директории использована не __dirname, а ссылка rocess.resourcesPath. Это связано с тем, что __dirname в Electron ссылается на *.asar архив, недоступный для записи. Пока читал чуть подробнее про эти *.asar файлы понял, что примерно по тому же принципу у меня написана библиотека docdb (проект разрабатывается, когда присутствует время).
А вот rocess.resourcesPath вернет ссылку на директорию ./resources/ в каталоге с программой. Эту же директорию нужно использовать для настройки резервного копирования redux-cluster, хранения UltraVNC Viewer, connections.json.
Чтобы однозначно определить время ошибки в красивом виде, заберу из одного из проектов функцию штампа времени
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
function datetime() { try { let dataObject = new Date; let resultString; if(dataObject.getDate() > 9){ resultString = dataObject.getDate() + '.'; } else { resultString = '0' + dataObject.getDate() + '.'; } if((dataObject.getMonth()+1) > 9){ resultString = resultString + (dataObject.getMonth()+1) + '.' + dataObject.getFullYear() + ' '; } else { resultString = resultString + '0' + (dataObject.getMonth()+1) + '.' + dataObject.getFullYear() + ' '; } if(dataObject.getHours() > 9){ resultString = resultString + dataObject.getHours() + ':'; } else { resultString = resultString + '0' + dataObject.getHours() + ':'; } if(dataObject.getMinutes() > 9){ resultString = resultString + dataObject.getMinutes() + ':'; } else { resultString = resultString + '0' + dataObject.getMinutes() + ':'; } if(dataObject.getSeconds() > 9){ resultString = resultString + dataObject.getSeconds(); } else { resultString = resultString + '0' + dataObject.getSeconds(); } return resultString + " | "; } catch(e){ return '00.00.0000 00:00:00 | '; } } |
Добавляю её в логгирование, а также добавляю перенос строки в формате CR LF:
1 2 3 4 |
window.log = function(d){ logger.write(datetime()+d+'\r\n'); } |
Для унификации процесса обновления подключений в redux было написано 2 функции:
— setConnections — загрука настроек подключений в Redux с анализом пользовательского ввода
— reloadConnections — загрузка данных из файла/по ссылке в зависимости от настроек и передача их в функцию
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
function setConnections(d){ let _conn = { names: { //sha1(name):name }, connections:{ //sha1(name): {..Object} }, groups: { //sha1(name):[..Array of sha1(name)] } }; if((typeof(d) === 'object') && (!Array.isArray(d))){ for(const groupName in d){ let groupNameHash = hasher(groupName); if(groupNameHash){ _conn.names[groupNameHash] = groupName; _conn.groups[groupNameHash] = []; for(const name in d[groupName]){ let nameHash = hasher(groupName+name); if(nameHash){ if( (typeof(d[groupName][name].host) === 'string') && (typeof(d[groupName][name].port) === 'string') && (Number.isInteger(Number.parseInt(d[groupName][name].port))) && (typeof(d[groupName][name].password) === 'string') ){ _conn.names[nameHash] = name; _conn.groups[groupNameHash].push(nameHash); _conn.connections[nameHash] = d[groupName][name]; } else { window.log('PRELOAD->setConnections->'+name+' required fields (host, port, password) omitted!'); } } } } } window.processStorage.dispatch({type:"SYNC_CONNECTIONS", payload:_conn}); return true; } else { window.log('PRELOAD->setConnections->d require type Object!'); return false; } } function reloadConnections(){ return new Promise(function(res, rej){ switch(window.processStorage.getState().settings.pathtype){ case 'file': fs.readFile(window.processStorage.getState().settings.path, function(err, string){ if(err){ window.log('PRELOAD->reloadConnectionsFile->'+err.toString()); rej(err); } else { let connections = JSON.parse(string); if(setConnections(connections) === true){ res(true); } else { rej(false); } } }); break; case 'url': fetch(window.processStorage.getState().settings.path).then(function(response){ if((response.status).toString().substr(-3, 1) === "2"){ return response.json(); } else { return new Promise(function(rs,rj){ rj(response); }); } }).then(function(json){ if(setConnections(json) === true){ res(true); } else { rej(false); } }).catch(function(err){ window.log('PRELOAD->reloadConnectionsUrl->'+err.toString()); rej(err); }); break; default: rej(false); break; } }); } |
Эту же функцию(reloadConnections) я подключу при старте приложения, но ошибки выводить пользователю не буду (только в лог).
Основная функция приложения — это запуск дочернего процесса с заданными в настройках параметрами:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
window.startVncClient = function(sha1){ try{ if(typeof(window.processStorage.getState().connections[sha1]) === 'object'){ let params = [ "-connect", window.processStorage.getState().connections[sha1].host+':'+window.processStorage.getState().connections[sha1].port, "-password", window.processStorage.getState().connections[sha1].password, "-keepalive", "15", "-disablesponsor", "-fttimeout", "60" ]; if(window.processStorage.getState().settings.viewonly === true){ params.push("-viewonly"); } if(["0", "1", "2", "3", "4", "5", "6", "7"].indexOf(window.processStorage.getState().settings.quickoption) !== -1){ params.push('-quickoption'); params.push(window.processStorage.getState().settings.quickoption); } else { if(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].indexOf(window.processStorage.getState().settings.compresslevel) !== -1){ if(params.indexOf("-noauto") === -1){ params.push("-noauto"); } params.push("-compresslevel"); params.push(window.processStorage.getState().settings.compresslevel); } if(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].indexOf(window.processStorage.getState().settings.quality) !== -1){ if(params.indexOf("-noauto") === -1){ params.push("-noauto"); } params.push("-quality"); params.push(window.processStorage.getState().settings.quality); } if(["-8bit", "-256colors", "-64colors", "-8colors", "-8greycolors", "-4greycolors", "-2greycolors"].indexOf(window.processStorage.getState().settings.colorsettings) !== -1){ if(params.indexOf("-noauto") === -1){ params.push("-noauto"); } params.push(window.processStorage.getState().settings.colorsettings); } } let childProc = child_process.spawn(path.join(process.resourcesPath, 'vncviewer.exe'), params).on('error', function(err){ if(typeof(err) !== 'undefined') window.log('PRELOAD->childProcError->'+err.toString()); }).on('close', function(exit){ if(typeof(exit) !== 'undefined') window.log('PRELOAD->childProcExitWitchNotNullCode->'+err.toString()); }); } else { window.log('PRELOAD->startVncClient->Invalid request arguments!'); } } catch(err){ window.log('PRELOAD->startVncClient->'+err.toString()); } } |
Для получения событий от main.js необходимо использовать IPC, т.к. это разные процессы. В Electron уже встроен функционал IPC, для чего в main.js необходимо подключить
1 2 3 4 |
const {ipcMain} = require('electron'); А в preload.js соответственно: const {ipcRenderer} = require('electron'); |
Отправка событий из main.js осуществляется методом:
1 2 |
mainWindow.webContents.send(event); |
Теперь добавляю слушатели событий в preload.js.
1 2 3 4 5 |
ipcRenderer.on('aboutVNCViewer', function(){ document.getElementById("m_version").innerHTML = 'Версия: <a>'+require(path.join(__dirname, 'package.json')).version+'</a>'; $('#aboutModal').modal(); }); |
1 2 3 4 5 |
ipcRenderer.on('openSettingsWindow', function(){ window.renderSettingsWindow(); $('#settingsModal').modal(); }); |
Функция отрисовки интерфейса при открытии окна настроек синхронизации
1 2 3 4 5 6 7 8 9 10 11 12 |
window.renderSettingsWindow = function(){ switch(window.processStorage.getState().settings.pathtype){ case 'file': document.getElementById("settingsSyncType").selectedIndex = 0; break; case 'url': document.getElementById("settingsSyncType").selectedIndex = 1; break; } document.getElementById("settingsSyncPath").value = window.processStorage.getState().settings.path; } |
1 2 3 4 5 |
ipcRenderer.on('openSettingsConnWindow', function(){ window.renderSettingsConnWindow(); $('#settingsConnModal').modal(); }); |
Функция отрисовки интерфейса при открытии окна настроек соединения
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
window.renderSettingsConnWindow = function(){ if(window.processStorage.getState().settings.viewonly === true){ document.getElementById("settingsConnViewonly").checked = true; } else { document.getElementById("settingsConnViewonly").checked = false; } switch(window.processStorage.getState().settings.quickoption){ case '1': document.getElementById("settingsConnQuicksettings").selectedIndex = 0; break; case '2': document.getElementById("settingsConnQuicksettings").selectedIndex = 1; break; case '3': document.getElementById("settingsConnQuicksettings").selectedIndex = 2; break; case '4': document.getElementById("settingsConnQuicksettings").selectedIndex = 3; break; case '5': document.getElementById("settingsConnQuicksettings").selectedIndex = 4; break; case '7': document.getElementById("settingsConnQuicksettings").selectedIndex = 5; break; case '8': document.getElementById("settingsConnQuicksettings").selectedIndex = 6; break; } if(window.processStorage.getState().settings.quality){ document.getElementById("settingsConnQuality"+window.processStorage.getState().settings.quality).checked = true; } else { document.getElementById("settingsConnQuality0").checked = true; } switch(window.processStorage.getState().settings.colorsettings){ case '-full': document.getElementById("settingsConnColors").selectedIndex = 0; break; case '-8bit': document.getElementById("settingsConnColors").selectedIndex = 1; break; case '-256colors': document.getElementById("settingsConnColors").selectedIndex = 2; break; case '-64colors': document.getElementById("settingsConnColors").selectedIndex = 3; break; case '-8colors': document.getElementById("settingsConnColors").selectedIndex = 4; break; case '-8greycolors': document.getElementById("settingsConnColors").selectedIndex = 5; break; case '-4greycolors': document.getElementById("settingsConnColors").selectedIndex = 6; break; case '-2greycolors': document.getElementById("settingsConnColors").selectedIndex = 7; break; } if(window.processStorage.getState().settings.compresslevel){ document.getElementById("settingsConnCompression"+window.processStorage.getState().settings.compresslevel).checked = true; } else { document.getElementById("settingsConnCompression0").checked = true; } if(document.getElementById("settingsConnQuicksettings").value === '8'){ document.getElementById("settingsConnCompressionForm").removeAttribute("disabled"); document.getElementById("settingsConnColorsForm").removeAttribute("disabled"); document.getElementById("settingsConnQualityForm").removeAttribute("disabled"); } else { document.getElementById("settingsConnCompressionForm").setAttribute("disabled", "disabled"); document.getElementById("settingsConnColorsForm").setAttribute("disabled", "disabled"); document.getElementById("settingsConnQualityForm").setAttribute("disabled", "disabled"); } } |
1 2 3 4 5 6 7 8 9 10 11 |
ipcRenderer.on('reloadConnections', function(){ reloadConnections().then(function(){ }).catch(function(err){ if(err){ window.log('PRELOAD->reloadConnections->'+err.toString()); document.getElementById("smallModalText").innerHTML = JSON.stringify(err); $('#smallModal').modal(); } }); }); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
ipcRenderer.on('execVNCViewer', function(){ let params = [ "-keepalive", "15", "-disablesponsor", "-fttimeout", "60" ]; if(window.processStorage.getState().settings.viewonly === true){ params.push("-viewonly"); } if(["0", "1", "2", "3", "4", "5", "7"].indexOf(window.processStorage.getState().settings.quickoption) !== -1){ params.push('-quickoption'); params.push(window.processStorage.getState().settings.quickoption); } else { if(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].indexOf(window.processStorage.getState().settings.compresslevel) !== -1){ if(params.indexOf("-noauto") === -1){ params.push("-noauto"); } params.push("-compresslevel"); params.push(window.processStorage.getState().settings.compresslevel); } if(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].indexOf(window.processStorage.getState().settings.quality) !== -1){ if(params.indexOf("-noauto") === -1){ params.push("-noauto"); } params.push("-quality"); params.push(window.processStorage.getState().settings.quality); } if(["-8bit", "-256colors", "-64colors", "-8colors", "-8greycolors", "-4greycolors", "-2greycolors"].indexOf(window.processStorage.getState().settings.colorsettings) !== -1){ if(params.indexOf("-noauto") === -1){ params.push("-noauto"); } params.push(window.processStorage.getState().settings.colorsettings); } } let childProc = child_process.spawn(path.join(process.resourcesPath, 'vncviewer.exe'), params).on('error', function(err){ if(typeof(err) !== 'undefined') window.log('PRELOAD->childProcError->'+err.toString()); }).on('close', function(exit){ if(typeof(exit) !== 'undefined') window.log('PRELOAD->childProcExitWitchNotNullCode->'+err.toString()); }); }); |
Для реализации Меню и кастомизации приложения, внесу корректировки в main.js
1 2 3 4 5 6 7 8 9 10 11 12 |
mainWindow = new BrowserWindow({ width: 360, height: 750, resizable: true, center: true, icon: path.join(__dirname, 'ITPharmaVNCViewer.ico'), skipTaskbar: false, webPreferences: { preload: path.join(__dirname, 'preload.js') } }); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
mainMenu = Menu.buildFromTemplate([ { label: 'Настройки', submenu: [ { label: 'Настройки соединения', click: function(){ mainWindow.webContents.send('openSettingsConnWindow'); } }, { label: 'Настройки синхронизации', click: function(){ mainWindow.webContents.send('openSettingsWindow'); } }, { label: 'Обновить список соединений', click: function(){ mainWindow.webContents.send('reloadConnections'); } } ] }, { label: 'Прочее', submenu: [ { label: 'Запустить VNCViewer', click: function(){ mainWindow.webContents.send('execVNCViewer'); } }, { label: 'О программе', click: function(){ mainWindow.webContents.send('aboutVNCViewer'); } } ] } ]); Menu.setApplicationMenu(mainMenu); |
Меню стало выглядеть так
Осталось реализовать User Interface. Изначально была идея написать UI на основе React, но, честно говоря, стало лень, т.к. рендеринг React в браузере не отличается производительностью и не позволяет использовать большую часть модулей. Собирать приложение через webpack — вносит определенный дискомфорт в достаточно простую разработку. Ну и принцип «чем проще логика, тем стабильней её работа» никто не отменял.
Потому решил «нарисовать» UI на чистом JS с примесью jquery и Bootstrap 4 (который все равно хочет jquery), для чего нужно подключить в index.html три скрипта и css:
1 2 3 4 5 |
<script src="jquery_3.4.1.js"></script> <script src="popper_1.12.9.js"></script> <script src="bootstrap_4.0.0.js"></script> |
Собственные настройки css подключил отдельным файлом
1 |
<link rel="stylesheet" href="ITPharmaVNCViewer.css"> |
В данном файле, собственно, настроен кастомный scrollbar и заблокировано выделение теста (как в нативных приложениях)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
::-webkit-scrollbar{ width:12px; } ::-webkit-scrollbar-thumb{ border-width:1px 1px 1px 2px; border-color: #28a745; background-color: #2bbb4c; } ::-webkit-scrollbar-thumb:hover{ border-width: 1px 1px 1px 2px; border-color: #127c2b; background-color: #28a745; } ::-webkit-scrollbar-track{ border-width:0; } ::-webkit-scrollbar-track:hover{ border-left: solid 1px #aaa; background-color: #eee; } body{ -webkit-touch-callout: none; -webkit-user-select: none; -khtml-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } |
А для того, чтобы jquery был доступен в preload.js (естественно, нужно понимать, что доступен он станет только после того как index.html будет загружен полностью) добавлю строчку
1 2 |
<script>if (window.module) module = window.module;</script> |
Блок «О программе» по умолчанию скрыт, отрисовка данных происходит перед открытием данного блока.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<div class="modal fade" id="aboutModal" tabindex="-1" aria-labelledby="aboutModalH" role="dialog" aria-hidden="true"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="aboutModalH">О программе</h5> <button type="button" class="close" data-dismiss="modal" aria-label="Close"> <span aria-hidden="true">×</span> </button> </div> <div class="modal-body"> <text id="m_author">Автор: <a href="mailto:admin@sergdudko.tk">Name Surname</a></text><br /> <text id="m_version"></text><br /> <text><a href="" onclick="window.openUrl('https://itpharma.by');">ITPharma©2019</a></text><br /> </div> </div> </div> </div> |
Блок «Настройки соединения» по умолчанию скрыт, отрисовка данных происходит перед открытием данного блока.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 |
<div class="modal fade" id="settingsConnModal" tabindex="-1" role="dialog" aria-labelledby="settingsConnModalLabel" aria-hidden="true"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="settingsConnModalLabel">Настройки соединения.</h5> <button type="button" class="close" data-dismiss="modal" aria-label="Close"> <span aria-hidden="true">×</span> </button> </div> <div class="modal-body"> <form> <fieldset id="settingsConnViewonlyForm"> <div class="input-group mb-3"> <div class="input-group-prepend"> <div class="input-group-text"> <input type="checkbox" id="settingsConnViewonly"> </div> </div> <text class="form-control">Только чтение (игнорировать ввод)</text> </div> </fieldset> <fieldset id="settingsConnQuicksettingsForm"> <legend class="col-form-label col-sm-2 pt-0">Скорость соединения</legend> <div class="form-group"> <select class="form-control" id="settingsConnQuicksettings"> <option value="1">Автоматически</option> <option value="2">Локальная сеть (>1Mbit/s)</option> <option value="3">Средняя скорость (>128Kbit/s)</option> <option value="4">Модем (19-128Kbit/s)</option> <option value="5">Медленное (<19Kbit/s)</option> <option value="7">Максимальная (>2Mbit/s)</option> <option value="8">Вручную</option> </select> </div> </fieldset> <fieldset id="settingsConnCompressionForm"> <legend class="col-form-label col-sm-2 pt-0">Уровень сжатия</legend> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnCompression" id="settingsConnCompression0" value="0"> <label class="form-check-label" for="settingsConnCompression0">0</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnCompression" id="settingsConnCompression1" value="1"> <label class="form-check-label" for="settingsConnCompression1">1</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnCompression" id="settingsConnCompression2" value="2"> <label class="form-check-label" for="settingsConnCompression2">2</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnCompression" id="settingsConnCompression3" value="3"> <label class="form-check-label" for="settingsConnCompression3">3</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnCompression" id="settingsConnCompression4" value="4"> <label class="form-check-label" for="settingsConnCompression4">4</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnCompression" id="settingsConnCompression5" value="5"> <label class="form-check-label" for="settingsConnCompression5">5</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnCompression" id="settingsConnCompression6" value="6"> <label class="form-check-label" for="settingsConnCompression6">6</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnCompression" id="settingsConnCompression7" value="7"> <label class="form-check-label" for="settingsConnCompression7">7</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnCompression" id="settingsConnCompression8" value="8"> <label class="form-check-label" for="settingsConnCompression8">8</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnCompression" id="settingsConnCompression9" value="9"> <label class="form-check-label" for="settingsConnCompression9">9</label> </div> </fieldset> <fieldset id="settingsConnColorsForm"> <legend class="col-form-label col-sm-2 pt-0">Количество цветов</legend> <div class="form-group"> <select class="form-control" id="settingsConnColors"> <option value="-full">32 бит</option> <option value="-8bit">8 бит</option> <option value="-256colors">256 цветов</option> <option value="-64colors">64 цвета</option> <option value="-8colors">8 цветов</option> <option value="-8greycolors">8 оттенков серого</option> <option value="-4greycolors">4 оттенка серого</option> <option value="-2greycolors">2 оттенка серого</option> </select> </div> </fieldset> <fieldset id="settingsConnQualityForm"> <legend class="col-form-label col-sm-2 pt-0">Качество изображения</legend> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnQuality" id="settingsConnQuality0" value="0"> <label class="form-check-label" for="settingsConnQuality0">0</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnQuality" id="settingsConnQuality1" value="1"> <label class="form-check-label" for="settingsConnQuality1">1</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnQuality" id="settingsConnQuality2" value="2"> <label class="form-check-label" for="settingsConnQuality2">2</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnQuality" id="settingsConnQuality3" value="3"> <label class="form-check-label" for="settingsConnQuality3">3</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnQuality" id="settingsConnQuality4" value="4"> <label class="form-check-label" for="settingsConnQuality4">4</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnQuality" id="settingsConnQuality5" value="5"> <label class="form-check-label" for="settingsConnQuality5">5</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnQuality" id="settingsConnQuality6" value="6"> <label class="form-check-label" for="settingsConnQuality6">6</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnQuality" id="settingsConnQuality7" value="7"> <label class="form-check-label" for="settingsConnQuality7">7</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnQuality" id="settingsConnQuality8" value="8"> <label class="form-check-label" for="settingsConnQuality8">8</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="settingsConnQuality" id="settingsConnQuality9" value="9"> <label class="form-check-label" for="settingsConnQuality9">9</label> </div> </fieldset> <form> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" id="settingsConnReset" data-dismiss="modal">Закрыть</button> <button type="button" class="btn btn-primary" id="settingsConnCommit">Сохранить</button> </div> </div> </div> </div> |
Блок «Настройки синхронизации» по умолчанию скрыт, отрисовка данных происходит перед открытием данного блока.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
<div class="modal fade" id="settingsModal" tabindex="-1" role="dialog" aria-labelledby="settingsModalLabel" aria-hidden="true"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="settingsModalLabel">Настройки синхронизации.</h5> <button type="button" class="close" data-dismiss="modal" aria-label="Close"> <span aria-hidden="true">×</span> </button> </div> <div class="modal-body"> <form> <fieldset id="settingsSyncTypeForm"> <div class="form-group"> <select class="form-control" id="settingsSyncType"> <option value="file">Файл</option> <option value="url">URL</option> </select> </div> </fieldset> <fieldset id="settingsSyncPathForm"> <input type="text" class="form-control form-control-sm" id="settingsSyncPath" placeholder=".form-control-sm"> </fieldset> </form> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" id="settingsSyncReset" data-dismiss="modal">Закрыть</button> <button type="button" class="btn btn-primary" id="settingsSyncCommit">Сохранить</button> </div> </div> </div> </div> |
Блок для вывода ошибок приложения по умолчанию скрыт, отрисовка данных происходит перед открытием данного блока.
1 2 3 4 5 6 7 8 9 |
<div class="modal fade" id="smallModal" tabindex="-1" role="dialog" aria-labelledby="mySmallModalLabel" aria-hidden="true"> <div class="modal-dialog modal-sm"> <div class="modal-content" id="smallModalText"> ... </div> </div> </div> |
Основное тело программы, в верхней части которого располагается наименование группы и стрелки переключения между группами. В средней части располагается строка поиска по группе. Под ней располагаются кнопки подключений. Отрисовка данных происходит при запуске приложения, а также при изменении в хранилище redux данных связанных с данным блоком.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
<div class="d-flex flex-column"> <div class="d-flex flex-row" id="myMenu"> <div class="flex-column w-10 p-1"> <button type="input" id="prew" class="btn btn-lg btn-block btn-outline-success"> < </div> <div class="flex-column w-100 p-1"> <div class="flex-row text-truncate font-weight-bold btn btn-lg btn-block btn-outline-success disabled" id="myListName"></div> </div> <div class="flex-column w-10 p-1"> <button type="input" id="next" class="btn btn-lg btn-block btn-outline-success"> > </div> </div> <div class="d-flex flex-column p-1" id="mySearchList"> <input type="text" class="form-control" id="mySearchText" placeholder="Поиск..."> </div> <div class="d-flex flex-column" id="myList"> </div> </div> |
Осталось вдохнуть логику в UI. По-умолчанию, при запуске, будет выбрана для отображения группа с идентификатором 0
1 2 |
window.group = 0; |
Создаю функцию перерисовки блока подключений, в зависимости от идентификатора группы и строки поиска
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function renderMyList(c = 0, search = ''){ try{ //наименование группы let listNameNode = document.getElementById("myListName"); let groups = Object.keys(window.processStorage.getState().groups); listNameNode.innerHTML = ''+window.processStorage.getState().names[groups[c]]+''; let listNode = document.getElementById("myList"); //создание списка группы if(window.processStorage.getState().groups[groups[c]]){ while (listNode.firstChild) { listNode.removeChild(listNode.firstChild); } for(let i = 0; i < window.processStorage.getState().groups[groups[c]].length; i++){ if((search === "") || (window.processStorage.getState().names[window.processStorage.getState().groups[groups[c]][i]].toLowerCase().indexOf(search.toLowerCase()) !== -1)){ let oneListDivNode = document.createElement("div"); oneListDivNode.id = 'father_'+window.processStorage.getState().groups[groups[c]][i]; oneListDivNode.classList.add("flex-row", "p-1"); let oneListBtnNode = document.createElement("button"); oneListBtnNode.id = window.processStorage.getState().groups[groups[c]][i]; oneListBtnNode.type = "input"; oneListBtnNode.value = window.processStorage.getState().groups[groups[c]][i]; oneListBtnNode.onclick = function () { window.startVncClient(this.getAttribute("value")); }; oneListBtnNode.innerHTML = window.processStorage.getState().names[window.processStorage.getState().groups[groups[c]][i]]; oneListBtnNode.classList.add("btn", "btn-outline-info", "btn-sm", "btn-block", "text-truncate"); oneListDivNode.appendChild(oneListBtnNode); listNode.appendChild(oneListDivNode); } } } } catch(err){ window.log('DISPLAY->renderMyList->'+err.toString()); } } |
Функция отрисовки всего UI (включая блокировку кнопок):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function startRender(){ try{ if(window.group < 1){ document.getElementById("prew").disabled = true; } else { document.getElementById("prew").disabled = false; } if(window.group > (Object.keys(window.processStorage.getState().groups).length - 2)){ document.getElementById("next").disabled = true; } else { document.getElementById("next").disabled = false; } renderMyList(window.group); } catch(err){ window.log('DISPLAY->startRender->'+err.toString()); } } |
Запускаю её при старте приложения
1 2 |
startRender(); |
Подписываюсь на изменения redux, для определения изменившихся данных буду хранить хэш интересующих данных (занимает меньше памяти, по сравнению с хранением объекта предыдущей версии).
1 2 3 4 5 6 7 8 9 10 11 12 |
let sha1store; window.processStorage.subscribe(function(){ let sha1tmp = window.hasher(JSON.stringify({names:window.processStorage.getState().names, connections:window.processStorage.getState().connections, groups:window.processStorage.getState().groups})); if(sha1store !== sha1tmp){ sha1store = sha1tmp; if(window.group > (Object.keys(window.processStorage.getState().groups).length - 1 )){ window.group = 0; } startRender(); } }); |
Описываю логику строки поиска (управляющие клавиши Escape и Enter)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
document.getElementById("mySearchText").addEventListener('keydown', function(e) { try{ switch(e.key){ case 'Enter': case 'Ent': return renderMyList(window.group, document.getElementById("mySearchText").value); break; case 'Escape': case 'Esc': document.getElementById("mySearchText").value = ""; return renderMyList(window.group); break; } } catch(err){ window.log('DISPLAY->searchListener->'+err.toString()); } }, true); |
Кнопки переключения между группами
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
document.getElementById("prew").addEventListener('click', function(e) { try{ --window.group; startRender(); } catch(err){ window.log('DISPLAY->prewListener->'+err.toString()); } }, true); document.getElementById("next").addEventListener('click', function(e) { try{ ++window.group; startRender(); } catch(err){ window.log('DISPLAY->nextListener->'+err.toString()); } }, true); |
Клик на кнопку подтверждения настроек синхронизации
1 2 3 4 5 6 7 8 9 10 11 12 |
document.getElementById("settingsSyncCommit").addEventListener('click', function(e) { try{ window.processStorage.dispatch({type:"SET_SYNC_SETTINGS", payload: { pathtype: document.getElementById("settingsSyncType").value, path: document.getElementById("settingsSyncPath").value }}); setTimeout(window.renderSettingsWindow, 100); } catch(err){ window.log('DISPLAY->settingsSyncCommitClick->'+err.toString()); } }, true); |
Клик на кнопку подтверждения настроек соединения
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
document.getElementById("settingsConnCommit").addEventListener('click', function(e) { try{ let quality, compresslevel; let radiosQuality = document.getElementsByName('settingsConnQuality'); for (var i = 0, length = radiosQuality.length; i < length; i++){ if (radiosQuality[i].checked){ quality = radiosQuality[i].value; break; } } let radiosCompression = document.getElementsByName('settingsConnCompression'); for (var i = 0, length = radiosCompression.length; i < length; i++){ if (radiosCompression[i].checked){ compresslevel = radiosCompression[i].value; break; } } window.processStorage.dispatch({type:"SET_CONN_SETTINGS", payload: { viewonly: document.getElementById("settingsConnViewonly").checked, quickoption: document.getElementById("settingsConnQuicksettings").value, compresslevel: compresslevel, quality: quality, colorsettings: document.getElementById("settingsConnColors").value }}); setTimeout(window.renderSettingsConnWindow, 100); } catch(err){ window.log('DISPLAY->settingsConnCommitClick->'+err.toString()); } }, true); |
В зависимости от выбранного режима, блокировка дополнительных настроек соединения
1 2 3 4 5 6 7 8 9 10 11 12 |
document.getElementById("settingsConnQuicksettings").addEventListener('change', function(e) { if(document.getElementById("settingsConnQuicksettings").value === '8'){ document.getElementById("settingsConnCompressionForm").removeAttribute("disabled"); document.getElementById("settingsConnColorsForm").removeAttribute("disabled"); document.getElementById("settingsConnQualityForm").removeAttribute("disabled"); } else { document.getElementById("settingsConnCompressionForm").setAttribute("disabled", "disabled"); document.getElementById("settingsConnColorsForm").setAttribute("disabled", "disabled"); document.getElementById("settingsConnQualityForm").setAttribute("disabled", "disabled"); } }); |
Вот собственно и весь проект. Как показала практика, приложения на Electron достаточно тяжелые, но вполне жизнеспособные в современных реалиях. Правда, могут достаточно долго загружаться, если у вас какой-нибудь Avast.