Статья представляет собой пошаговое описание моего опыта создания кроссплатформенного десктопного приложения с помощью Webix, Electron и Node.js.


image

Однажды мне пришла в голову светлая мысль создать десктопное приложение на базе стека веб-технологий, который мне хорошо знаком. Знаю, что программисты, пишущие под десктоп, обычно используют C++, Java, C#, а на стек веб-технологий для этих целей смотрят свысока. Но, поскольку я писал приложение для себя, то справедливо решил, что использование знакомых инструментов ускорит процесс. Ну и конечно захотелось «скрестить ужа с ежом» и посмотреть что получится. Если вкратце, то получившийся результат можно запускать и как обычное веб-приложение, и как десктоп.

Код уже готового приложения можно скачать с GitHub.

Что будет делать наше приложение… Это TODO-list (а как же иначе...), в который мы сможем добавлять события, редактировать их и удалять. Событие будет иметь заголовок, содержание, место проведения, дату и приоритет. Также будет доступна возможность перевода интерфейса на русский и английский языки. Назовем его «Data master».

Для создания веб-приложения я использовал Webix. Он представляет собой кроссплатформенную и кроссбраузерную UI библиотеку, использующие компоненты для быстрого построения приложения с использованием JavaScript синтаксиса. Для компиляции веб-приложения в десктоп использовался Electron. Это кроссплатформенный инструмент, работающий на базе Node.js и позволяющий компилировать веб-приложение для запуска на различных платформах различной разрядности: Windows, Linux, Mac. Для всяких вспомогательных вещей используются инструменты на базе Node.js.

Начнем со структуры папок. В корне проекта я создал ее в таком виде:

  • css — стили
  • data — бэкенд
  • img — изображения
  • js — скрипты JavaScript

После установки модулей Node.js добавится папка «node_modules», для Webix будет использоваться папка «codebase», в папке "~/release/DataMaster" будут версии десктопного приложения для различных платформ.

В итоге структура проекта у меня получилась такая:
image


Корневая папка проекта должна быть расположена на сервере. В моем случае это Apache.
Итак, для начала я зашел на страницу загрузки Webix и нажал «Скачать Webix Standard». Это бесплатная версия библиотеки (лицензия «GNU GPLV3»), которая вполне подойдет для наших нужд. Имеется еще коммерческая версия «Webix PRO», которая отличается главным образом расширенной библиотекой виджетов, а также возможностями техподдержки. Из полученного архива «webix.zip» копируем папку «codebase» в корень нашего проекта. Внутри папки «codebase» обратите внимание на файлы webix.js и webix.css. Подключение этих файлов в приложении позволяет работать с Webix. В папке «skins» содержатся css-файлы с темами.

Создадим в корне проекта файл index.html.
index.html
<!DOCTYPE HTML>
<html>
    <head>
    	<link rel="stylesheet" href="codebase/skins/contrast.css" type="text/css">
    	<link rel="stylesheet" href="css/main.css" type="text/css">
    	<script src="codebase/webix.js" type="text/javascript"></script>
    	<script src="codebase/i18n/en.js" type="text/javascript"></script>
    	<script src="codebase/i18n/ru.js" type="text/javascript"></script>
    </head>
    <body>
    	<script src="bundle.js" type="text/javascript"></script>
    </body>
</html>


Добавим webix.js. Подключение webix.css дает нам возможность использовать стандартную тему. Я же решил подключить симпатичную темненькую тему, которая лежит в «codebase/skins/contrast.css». Также мы подключили файлы из папки «codebase/i18n» для использования встроенной возможности локализации Webix. В индексного файла подключаем файл «bundle.js». Там будет находиться сборка всего нашего js-кода. Для сборки нам понадобится Node.js и Gulp.

Если у вас еще не установлена Node.js, то сделать это можно отсюда. Командами $ node -v и $ npm -v проверьте корректность установки Node.js и пакетного менеджера платформы — NPM.

Теперь в папке «js» мы будем создавать основную логику приложения. Файл internalization.js содержит объект для интернационализации интерфейса приложения. По аналогии с уже имеющимися языками (русский, английский) вы можете добавить туда другие языки в случае необходимости.
internalization.js
var translations = {
	// English
	"en-US": {
		localeName: "en-US",
		headerTitle: "Data master",
		resetFilters: "Reset filters",
		changeLocale: "Change language:",
		loadData: "Load data",
		addRow: "Add row",
		clearSelection: "Clear selection",
		deleteRow: "Delete row",
		saveData: "Save data",
		title: "Title",
		noItemSelected: "No item selected",
		dataSaved: "Data saved",
		reservedButton: "Reserved botton"
	},

	// Russian
	"ru-RU": {
		localeName: "ru-RU",
		headerTitle: "Мастер данных",
		resetFilters: "Сбросить фильтры",
		changeLocale: "Сменить язык:",
		loadData: "Загрузить данные",
		addRow: "Добавить ряд",
		clearSelection: "Снять выделение",
		deleteRow: "Удалить ряд",
		saveData: "Сохранить",
		title: "Название",
		noItemSelected: "Нет выбранных рядов",
		dataSaved: "Данные сохранены",
		reservedButton: "Зарезервировано..."
	}
};


В файле logic.js содержатся функции, назначение которых вы можете понять из их названия и из комментариев к коду.
logic.js

var defaultLocale = "en-US";

// object from translations.js
var localizator = translations[defaultLocale];

/**
 * Get data from backend and fill datatable grid
 */
function getData() {
    $$("dataFromBackend").clearAll();
    $$("dataFromBackend").load("http://localhost/data_master/data/data.php");
}

/**
 * Add new row to datatable
 */
function addRow() {
    $$("dataFromBackend").add(
        {
            title: "-----",
            content: "-----",
            place: "-----"
            //date: "-----",
            //priority: "-----"
        }
    );
}

/**
 * Reset selection in datatable grid
 */
function clearSelection() {
    $$("dataFromBackend").unselectAll();
}

/**
 * Delete selected row
 */
function deleteRow() {
    if (!$$("dataFromBackend").getSelectedId()) {
        webix.alert(localizator.noItemSelected);
        return;
    }

    //removes the selected item
    $$("dataFromBackend").remove($$("dataFromBackend").getSelectedId());
}

/**
 * Save data to backend from datatable grid
 */
function saveData() {
    var grid = $$("dataFromBackend");
    var serializedData = grid.serialize();
    webix.ajax().post("http://localhost/data_master/data/save.php", {data: serializedData});
    webix.alert(localizator.dataSaved);
}

/**
 * Reset filters settings
 */
function resetFilters() {
    $$("dataFromBackend").getFilter("title").value = null;
    $$("dataFromBackend").getFilter("content").value = null;
    $$("dataFromBackend").getFilter("place").value = null;
    $$("dataFromBackend").getFilter("date").value = null;
    $$("dataFromBackend").getFilter("priority").value = null;
    
    // reload grid
    $$("dataFromBackend").clearAll();
    $$("dataFromBackend").load("http://localhost/data_master/data/data.php"); 
}

/**
 * Change translation to selected
 */
function changeLocale(locale) {
    localizator = translations[locale];
    
    $$("headerContainer").define("template", localizator.headerTitle);
    $$("headerContainer").refresh();

    $$("resetFiltersContainer").define("value", localizator.resetFilters);
    $$("resetFiltersContainer").refresh();

    $$("changeLocale").define("label", localizator.changeLocale);
    $$("changeLocale").refresh();

    $$("loadData").define("value", localizator.loadData);
    $$("loadData").refresh();

    $$("addRow").define("value", localizator.addRow);
    $$("addRow").refresh();

    $$("clearSelection").define("value", localizator.clearSelection);
    $$("clearSelection").refresh();

    $$("deleteRow").define("value", localizator.deleteRow);
    $$("deleteRow").refresh();

    $$("saveData").define("value", localizator.saveData);
    $$("saveData").refresh();

    $$("reservedButton").define("value", localizator.reservedButton);
    $$("reservedButton").refresh();

    webix.i18n.setLocale(locale);
}

/**
 * Function for reserved button
 */
function reservedButton() {
    // your code...
}


Большинство функций являются обработчиками события «onclick» кнопок. Код функций в основном представляет собой способы работы с Webix-элементами. В общих чертах он интуитивно понятен, если нужна более подробная информация — добро пожаловать на страницу документации Webix.

В файле objects.js планировалось хранить функции-конструкторы, которые являются обертками над стандартными компонентами Webix. Я думал поместить туда часто используемые в приложении виджеты, но ограничился лишь одним — наиболее повторяющимся — элементом Button. Чуть ниже я поясню его использование.
objects.js

/**
 * Create object with type "Button"
 *
 * @constructor
 */
function Button(id, value, type, width, onClickFunction) {
	this.view = "button";
	this.id = id;
	this.value = value;
	this.type = type;
	this.width = width;
	this.on = {
		"onItemClick": function(){ 
	      onClickFunction();
	    }
	}
}


Структура задана в файле structure.js

/**
 * Create main layout
 */
webix.ui({
	view: "layout",
	id: "page",
	rows:[
		{
			cols: [
				{
					view:"icon",
					id: "headerIconContainer",
					icon:"calendar"
				},
				{
					view:"template",
					id: "headerContainer",
					type:"header",
					template:"Data master"
			    },
	  new Button("resetFiltersContainer", "Reset filters", "form", 150, resetFilters),
				{
					id: "divider",
					width: 20
				},
				{
					view: "combo", 
				    id: "changeLocale",
				    label: 'Change locale:',
				    labelWidth: 130,
				    width: 230,
				    align: "right",
				    value: "en-US",
				    options: [
				    	"ru-RU",
				    	"en-US"
				    ],
				    on: {
				        "onChange": function(newv, oldv) { 
				          	changeLocale(newv);
				        }
				    }
				}
			]
		},
	  {
	  	view: "datatable",
	  	id: "dataFromBackend",
		columns: [
			{
				id: "title",
				header: [
					{
						text: "<b>Title</b>"
					},
					{
						content: "textFilter"
					}
				],
				editor: "text",
				fillspace: 2
			},
			{
				id: "content",
				header: [
					{
						text: "<b>Content</b>"
					},
					{
						content: "textFilter"
					}
				],
				editor: "popup",
				fillspace: 8
			},
			{
				id: "place",
				header: [
					{
						text: "<b>Place</b>"
					},
					{
						content: "textFilter"
					}
				],
				editor: "text",
				fillspace: 2
			},
			{
				id: "date",
				header: [
					"<b>Date</b>",
					{
						content: "dateFilter"
					}
				],
				editor: "date",
				map: "(date)#date#",
				format: webix.Date.dateToStr("%d.%m.%Y"),
				fillspace: 2
			},
			{
				id: "priority",
				header: [
					"<b>Priority</b>",
					{
						content: "selectFilter"
					}
				],
				editor: "select",
				options: [1, 2, 3, 4, 5],
				fillspace: 1
			}
		],
		editable: true,
		select: "row",
		multiselect: true,
	    // initial data load
	    data: webix.ajax().post("http://localhost/electron_with_backend/data/data.php")
	  },
	  	{
	  		view: "layout",
	  		id: "buttonContainer",
	  		height: 50,
	  		cols: [
			  	// Webix ui.button structure example:
			  	/*{
				  	view: "button", 
				    id: "loadData", 
				    value: "Load data", 
				    type: "form", 
				    width: 150,
				    on: {
					    "onItemClick": function(id, e, trg){ 
					      getData();
					    }
					}
				},*/
		  new Button("loadData", "Load data", "form", 150, getData),
		  new Button("addRow", "Add row", "form", 150, addRow),
		  new Button("clearSelection", "Clear selection", "form", 150, clearSelection),
		  new Button("deleteRow", "Delete row", "form", 150, deleteRow),
		  new Button("saveData", "Save data", "form", 150, saveData),
		  new Button("reservedButton", "Reserved button", "form", 150, reservedButton),
				{}
			]
	  	}
	]
});

$$("buttonContainer").define("css", "buttonContainerClass");
$$("resetFiltersContainer").define("css", "resetFiltersContainerClass");
$$("headerIconContainer").define("css", "headerIconContainerClass");
$$("headerContainer").define("css", "headerContainerClass");
$$("changeLocale").define("css", "changeLocaleClass");
$$("divider").define("css", "dividerClass");


Как это работает… В метод webix.ui() передается объект, имеющий многоуровневую структуру. Свойство view определяет тип виджета Webix: в нашем случае «layout». Этих типов очень много, каждый из них имеет свои методы и свойства. Кроме того, мы можем расширять стандартные компоненты Webix с помощью метода webix.protoUI(), добавляя или переопределяя необходимую нам функциональность. Как видите, работа с Webix осуществляется с помощью Javascript, поэтому весь код работы с этой библиотекой мы помещаем в теги <script>. В методе webix.ui() мы задали последовательность из рядов и колонок, часть которых, в свою очередь, имеют вложенные ряды и колонки, образуя сетку, параметры элементов которой мы можем задать, например, с помощью свойств «width» и «height». В колонки и ряды мы «вкладываем» элементы, настраивая их. Например, вот так можно определить кнопку:

{
	view: "button", 
	id: "loadData", 
	value: "Load data", 
	type: "form", 
	width: 150,
	on: {
	    "onItemClick": function(id, e, trg){ 
	      getData();
	    }
}


Свойство «id» — это свойство Webix «view_id», через которое мы можем получить доступ к элементу с помощью метода $$(). Например, $$(«loadData») вернет нам объект кнопки, описанной в коде выше. Свойство «value» определяет надпись на кнопке, «type» — тип, «width» — ширину. В объекте «on» можно задать обработчики событий для элемента. В примере выше — он один («onItemClick») и соответствует событию «onclick», которое вызывает функцию getData().

Вместо описанной выше структуры для создания элемента Button (в файле «objects.js») я использовал функцию-конструктор. Она создает и возвращает объект Button в соответствии с переданными параметрами. Это позволяет устранить дублирование кода и создавать объект таким образом:
new Button("loadData", "Load data", "form", 150, getData)
Кстати, я добавил зарезервированную кнопку для лучшего UX в скомпилированном приложении. Функциональности для нее я не придумал, поэтому можете использовать ее, как вам вздумается.

В нижней части файла components.js имеется код вида:
$$("buttonContainer").define("css", "buttonContainerClass")
Таким способом мы можем определять и изменять свойства элементов (в примере: добавление атрибута класс со значением «buttonContainerClass»). Способ, приведенный здесь, указан для наглядности. Мы можем изначально инициализировать объект каким либо классом, присвоив значение свойству «css».

Webix имеет различные способы загрузки данных в приложение и в отдельные элементы. В функции getData() я использовал метод load() для загрузки данных в грид. Метод убращается к нашему бэкенду по URL «data/data.php».

Бэкенд нашего приложения до неприличия прост. Я решил не использовать базы данных для такого маленького приложения. Данные хранятся в файле data.json, читаются оттуда с помощью data.php, и сохраняются туда же с помощью save.php.
data.php
<?php
$dataFromFile = json_decode(file_get_contents("data.json"));
echo json_encode($dataFromFile);
/*$example_json_data = array(
  array (title => "My Fair Lady", year => 1964, votes => 533848, rating => 8.9, rank => 5),
  array (title => "Film 1", year => 1984, votes => 933848, rating => 6.9, rank => 4),
  array (title => "Film 2", year => 1966, votes => 53848, rating => 4.3, rank => 5),
  array (title => "Film 3", year => 1975, votes => 567848, rating => 2.9, rank => 2),
  array (title => "Film 4", year => 1981, votes => 433788, rating => 6.3, rank => 1)
);*/
//echo json_encode($example_json_data);


save.php
<?php
$data = $_POST["data"];
file_put_contents("data.json", $data);


В коммерческом проекте, конечно, следовало бы сделать различные проверки данных и обработку ошибок, но для наглядности я их опустил. В файл data-example.json я поместил образец структуры данных для загрузки в Webix элемент «datatable», взятый с сайта документации.
data-example.json
[
  {"title":"My Fair Lady", "year":1964, "votes":533848, "rating":8.9, "rank":5},
  {"title":"Film 1", "year":1984, "votes":933848, "rating":6.9, "rank":4},
  {"title":"Film 2", "year":1966, "votes":53848, "rating":4.3, "rank":5},
  {"title":"Film 3", "year":1975, "votes":567848, "rating":2.9, "rank":2},
  {"title":"Film 4", "year":1981, "votes":433788, "rating":6.3, "rank":1}
]


Сохранение данных осуществляется в функции saveData() с помощью AJAX-метода webix.ajax().post(), которому передается URL на бэкенде и объект с данными. Вообще Webix может работать с данными по-разному, принимая и отдавая, например, json или xml. Кстати, в скачанном архиве с версией Webix, кроме папки codebase есть папка samples, в которой можно глянуть примеры работы с различными компонентами системы. В папке «samples/common/connector» есть «родная» основа для работы с бэкендом.

Таким образом, в общих чертах работа нашего приложения выполняется так… Создается сетка с рядами и колонками, в которые помещаются элементы. При взаимодействии с элементами происходят события, и выполняются обработчики, определенные для этих событий. Некоторые из обработчиков используют методы для общения с бэкендом для получения и сохранения данных. Итого мы имеем SPA-приложение, где получение и обработка данных не требуют перезагрузки страницы. Перевод интерфейса приложения осуществляется за счет взятия свойств объекта translations в соответствии с выбранной локалью, задания нового значения свойств «value» элементов и обновления этих элементов. Логика висит на событии «onChange» комбобокса и вызывает нашу функцию changeLocale(). В этой функции мы, кстати, встроенный метод webix.i18n.setLocale(locale), куда передаем локаль из комбобокса. Подробнее можно глянуть здесь.

Затем нам нужно собрать весь js код в бандл. Но сначала проделаем небольшую подготовительную работу. Создадим в корне проекта файл package.json с основными настройками приложения.
package.json
{
    "name": "data_master",
    "description": "Simple ToDo list with desktop building",
    "version": "0.0.1",
    "homepage": "https://github.com/paratagas/data_master",
    "repository": {
        "type": "git",
        "url": "git+https://github.com/paratagas/data_master.git"
    },
    "author": {
        "name": "Yauheni Svirydzenka",
        "email": "partagas@mail.ru",
        "url": "https://github.com/paratagas"
    },
    "tags": [
        "node.js",
        "webix",
        "electron",
        "ToDo list"
    ],
    "main": "main.js",
    "scripts": {
        "start": "electron .",
        "package": "electron-packager ./ DataMaster --all --out ~/release/DataMaster --overwrite"
    },
    "dependencies": {
        "electron-prebuilt": "^0.35.6",
        "electron-packager": "^8.4.0"
    },
    "devDependencies": {
        "gulp": "^3.9.0",
        "gulp-concat": "^2.6.0",
        "gulp-uglify": "^1.2.0",
        "gulp-sourcemaps": "^1.5.2"
    },
    "license": "GPL-3.0"
}


Затем выполним команду $ npm install для загрузки необходимых компонентов. В файле gulpfile.js в корне проекта зададим настройки нашей сборки.

gulpfile.js

var gulp = require('gulp'),
    uglify = require('gulp-uglify'),
    concat = require('gulp-concat');
    // to create source mapping
    sourcemaps = require('gulp-sourcemaps');

/*
 * Collect all js files to one bundle script
 * Command: "gulp bundle"
 */
gulp.task('bundle', function() {
    // choose any files in directories and it's subfolders
    return gulp.src('js/**/*.js')
        .pipe(sourcemaps.init())
        .pipe(concat('bundle.js'))
        .pipe(sourcemaps.write('./'))
        //.pipe(uglify())
        // output result to current directory
        .pipe(gulp.dest('./'));
});

/*
 * Watch js files changing and run task
 * Command: "gulp watch"
 */
gulp.task('watch', function () {
	gulp.watch('./js/**/*.js', ['bundle']);
});


Я закомментировал выполнение минификации, чтобы можно было посмотреть как в итоге выглядит bindle.js со всем нашим кодом. Кроме того, я не использовал минификацию CSS, так как у нас только один файл с небольшим количеством стилей. Вы можете изменить это поведение, если захотите. Теперь мы можем собрать проект, выполнив команду $ gulp bundle в корне проекта. В процессе разработки команда $ gulp watch позволяет отслеживать изменения js файлов и при наличии таковых выполнять команду $ gulp bundle.

Наше веб-приложение уже готово и мы можем запустить его на рабочем сервере. У меня получилось что-то вроде:
image

Теперь давайте сделаем из него десктоп с помощью Electron. Выбрать и скачать свежую версию можно здесь. Внутри страницы каждого релиза есть список версий для различных платформ. В нашем «package.json» определены два модуля, которые позволят нам сделать основную работу. Модуль «electron-prebuilt» отвечает за предварительную сборку и запуск приложения. Отдельно модуль можно установить командой $ npm install --save-dev electron-prebuilt. В свою очередь, модуль «electron-packager» позволяет компилировать приложения для целевой платформы или для всех возможных платформ. Отдельно устанавливается командой $ npm install --save-dev electron-packager.

Обратите внимание на секцию:
"scripts": {
"start": "electron .",
"package": "electron-packager ./ DataMaster --all --out ~/release/DataMaster --overwrite"
},


Определив ее, вы можем запускать предсборку приложения командой $ npm start, а компиляцию — командой $ npm run-script package. Кстати, если мы изменим команду package, например, на "package": "electron-packager ./ DataMaster --win32-x64 --out ~/release/DataMaster --overwrite" то приложение будет скомпилировано для целевой платформы — в нашем случае Windows x64. На данный момент Electron поддерживает платформы: Windows x32/x64, Linux x32/x64/armv7, OS X/x64. Для более полного понимания можно глянуть документацию.

Создадим в корне проекта файл main.js. Он нужен для настроек Electron.
main.js

/*
 * Commands:
 * npm init - initialize npm in current directory
 * npm install - install modules
 * npm install --save-dev electron-prebuilt - install module for pred-build
 * npm install --save-dev electron-packager - install module for build
 * npm start - to start app
 * npm run-script package - to compile app
 */

const electron = require('electron');
// lifecycle of our app
const app = electron.app;
// create window for our app
const BrowserWindow = electron.BrowserWindow;

// To send crash reports to Electron support
// electron.crashReporter.start();

// set global link
// if not, the window will be closed after garbage collection
var mainWindow = null;

/**
 * Check that all windows are closed before quiting app
 */
app.on('window-all-closed', function() {
    // OS X apps are active before "Cmd + Q" command. Close app
    if (process.platform != 'darwin') {
        app.quit();
    }
});

/**
 * Create main window menu
 */
function createMenu() {
    var Menu = electron.Menu;
    var menuTemplate = [
        {
            label: 'File',
            submenu: [
                {
                    label: 'New window',
                    click: function() {
                        createSubWindow();
                    }
                },
                {type: "separator"},
                {
                    label: 'Exit',
                    click: function() {
                        app.quit();
                    }
                }
            ]
        },
        {
            label: 'Edit',
            submenu: [
                {
                    label: 'Cut',
                    role: 'cut'
                },
                {
                    label: 'Copy',
                    role: 'copy'
                },
                {
                    label: 'Paste',
                    role: 'paste'
                }
            ]
        },
        {
            label: 'About',
            submenu: [
                {
                    label: 'Name',
                    click: function() {
                        console.log(app.getName());
                    }
                },
                {
                    label: 'Version',
                    click: function() {
                        console.log(app.getVersion());
                    }
                },
                {
                    label: 'About',
                    click: function() {
                        console.log('ToDo list');
                    }
                }
            ]
        },
        {
            label: 'Help',
            submenu: [
                {
                    label: 'Node.js docs',
                    click: function() {
                        require('electron').shell.openExternal("https://nodejs.org/api/");
                    }
                },
                {
                    label: 'Webix docs',
                    click: function() {
                        require('electron').shell.openExternal("http://docs.webix.com/");
                    }
                },
                {
                    label: 'Electron docs',
                    click: function() {
                        require('electron').shell.openExternal("http://electron.atom.io/docs/all");
                    }
                }
            ]
        }
    ];

    var menuItems = Menu.buildFromTemplate(menuTemplate);
    Menu.setApplicationMenu(menuItems);
}

/**
 * Create main window
 */
function createMainWindow() {
    mainWindow = new BrowserWindow({
        title: "Data master",
        resizable: false,
        width: 910,
        height: 800,
        // set path to icon for compiled app
        icon: 'resources/app/img/icon.png',
        // set path to icon for launched app
        //icon: 'img/icon.png'
        center: true
        // to open dev console: The first way
        //devTools: true
    });

    createMenu();

    // load entry point for desktop app
    mainWindow.loadURL('file://' + __dirname + '/index.html');
    
    // to open dev console: The second way
    //mainWindow.webContents.openDevTools();

    // Close all windows when main window is closed
    mainWindow.on('closed', function() {
        mainWindow = null;
        newWindow = null;
    });
}

/**
 * Create sub menu window
 */
function createSubWindow() {
    newWindow = new BrowserWindow({
        title: "Go to GitHub",
        resizable: false,
        // imitate mobile device
        width: 360,
        height: 640,
        icon: 'resources/app/img/mobile.png',
        center: true
    });
    
    newWindow.loadURL("https://github.com/");

    newWindow.on('closed', function() {
        newWindow = null;
    });
}

/**
 * When Electron finish initialization and is ready to create browser window
 */
app.on('ready', function() {
    createMainWindow();
});


В комментариях в файле описывается назначение некоторых шагов. В общих чертах мы создаем объект electron, затем окно приложения, после чего настраиваем его. После этого в окно передается основной URL приложения, например, так: mainWindow.loadURL('file://' + __dirname + '/index.html'). В нашем случае это файл «index.html» в корне проекта. В конце выражением mainWindow = null удаляем ссылку на окно, так как если приложение поддерживает несколько окон, то нужно ловить момент когда следует удалить соответствующий элемент. Закрытие основного окна приложения в нашем случае закрывает (присваивает null) дочернее окно. В настройках также можно задать иконку готового десктоп-приложения. Для этого указываем icon: 'resources/app/img/icon.png', где «resources/app» — место, где хранится исходный код в уже скомпилированном варианте приложения.

Electron также позволяет создавать кастомизированное меню окон приложения. В демонстрационных целях я добавил несколько пунктов меню, чтобы показать, как это делается. Хорошая инфа на эту тему есть вот тут и в официальной документации. В пункте меню File > New window я добавил новое окно. Оно имитирует просмотр контента на мобильном устройстве и открывает страницу GitHub. Можно задать стартовый URL для нового окна и в нашем веб-приложении, создав таким образом еще одну точка входа, если, например, требуется обособить какой-либо функционал.

В режиме разработки можно активировать Chrome Dev Tools. В комментариях файла «main.js» указана пара способов сделать это.
Выполняем команду $ npm run-script package и в "~/release/DataMaster" появляются готовые приложения под различные платформы.
С открытым дополнительным окном вид такой:
image

В итоге у нас получилось вполне работоспособное приложение, которое может кому-нибудь пригодиться. Код проекта не претендует на лучшие практики разработки (хотя я и старался), но, возможно, кому-то покажутся интересными использованные технологии и их взаимодействие. Собственно, для этого я и написал эту статью. Ведь именно из таких вот статей на Хабре я в свое время узнал об этих инструментах и теперь с удовольствием их использую. Отмечу, что в приложении используется лишь небольшая часть возможностей Webix и Electron. На самом деле эти инструменты обладают довольно обширным функционалом, владение которым позволяет создавать солидные кроссплатформенные приложения.

Изменения и дополнения


Обсуждение статьи и проекта в комментариях и с друзьями натолкнуло меня на мысль переписать бэкенд приложения, использовав в качестве основы Node.js и Express.
Это позволило отказаться от Apache и PHP и уменьшить зависимости проекта.
В корне проекта был создан файл «server.js», в котором описана вся серверная логика.
server.js

const express = require('express');
const bodyParser = require('body-parser');
const fs = require('fs');
var cors = require('cors');
var path = require("path");
const app = express();
const port = 3000;

// use to parse json data
app.use(bodyParser.json());

// use to create cross-domain requests (CORS)
app.use(cors());

// create path aliases to use them in index.html file
// otherwise the assets in it will not work and icons will not be shown
// scheme:
// app.use('/my_path_alias', express.static(path.join(__dirname, '/path_to_where/my_assets_are')));
app.use('/css', express.static(path.join(__dirname, '/css')));
app.use('/skins', express.static(path.join(__dirname, '/codebase/skins')));
app.use('/bundle', express.static(path.join(__dirname, '/')));
app.use('/codebase', express.static(path.join(__dirname, '/codebase')));
app.use('/i18n', express.static(path.join(__dirname, '/codebase/i18n')));
app.use('/fonts', express.static(path.join(__dirname, '/codebase/fonts')));

const filePath = __dirname + '/data/';
const fileName = "data.json";

/**
 * Get index page
 *
 * @param {string} URL
 * @param {function} Callback
 */
app.get('/', (req, res) => {
	res.sendFile(path.join(__dirname + '/index.html'));
});

/**
 * Send GET request to get data
 *
 * @param {string} URL
 * @param {function} Callback
 */
app.get('/data', (req, res) => {
	const options = {
		root: filePath
	};
  
	res.sendFile(fileName, options, function (err) {
		if (err) {
			console.log('Error:', err);
		} else {
			console.log('Received:', fileName);
		}
	});
});

/**
 * Send POST request to save data
 *
 * @param {string} URL
 * @param {function} Callback
 */
app.post('/data', (req, res) => {
	// use JSON.stringify() 2nd and 3rd param to create pretty JSON data
	// remove them for minified JSON
	fs.writeFile(filePath + fileName, JSON.stringify(req.body, null, 4), 'utf-8', (err) => {
		if (err) {
			console.log('Error:', err);
		}
		res.status(200).send(req.body);
	});
});

/**
 * Listen to server with specified port
 *
 * @param {string} Port
 * @param {function} Callback
 */
app.listen(port, () => {
	// open browser on http://localhost:3000
	console.log('Server is running on http://localhost:' + port);
});


В комментариях к коду описано его назначения. Я же хотел бы остановиться на некоторых моментах.

Во-первых, поскольку основной сервер теперь находится по адресу http://localhost:3000, мне пришлось изменить пути в файлах «js/logic.js» и «js/structure.js». И здесь я столкнулся с первой проблемой. Значение параметра HTTP-заголовка «Content-type» в Webix запросах вида «webix.ajax().post()» по-умолчанию: «application/x-www-form-urlencoded». Это не позволяло правильно обработать наши данные для сохранения в файле «data/data.json». Даже передача заголовков с сервера Express с помощью «app.set()» не работала. Решил с помощью передачи заголовка непосредственно в запрос:

webix.ajax().headers({
    "Content-Type": "application/json"
}).post("http://localhost:3000/data", {data: serializedData});

Таким образом, у нас в приложении появилось три URL:
  • localhost:3000 — GET запрос для получения главной страницы
  • localhost:3000/data — AJAX запрос методом GET для получения данных
  • localhost:3000/data — AJAX запрос методом POST для сохранения данных

Вторая проблема возникла из-за запрета Javascript на кросс-доменные запросы (CORS). После многочисленных попыток использования различных заголовков я нашел в сети информацию о модуле Node.js, который так и называется: cors. В итоге вторая проблема была решена одной строкой кода: app.use(cors());.

Третья проблема возникла при отображении индексной страницы. Express не хотел отображать стили и скрипты в таком виде, как они были. Можете сравнить (cтарый «index.html» вы можете глянуть выше)…
новый index.html
<!DOCTYPE HTML>
<html>
    <head>
    	<link rel="stylesheet" href="skins/contrast.css" type="text/css">
    	<link rel="stylesheet" href="css/main.css" type="text/css">
    	<script src="codebase/webix.js" type="text/javascript"></script>
    	<script src="i18n/en.js" type="text/javascript"></script>
    	<script src="i18n/ru.js" type="text/javascript"></script>
    </head>
    <body>
       
    	<script src="bundle/bundle.js" type="text/javascript"></script>

    </body>
</html>



Для того, чтобы новые пути работали мне понадобилось прописать в «server.js» псевдонимы путей.

app.use('/css', express.static(path.join(__dirname, '/css')));
app.use('/skins', express.static(path.join(__dirname, '/codebase/skins')));
app.use('/bundle', express.static(path.join(__dirname, '/')));
app.use('/codebase', express.static(path.join(__dirname, '/codebase')));
app.use('/i18n', express.static(path.join(__dirname, '/codebase/i18n')));
app.use('/fonts', express.static(path.join(__dirname, '/codebase/fonts')));

Первым параметром в app.use() здесь передается псевдоним пути, вторым — сам путь. Теперь в «index.html», например, к пути «skins» мы должны обращаться так:
<link rel="stylesheet" href="skins/contrast.css" type="text/css">

вместо (было раньше):
<link rel="stylesheet" href="codebase/skins/contrast.css" type="text/css">

Соответственно, для использования новых модулей в «package.json» я прописал новые зависимости: «express», «body-parser» и «cors».
Для удобства разработки я также установил пакет Nodemon. Это модуль, отслеживающий изменения файлов проекта и (при наличии изменений) перезапускающий сервер.
Теперь в коллекции команд у нас появились:
$ npm run nodemon
для запуска сервера в режиме разработки и
$ npm run server
для запуска сервера в рабочем режиме.

Последняя команда теперь должна предварять любой запуск нашего приложения, как веб-версии, так и десктоп. Также сервер должен работать в момент компиляции.

Теперь наше приложение имеет меньше зависимостей и использует встроенный сервер Node.js.

Как вы могли заметить, файл «server.js» (в отличие от предыдущих примеров js-кода) написан с учетом синтаксиса стандарта ES6. В будущем я планирую переписать весь проект на ES6. Код проекта (с учетом изменений) по-прежнему доступен на GitHub.
Поделиться с друзьями
-->

Комментарии (34)


  1. vlreshet
    03.02.2017 18:08
    -1

    Таких постов на хабре (не с webix, правда, но тут не суть) уже с десяток. Зачем ещё один? Что принципиально нового можно узнать?


    1. paratagas
      03.02.2017 18:32

      То, что именно эти инструменты можно использовать друг с другом — на примере конкретного рабочего решения.


      1. vlreshet
        03.02.2017 19:59
        +1

        Webix — JS библиотека. Electron использует самый обычный js движок от хрома (кое-чего нового добавлено, но стандартные апи идентичные) + HTML5, который и в африке HTML5. Следовательно, всё что работает в обычном хром браузере — всё будет работать и в електроне (но не наоборот). В чём тогда суть?


        1. paratagas
          03.02.2017 21:36
          +4

          Суть в том, что в теории так оно и есть. Но на практике всегда появляются какие-то подводные камни и нюансы, на которых можно споткнуться. Например, даже такая простая вещь, как задание иконки для приложения Electron может вызвать трудности, так как в документации не указано, как одновременно задать иконку для запускаемого под Node.js приложения и скомпилированного приложения. В целом я насчитал четыре статьи про создание приложений на Electron на Хабре за последние 2 года. Не вижу ничего плохого, в том, что я поделился своим опытом работы с ним. API развивается, версии становятся более свежими, появляются новые возможности. Кроме того, возможно, кто-то просто пропустил предыдущие статьи, а из более свежих сможет узнать, что такие инструменты существуют, попробовать их и, возможно, использовать.


          1. hardex
            05.02.2017 17:11

            Не указано, потому что компиляция — прерогатива electron-packager/electron-builder — разработок третьей стороны — а не электорна.


    1. MrGobus
      07.02.2017 10:11

      Вы говорите как человек прочитавший и помнящий весь хабр за все года =)
      В целом неплохой пост, я вот про Webix узнал (знал раньше но сейчас задумался о его пользе), да и даблы постов это неплохо если их разделяет определенный период времени. Кто-то освежит информацию, а кто-то откроет для себя что-то новое, не все же старые посты читают.


      1. Zenitchik
        07.02.2017 11:59

        Тот, кто будет гуглить webix найдёт старые и новые посты с равной вероятностью.


        1. paratagas
          07.02.2017 12:18

          Да, если человек знает, что искать нужно библиотеку с таким названием. Я, например, узнал о существовании Electron из комментариев к статье на Хабре про какой-то из аналогичных инструментов (точно не помню, скорее всего это NW.js).


          1. Zenitchik
            07.02.2017 12:42

            В любом случае, если человек захочет разобраться в вопросе, он за полдня нагуглит очень многое. И среди этого многого будут посты с хабра за четыре последних года.


  1. max1gu
    05.02.2017 12:21
    +3

    скажите, сколько памяти занимает ваше трехстрочное приложение поле получаса работы?
    Сам попробовал электрон, но на второй день снес — дешевле в пкстую форму приложения на дельфи7 поставить браузер на всю форму, а страницы и всё остальное зашить в ресурсы (ИЕ он и так умеет)
    Дешево и сердито.


    1. paratagas
      05.02.2017 12:30

      Честно говоря, не замерял. Но каких-то особых тормозов не заметил. Хотя приложение небольшое. Насчет Delphi (равно, как и других традиционных инструментов для десктопа) согласен с Вами. Но суть конкретно это приложения — больше эксперимент — освоение новых возможностей платформы Node.js. Отмечу, что в скомпилированном приложении под Win x64 один только exe-шник имеет размер около 70 Мб. Аналогичный exe-шник, сделанный, например, с помощью Windows Forms и C#, точно имел бы в разы меньший объем.


    1. paratagas
      05.02.2017 13:00
      +5

      Стало любопытно и я замерил (для Windows 7 x64).
      Для скомпилированного приложения:
      Сразу после запуска — около 26Мб, после получаса работы — около 28Мб. Разница между активным режимом (изменение данных, открытие окон и ссылок на документацию) и пассивным режимом — около 1Мб.
      Для приложения, запускаемого с помощью

      $ npm start
      
      все показатели примерно на 1Мб меньше


    1. paratagas
      05.02.2017 13:18
      +4

      Прошу прощения — не сразу заметил все процессы приложения. Итого результат таков: основное приложение — 3 процесса примерно по 26Мб (всего около 80Мб). Плюс еще примерно по 35Мб на каждое дополнительно открытое окно Electron из меню.


      1. herr_kaizer
        05.02.2017 17:51

        Жесть.


  1. vSLY
    05.02.2017 19:27

    Спасибо за статью!
    А подскажите пожалуйста, если я захочу затем портировать это приложение на мобильные устройства (желательно в виде исполняемого файла со страницей в google play/app store) какими инструментами можно воспользоваться, чтобы не менять код сильно?
    Стоит задача кросс-платформенной разработки из одного исходника, пока что смотрю в сторону Delphi 10.1 Berlin, но рассматриваю и другие варианты.


    1. Leopotam
      05.02.2017 23:19

      Если в рамках js / electron, то можно сразу смотреть в сторону reactjs, чтобы потом максимально безболезненно переехать на react-native.


    1. max1gu
      06.02.2017 00:42

      тогда сразу на cordova


    1. igormatyushkin1
      06.02.2017 10:35

      Задача сводится к тому, чтобы положить статичный веб-сайт в application bundle и отобразить его через WebView в Android, либо через WKWebView в iOS. Лучше всего создать по одному проекту для каждой платформы, используя стандартные инструменты этой самой платформы: Android Studio и Xcode для Android и iOS соответственно.


  1. paratagas
    05.02.2017 19:40
    -1

    Слышал о своего рода эмуляторах, например, DOSBox. Такой инструмент устанавливается на Android, после чего с его помощью можно запускать, например, exe-файлы. Сам такого рода эмуляторы не использовал, поэтому про их эффективность ничего не скажу. С другой стороны, возможно, лучше будет конвертировать для целевой платформы веб-приложение (которое в нашем случае написано на Webix), например, с помощью, PhoneGap.


  1. Goodkat
    06.02.2017 03:01
    +2

    Вы храните данные приложения с помощью php — у вас в скомпиллированном десктопном приложении, получается, будут браузер, вебсервер с php и node.js?


    1. paratagas
      06.02.2017 10:34

      Да. При такой архитектуре без любого из этих компонентов (кроме браузера — он уже не нужен) скомпиллированное десктопное приложение работать не будет. В будущем планирую сделать и бэкенд и сервер на Node.js. Тогда можно будет обойтись и без отдельного веб-сервера, и без PHP.


      1. Goodkat
        06.02.2017 11:55

        А как работает этот вебсервер? Он остаётся внутри приложения или виден извне для всех?
        Что будет, если запустить три копии приложения? Они начнут конфликтовать?
        Можете подробней осветить этот вопрос?


        1. paratagas
          06.02.2017 12:34

          Сервер остается снаружи десктопа и виден извне. Если кто-то еще имеет к нему доступ (в том числе другие копии приложения), то они получат равные права на модификацию данных. Изменения данных в одной копии после обновления отражаются на других копиях. С другой стороны, если сделать бэкенд с использованием транзакций, то можно запускать несколько копий десктопного приложения на разных машинах и использовать общую базу данных.


          1. Goodkat
            06.02.2017 14:38

            Получается, что Electron — это просто лончер для вебсервера и браузера.
            И если в системе вдруг окажется другое Electron-приложение, а в списке их много, то последствия будут непредсказуемыми, они могут испортить друг другу данные.


            1. paratagas
              06.02.2017 15:34

              «Получается, что Electron — это просто лончер для вебсервера и браузера»:
              В минимальном виде — да. Но Electron обладает теми же возможностями доступа, например, к ОС или файловой системе, что и Node.js. Это делает его более мощным инструментом, чем просто launcher.

              «И если в системе вдруг окажется другое Electron-приложение… то… они могут испортить друг другу данные»:
              Если они будут обращаться к одним и тем же данным по одному и тому же URL на сервере — то да. Но это же верно и для обычных веб-приложений при определенных условиях.

              Если приложение небольшое, то, поскольку внутри Electron создается окно браузера, можно использовать localStorage. Кроме того, существуют различные инструменты и техники для доступа к БД (SQL/NoSQL), адаптированные для Electron или аналогичных ему инструментов: linvodb3, MySQL, Couchbase.


              1. Goodkat
                06.02.2017 16:19

                Если они будут обращаться к одним и тем же данным по одному и тому же URL на сервере — то да. Но это же верно и для обычных веб-приложений при определенных условиях.
                У вебприложений обычно разные серверы, а если они запущены на одном сервере, то разработчик знает о наличии других вебприложений на том же сервере.

                А тут могут быть приложения от разных разработчиков, которые не зная друг о друге используют Electron и сохраняют данные используя локальный вебсервер.
                Да и просто запустив такое приложение вы открываете дыру на своей машине.

                Фигня какая-то получается.

                Я думал, что Electron использует модифицированные вебсервер и браузер, которые контактируют как-нибудь напрямую, без открытия доступных извне сокетов.


                1. paratagas
                  06.02.2017 16:49
                  +1

                  В приведенном Вами выше списке есть одно из приложений, которое требует доступ к URL

                  http://localhost:8000
                  

                  И есть как минимум еще одно приложение, которое требует этот же URL.
                  В этом случае действительно возможен конфликт нескольких приложений.
                  Спасибо за Ваши комментарии. Поднятая Вами проблема достаточно актуальна и интересна. В свободное время попробую изучить ее детально.


  1. kostus1974
    06.02.2017 10:36
    -2

    зачем это нужно? примитивный туду на 50 строк и общий размер под 50 МБ?
    когда это всё кончится уже?
    ну сделали вы туду на жээс — ну и что? что вам мешает просто молча запускать это просто на вашем любимом хомячном веб-сервере? зачем это публичное запихивание ноджээс во все дыры с объявлением десктопности этого? зачем это на хабре?


    1. paratagas
      06.02.2017 10:43

      Платформа Node.js и язык Javascript развиваются. Как минимум, кому-то на Хабре будет интересно узнать про их новые возможности и существующие недостатки.


    1. Zenitchik
      06.02.2017 11:55

      Если бы этого не было на Хабре, как бы я узнал, что это — дрянь? Самому бы шишки набивать пришлось, время тратить.


      1. kostus1974
        07.02.2017 16:47

        то есть слить в кучку локальный веб-сервер и браузер, и потом назвать это десктопным приложением — это надо какой-то путь пройти, как-то поумнеть? какой вы тут опыт увидели? где тут можно «шишки набивать»?


        1. Zenitchik
          07.02.2017 17:03

          слить в кучку локальный веб-сервер и браузер, и потом назвать это десктопным приложением

          А почему бы нет? Не будь под капотом 50 метров мёртвой массы — всё нормально было бы.


  1. justboris
    06.02.2017 12:42

    Насколько я понял из статьи, php вам нужен ради написания 4 строк кода.


    Вот эквивалентный код на node.js. И Apache ставить не надо.


    const express = require('express');
    const bodyParser = require('body-parser');
    
    const app = express();
    
    app.use(bodyParser.json());
    app.get('/data', (req, res) => {
      res.sendFile('data.json');
    });
    app.post('/data', (req, res) => {
       fs.writeFile('data.json', JSON.stringify(req.body), (err) => {
          if(err) {
            return res.status(500).send(`Error: ${err.message}`)
          }
          res.status(200).send(req.body);
       })
    });
    
    app.listen(3000);


    1. paratagas
      06.02.2017 12:58

      Спасибо! Изначально я планировал более сложную систему с категориями и подкатегориями дел, думал использовать PHP-фреймворк. Но потом решил не перегружать проект, от идеи фреймворка отказался, а PHP по инерции оставил. Согласен, что использование Node.js сервера здесь намного уместнее.