Данная статья является продолжением статьи «Десктопные приложения на JavaScript. Часть 1». В предыдущей части мы рассмотрели следующее:
- установка NW.js
- сборка и запуск приложений на NW.js
- основы работы с нативными контроллами (на примере создания меню)
В рамках статьи мы рассмотрим создание приложения для хранения паролей. Приложение относительно простое и является по большей части прототипом для реального. Однако при желании и наличии времени, его можно доработать и вполне использовать для повседневной работы.
Основа приложения для хранения паролей
Как известно, разработку можно вести как на чистом JavaScript, так и используя разнообразные фреймворки, которых существует такое огромное количество, что порой теряешься в их многообразии и долго не можешь решиться, что же в итоге выбрать. Для разработки приложений особенно популярны паттерны, название которых начинается с MV (MVC , MVVM , MVP ). Одним из фрейворков использующих подобный паттерн, является Angular JS, именно его мы и будем использовать при разработке нашего приложения. Если вы не знакомы с ним, советую почитать документацию (tutorial , API), также можно почерпнуть основные сведения в руководстве на русском языке.
Что же будет представлять из себя приложение? Все данные отображаются в виде таблицы, при этом логин должен быть виден, а вместо пароля должны стоять звездочки. Пользователь может добавить новый логин/пароль, а также удалить записи, ставшие ненужными. Кроме того, необходимо предусмотреть возможность редактирования.
Реализуем базовую функциональность приложения. Для этого необходимо создать папку, в которой мы будем распологать исходный код, а также поместить в нее package.json (о том как это сделать, см. Часть 1).
Создадим базовую структуру папок, состоящую из следующих директорий:
- CSS — в этой папке будем размещать стили (Добавим сюда файл index.css, в котором будут содержаться основные стили)
- Controller — здесь будут находится контроллеры
- View — папка для представлений
- Directive — папка с директивами
- Lib — библиотеки (в эту папку необходимо скопировать angular.min.js, о том как добавить angularJS)
Кроме того в корень проекта добавим файл index.html, который будет являться точкой входа в приложение. Создадим базовую разметку:
<html ng-app="main">
<head>
<meta charset="utf-8">
<title>Password keeper</title>
<link rel="stylesheet" type="text/css" href="css/index.css">
</head>
<body>
<table>
<thead>
<tr>
<td></td>
<td>Login</td>
<td>Password</td>
<td></td>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
<td></td>
<td><a>удалить</a></td>
</tr>
</tbody>
<tfoot>
<tr>
<td></td>
<td><a>добавить</a></td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
<script type="text/javascript" src="lib/angular.min.js"></script>
</body>
</html>
Так как приложение у нас достаточно простое, создадим контроллер и в рамках него будем размещать всю основную логику приложения (по мере разрастания логики, необходимо добавить папку Service и размещать в ней сервисы, в которых и должна размещаться вся сложная логика, контроллеры по возможности необходимо оставлять «тонкими»). Назовем контроллер main, а файл контроллера main.ctrl.js. Итак, заготовка для контроллера:
(function () {
'use strict';
angular
.module('main', [])
.controller('MainCtrl', [MainCtrl]);
function MainCtrl() {
this.data = [];
return this;
}
})();
Данные, содержащие логины/пароли, для нашего прототипа будут размещаться в массиве data. Для упрощения реализации редактирования, создадим свой элемент EditableText и оформим его в виде директивы. Данный элемент будет работать следующим образом: элемент отображается как текст, при щелчке по элементу, текст превращается в тектовое поле input, при потере фокуса элемент вновь отображается как текст. Для этого создадим внутри папки View файл с разметкой для директивы editableText.html:
<input ng-model="value">
<span ng-click="edit()">{{value}}</span>
А внутри папки directive создадим файл editableText.js:
(function () {
'use strict';
angular
.module('main')
.directive('editableText', [editableText]);
function editableText() {
var directive = {
restrict: 'E',
scope: {
value: "="
},
templateUrl: 'view/editableText.html',
link: function ( $scope, element, attrs ) {
// получаем ссылку на внутренний input нашей директивы
var inputElement = angular.element( element.children()[0] );
element.addClass( 'editable-text' );
// функция, вызываемая при щелчке на элементе, когда он отображается
// в режиме для чтения
$scope.edit = function () {
element.addClass( 'active' );
inputElement[0].focus();
};
// при потере фокуса, т.е. когда пользователь закончил редактирование
inputElement.prop( 'onblur', function() {
element.removeClass( 'active' );
});
}
};
return directive;
}
})();
Для работы директивы необходимы также некоторые стили, которые можно разместить внутри index.css:
.editable-text span {
cursor: pointer;
}
.editable-text input {
display: none;
}
.editable-text.active span {
display: none;
}
.editable-text.active input {
display: inline-block;
}
Использование директивы происходит следующим образом:
<editable-text value="variable"></editable-text>
Для логина все в порядке — отображаем либо текст, либо текстовое поле, но как быть с паролем, ведь мы не должны его показывать. Добавим в scope нашей директивы поле crypto следующим образом:
scope: {
value: "=",
crypto: "="
}
А также изменим разметку директивы:
<input ng-model="value">
<span ng-click="edit()">{{crypto?'***':value}}</span>
Кроме того, необходимо не забывать добавлять скрипты в index.html:
<script type="text/javascript" src="lib/angular.min.js"></script>
<script type="text/javascript" src="controller/main.ctrl.js"></script>
<script type="text/javascript" src="directive/editableText.js"></script>
Пришло время добавить функциональность. Изменим контроллер следующим образом:
function MainCtrl() {
var self = this;
this.data = [];
this.remove = remove;
this.copy = copy;
this.add = add;
return this;
function remove(ind){
self.data.splice(ind, 1);
};
function copy(ind){
// добавим позже
};
function add(){
self.data.push({login: "login", password: "password"});
};
}
Кроме того необходимы изменения в разметке:
<body ng-controller="MainCtrl as ctrl">
<table>
<thead>
<tr>
<td></td>
<td>Login</td>
<td>Password</td>
<td></td>
</tr>
</thead>
<tbody>
<tr ng-repeat="record in ctrl.data track by $index">
<td><a ng-click="ctrl.copy($index)">{{$index}}</a></td>
<td><editable-text value="record.login"></editable-text></td>
<td><editable-text value="record.password" crypto="true"></editable-text></td>
<td><a ng-click="ctrl.remove($index)">удалить</a></td>
</tr>
</tbody>
<tfoot>
<tr>
<td></td>
<td><a ng-click="ctrl.add()">добавить</a></td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
<script type="text/javascript" src="lib/angular.min.js"></script>
<script type="text/javascript" src="controller/main.ctrl.js"></script>
<script type="text/javascript" src="directive/editableText.js"></script>
</body>
На данном этапе можно заняться стилизацией. Пример простой стилизации (напоминаю, что добавляем стили в index.css, однако если стилей будет достаточно много, можно разбить стили по файлам или даже использовать препроцессор, например LESS):
table {
border-collapse: collapse;
margin: auto;
width: calc(100% - 40px);
}
table, table thead, table tfoot,
table tbody tr td:first-child,
table tbody tr td:nth-child(2),
table tbody tr td:nth-child(3),
table thead tr td:nth-child(2),
table thead tr td:nth-child(3) {
border: 1px solid #000;
}
table td {
padding: 5px;
}
table thead {
background: #EEE;
}
table tbody tr td:first-child {
background: #CCC;
}
table tbody tr td:nth-child(2) {
background: #777;
color: #FFF;
}
table tbody tr td:nth-child(3) {
background: #555;
color: #FFF;
}
table thead tr td:nth-child(2),table thead tr td:nth-child(3) {
text-align: center;
}
table a {
font-size: smaller;
cursor: pointer;
}
Приложение выглядит следующим образом:
Работа с буфером обмена
Итак, основа приложения готова, но оно пока не реализует основное назначение, мы не можем копировать пароли (вернее можем, но достаточно неудобно). Для начала рассмотрим работу с буфером обмена в NW.js
Существует специальный объект — Clipboard, который используется как абстракция для буфера обмена Windows и GTK, а также для pasteboard (Mac). На момент написания статьи осуществляется поддержка записи и чтения только текста.
Для работы с объектом нам понадобится знакомый нам модуль nw.gui:
var gui = require('nw.gui');
var clipboard = gui.Clipboard.get();
Обратите внимание, что мы не может создать свой объект, мы можем получить лишь системный. Поддерживаются три метода:
- get ([type]) — получить объект из буфера обмена с указанием типа данного объекта, по умолчанию text, однако пока это единственный поддерживаемый тип
- set (data, [type]) — отправить объект в буфер обмена (также поддерживается лишь type — «text»)
- clear — очистить буфер обмена
Теперь можно доделать функциональность приложения, и контроллер будет выглядеть следующим образом:
function MainCtrl() {
var self = this;
var gui = require('nw.gui');
var clipboard = gui.Clipboard.get();
this.data = [];
this.remove = remove;
this.copy = copy;
this.add = add;
return this;
function remove(ind){
self.data.splice(ind, 1);
};
function copy(ind){
clipboard.set(self.data[ind].password);
};
function add(){
self.data.push({login: "login", password: "password"});
};
}
Хранение паролей
После того, как приложение было запущено, пользователь занес на хранение несколько паролей, закрыл приложение. На следующий день оказывается, что пароли пропали. Проблема в том, что мы держали их в обычной локальной переменной, которая при закрытии удалилась.
В третьей части мы рассмотрим работу NW.js с базами данных, а пока будем хранить пароли в localStorage. Прежде чем приступить к созданию функционала, (хотя приложение у нас пока лишь только прототип) необходимо позаботиться о безопасности. Для этого мы не должны хранить пароли в открытом виде.
Для шифрования/дешифрования существуют различные библиотеки на JavaScript. Одной из таких библиотек является crypto-js. Установим ее как модуль для node.js. Библиотека поддерживает большое количество стандартов, полный список которых можно найти в документации. При этом, можно подключить как все модули, так и отдельный модуль:
// подключаем все модули, доступ к отдельному модулю можно получить например так CryptoJS.HmacSHA1
var CryptoJS = require("crypto-js");
// подключаем отдельный модуль, например AES
var AES = require("crypto-js/aes");
Для того, чтобы зашифровать сообщение используется метод encrypt:
var ciphertext = CryptoJS.AES.encrypt('сообщение', 'секретный ключ');
Расшифровка происходит немного сложнее:
var bytes = CryptoJS.AES.decrypt(ciphertext.toString(), 'секретный ключ');
var plaintext = bytes.toString(CryptoJS.enc.Utf8);
Давайте модифицируем наше приложение для того, чтобы мы могли сохранять пароли при закрытии приложения и загружать их при запуске.
Создадим сервис crypto.svc и поместим в папку service (если вы еще не создали данную папку, то создайте ее в корне приложения):
(function () {
'use strict';
angular
.module('main')
.factory('CryptoService', [CryptoService]);
function CryptoService() {
var CryptoJS = require("crypto-js");
var secretKey = "secretKey";
var service = {
encrypt: encrypt,
decrypt: decrypt
};
return service;
function encrypt(data) {
return CryptoJS.AES.encrypt(JSON.stringify(data), secretKey);
}
function decrypt(text) {
var bytes = CryptoJS.AES.decrypt(text.toString(), secretKey);
var decryptedData = JSON.parse(bytes.toString(CryptoJS.enc.Utf8));
return decryptedData;
}
}
})();
Для использования нашего сервиса модернизируем контроллер:
(function () {
'use strict';
angular
.module('main', [])
.controller('MainCtrl', ['$scope', 'CryptoService', MainCtrl]);
function MainCtrl($scope, CryptoService) {
var self = this;
var gui = require('nw.gui');
var clipboard = gui.Clipboard.get();
var localStorageKey = "loginPasswordData"
this.data = [];
this.remove = remove;
this.copy = copy;
this.add = add;
load();
$scope.$watch("ctrl.data", save, true);
return this;
function remove(ind){
self.data.splice(ind, 1);
};
function copy(ind){
clipboard.set(self.data[ind].password);
};
function add(){
self.data.push({login: "login", password: "password"});
};
function load(){
var text = localStorage.getItem(localStorageKey);
if(text) {
self.data = CryptoService.decrypt(text);
}
}
function save(){
if(self.data) {
localStorage.setItem(localStorageKey, CryptoService.encrypt(self.data));
}
}
}
})();
Кроме подключения сервиса, нам так же понадобился уже существующий в AngularJS сервис $scope. Мы используем метод $watch для отслеживания момента изменения данных, для того, чтобы вовремя сохранить их (обратите внимание, что третьим аргументом мы передаем true для того, чтобы отслеживались изменения не только в массиве, т.е. вставка/удаление, но и изменения элементов массива, т.е. изменение логина или пароля отдельного элемента массива). Загрузка данных происходит при открытии представления.
Сворачиваем в трей
Основа приложения готова, но как известно подобные программы зачастую сворачиваются в системный трей, чтобы не перегружать пользователя обилием открытых окон.
Еще одна абстракция, которую ввели в NW.js — это трей: System Tray Icon для Windows, Status Icon для GTK, Status Item для OSX. Данный объект создается с помощью известного нам модуля gui:
var gui = require("nw.gui");
var tray = new gui.Tray({ title: 'Tray', icon: 'img/icon.png' });
При работе с данным объектом необходимо заботиться об области видимости переменной, если создать внутри функции, то вскоре он будет удален GC. При создании объекта можно сразу же создать свойства, как мы это сделали в примере, а можно позаботиться об этом несколько позже. Следующие свойства можно определить для данного объекта:
- Title — будет показываться только в Mac OSX
- Tooltip — подсказка, доступная на всех платформах
- Icon — иконка, отображаемая в трее, также доступна на всех платформах
- Menu — меню, которое в Mac OS X будет появляться по щелчку, для Windows и Linux — будет реагировать на одиночный щелчок и щелчок правой кнопкой (о том, как создать меню, см первую часть цикла статей)
Для того, чтобы использовать трей в нашем приложении, необходимо создать любой элемент разметки, наиболее очевидным вариантом является кнопка button. Далее необходимо подписаться на событие click, и использовать методы объекта window, с которым мы сейчас и познакомимся.
Работаем с окном
Для работы с окном, необходимо либо получить существующее окно, либо создать новое. Итак, для того, чтобы получить текущее окно, в котором и отображается наше приложение, необходимо выполнить команду:
var win = gui.Window.get();
А для создания нового окна, необходимо указать адрес, по которому располагается страница, для открытия в данном окне, а также параметры для открытия (данные параметры соотвествуют тем, что мы указываем при создании манифеста, см. первую часть из цикла статей):
var win = gui.Window.open ('https://myurl', {
position: 'center',
width: 901,
height: 127
});
Также в параметрах можно передать специальное свойство focus: true, при указании которого, только что созданное окно сразу же получит фокус, в противном случае фокус останется на нашем текущем окне.
Если мы создаем новое окно и хотим что-то с ним сделать после того, как оно будет создано, необходимо подписаться на соответствующее событие:
win.on ('loaded', function(){
// получим элемент document текущего окна, с которым впоследствии мы можем работать
var document = win.window.document;
// логика для работы с созданным окном...
});
Как видно из примера, одним из свойств окна является объект window, из которого мы можем получить остальные элементы, включая document. Кроме данного свойства окно также поддерживает множество других:
- x, y — координаты окна
- width, height — размеры окна
- title — заголовок окна
- menu — главное меню приложения, которое будет располагаться в верхней части окна
Данные свойства можно не только читать, но и изменять. Кроме них также есть свойства, доступные только для чтения (все они логические и могут принимать значения true или false)
- isTransparent — является ли окно прозрачным
- isFullscreen — открыто ли окно на полный экран
- isKioskMode — открыто ли приложение в киоск моде
Помимо свойств, объект поддерживает большое количество методов. Основные методы приводятся ниже и для удобства объединены по группам.
Методы для изменения позиции и размеров окна:
- moveTo — переместить окно на позицию, переданную в параметрах в виде координаты x и координаты y
- moveBy — переместить окно на определенное количество пикселей вправо и вниз (в случае задания отрицательных аргументов влево и вверх)
- resizeTo — изменить размеры окна: первый аргумент указывает ширину, второй — высоту окна
- resizeBy — изменить размеры окна на определенное количество пикселей вправо и вниз (в случае задания отрицательных аргументов влево и вверх)
- setPosition — задать специфическую позицию окна, переданную в качестве аргумента (на данный момент поддерживается только 'center')
Методы для работы с фокусом и видимостью:
- focus — метод без параметров для передачи фокуса окну
- blur — метод без параметров для того, чтобы сделать окно не активным
- hide — скрыть окно
- show — показать окно, однако если передать в качестве аргумента false, то метод будет работать как hide
Методы для управления свертыванием/развертыванием, закрытием окна:
- close — закрытие окна, при этом возникает событие close, однако если передать в качестве аргумента true, то событие возникать не будет
- reload — перезагрузить окно
- reloadDev — перезагрузить окно, но с элементами разработчика
- maximize — распахнуть окно на весь экран
- unmaximize — вернуть окно в исходный размер после того, как окно распахнули
- minimize — свернуть окно
- restore — развернуть окно, противоположно minimize
- setShowInTaskbar — показывать ли окно на панели задач
- setAlwaysOnTop — показывать ли окно поверх других
Методы для управления состоянием:
- enterFullscreen, leaveFullscreen, toggleFullscreen — управление полноэкранным режимом
- enterKioskMode, leaveKioskMode, toggleKioskMode — управление киоск режимом
- setTransparent — установить/сбросить прозрачность окна, в зависимости от переданного аргумента
- showDevTools — показать инструменты разработчика
- closeDevTools — скрыть инструменты разработчика
- isDevToolsOpen — проверка: открыты ли инструменты разработчика
Методы управления возможностью изменять размеры окна
- setResizable — установить/сбросить возможность изменения размера экрана
- setMaximumSize — задать ограничения на максимальный размер экрана (первый аргумент — ширина, второй — высота)
- setMinimumSize — задать ограничения на минимальный размер экрана (первый аргумент — ширина, второй — высота)
Итак, познакомившись с объектами tray и window, напишем функциональность сворачивания в трей. Для этого в разметку необходимо (как говорилось выше) добавить элемент, например кнопку или ссылку:
<a ng-click="ctrl.toTray()">В трей</a>
И изменить контроллер следующим образом:
(function () {
'use strict';
angular
.module('main', [])
.controller('MainCtrl', ['$scope', 'CryptoService', MainCtrl]);
function MainCtrl($scope, CryptoService) {
var self = this;
var localStorageKey = "loginPasswordData"
this.data = [];
var gui = require('nw.gui');
var clipboard = gui.Clipboard.get();
var win = gui.Window.get();
var tray = new gui.Tray({ title: 'Tray', icon: 'img/test.png' });
tray.on("click", restoreFromTray);
this.remove = remove;
this.copy = copy;
this.add = add;
this.toTray = toTray;
load();
$scope.$watch("ctrl.data", save, true);
return this;
function remove(ind){
self.data.splice(ind, 1);
};
function copy(ind){
clipboard.set(self.data[ind].password);
};
function add(){
self.data.push({login: "login", password: "password"});
};
function load(){
var text = localStorage.getItem(localStorageKey);
if(text) {
self.data = CryptoService.decrypt(text);
}
}
function save(){
if(self.data) {
localStorage.setItem(localStorageKey, CryptoService.encrypt(self.data));
}
}
function toTray(){
win.minimize();
win.setShowInTaskbar(false);
}
function restoreFromTray(){
win.restore();
win.setShowInTaskbar(true);
}
}
})();
Также для работы данного примера необходимо создать папку img и поместить туда иконку трея (в данном примере это img/test.png).
Заключение
В рамках статьи мы написали прототип приложения, который вы можете улучшить различным образом: начиная от стилей и заканчивая улучшениями в функционале. Например:
- можно подписаться на событие keydown и для первых 10 паролей, при нажатии на кнопку от 0 до 9, копировать пароль в буфер обмена, это упростит и ускорит работу с программой
- добавить способ копирования не только пароля, но и логина
Успехов в программировании!
Автор: sev89