Десктопные приложения на JavaScript. Часть 2

в 9:12, , рубрики: angular.js, crypto.js, javascript, nw.js

Данная статья является продолжением статьи «Десктопные приложения на 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:

Код директивы editable-text

(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;
}

Приложение выглядит следующим образом:

Десктопные приложения на JavaScript. Часть 2 - 1

Работа с буфером обмена

Итак, основа приложения готова, но оно пока не реализует основное назначение, мы не можем копировать пароли (вернее можем, но достаточно неудобно). Для начала рассмотрим работу с буфером обмена в 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js