GrabDuck

Создание десктопного приложения с помощью Webix и Electron

:

Статья представляет собой пошаговое описание моего опыта создания кроссплатформенного десктопного приложения с помощью 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" будут версии десктопного приложения для различных платформ.
Корневая папка проекта должна быть расположена на сервере. В моем случае это 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" появляются готовые приложения под различные платформы.


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

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


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