VpnElectron компонент, основной элемент управления приложением.

Electron компонент — под этим термином я подразумеваю как раз ту организацию Electron кода, о которой я говорил в 1 части.

Структура папок.

vpn
¦
¦   index.js
¦
+---client // все что относится к клиенту
¦       index.html
¦       paper-plane.svg
¦       script.js
¦       style.css
¦       TweenMax.min.js
¦
L---icon
        blue.ico
        orange.ico
        red.ico
        yellow.ico

index.js — Файл, в котором создается Electron компонент.

Cодержимое файла index.js.

Код
const { BrowserWindow, Tray, ipcMain } = require('electron')

module.exports = class VPN {
    constructor() {
        // Создаем окно
        this.root = new BrowserWindow({
            title: `JS.VPN-Client`,
            frame: false, // Убираем рамку
            transparent: true, // Устанавливаем прозрачность
            alwaysOnTop: true, // Устанавливаем поверх всех окон
            resizable: false, // Запрещаем масштабирование
            center: true, 
            show: false, // Запрещаем показывать окно после загрузки 
            acceptFirstMouse: true, // Разрешает взаимодействие с окном до фокуса на окне
            width: 116, 
            height: 116,
            fullscreenable: false
        })

        // Загружаем страницу
        this.root.loadURL(`${__dirname}/client/index.html`) 
        
        this.tray = {}
        
        // Хранит статус переподключения
        this.isReconnect = false
        // Хранит таймеры переподключений
        this.reconnect_stack = []        

        // Изначальный статус подключения
        this.status = false
        
        // Эти методы хранят коллбеки подключения и отключения 
        this.cb_connect = () => {}
        this.cb_disconnect = () => {}
    }

    ready() {
        // Ожидаем пока окно полностью инициализируется
        return new Promise(res => {
            this.root.once('ready-to-show', res)
        })
    }

    showTray() {
        // Инициализирует Tray (см. доку Electron) и устанавливает Title 
        this.tray = new Tray(`${__dirname}/icon/red.ico`)
        this.tray.setToolTip('Отключен')
    }

    show() {
        // Показывает окно
        this.root.show()
    }

    hide() {
        // Скрывает окно
        this.root.hide()
    }

    isVisible() {
        // Возвращает boolean - в зависимости от того показано окно или скрыто
        return this.root.isVisible()
    }

    onTrayClick(cb) {
        // Устанавливаем обработчик на Tray
        this.tray.on('click', cb)
    }

    onTrayRightClick(cb) {
        // Устанавливаем обработчик на Tray
        this.tray.on('right-click', cb)
    }

    center() {
        // Устанавливаем окно по центру
        this.root.center()
    }

    onDisconnect(cb) {
        // Передаем оригинальную функцию
        this.cb_disconnect = cb
        // Обработчик сигнала VPN_DISCONNECT (отключение от VPN) 
        ipcMain.on('VPN_DISCONNECT', e => {
            cb()
            e.returnValue = 'ok'
        })
    }

    onConnect(cb) {
        // Передаем оригинальную функцию
        this.cb_connect = cb
        // Обработчик сигнала VPN_CONNECT (подключение от VPN)
        ipcMain.on('VPN_CONNECT', (e, data) => {
            cb()
            e.returnValue = 'ok'
        })
    }

    connect() {
        // Имитация подключения
        this.cb_connect()
    }

    disconnect() {
        // Имитация отключения
        this.cb_disconnect()
    }

    onContext(cb) {
        // Обработчик сигнала VPN_CONTEXT (вызов контекстного меню)
        ipcMain.on('VPN_CONTEXT', (e, data) => {
            cb(data)
            e.returnValue = 'ok'
        })
    }

    stopReconnect() {
        // Останавливаем и удаляем все таймеры переподключений
        this.reconnect_stack.map(i => clearTimeout(i))
        this.reconnect_stack = []
    }

    reconnect(cb, time = 15000) {
        // Останавливаем все предыдущие таймеры переподключения
        this.stopReconnect()
        // Добавляем в массив таймер
        this.reconnect_stack.push(
            // Таймер переподключения
            setTimeout(() => {
                // Переподключение началось
                this.isReconnect = true
                cb(() => {
                    this.disconnect()
                    this.connect()
                })
                // Переподключение закончилось
                this.isReconnect = false
            }, time)
        )
    }

    setStatus(animation, color) {
        let statusText = 'Отключен'

        if (color == 'blue') {
            statusText = 'Подключен'
        }

        if (color == 'yellow') {
            statusText = 'Подключение'
        }

        if (color == 'orange') {
            statusText = 'Установка компонентов'
        }

        // Устанавливает Title для Tray
        this.tray.setToolTip(statusText)
        // Изменяем иконку
        this.tray.setImage(`${__dirname}/icon/${color}.ico`)
        this.status = color == 'red' ? false : true;
        // Отсылаем сигнал VPN_STATUS и статус подключения в окно 
        this.root.webContents.send('VPN_STATUS', JSON.stringify({
            animation, color
        }))
    }
}


index.html — HTML страница окна.

Cодержимое файла index.html.

Код
<!DOCTYPE html>
<html lang='ru'>
<head>
    <meta charset='UTF-8'>
</head>
<body>
<link rel='stylesheet' type='text/css' href='style.css'>
<script src='TweenMax.min.js'></script>
<script src='script.js'></script>
<div class='btn-border'>
    <div class='btn-body'>	
        <div class='but-plane'></div>
    </div>
</div>
<script>
const { ipcRenderer, remote }  = require('electron')
      , plane  = document.getElementsByClassName('but-plane')[0]
      , btnBorder = document.getElementsByClassName('btn-border')[0]
      , btnBody  = document.getElementsByClassName('btn-body')[0]
	
// Получаем контроль над окном
const win = remote.getCurrentWindow() 

// Если окно имело уже какую-то позицию в локальном хранилище то устанавливаем ее
localStorage.x && win.setPosition(
    parseInt(localStorage.x),
    parseInt(localStorage.y)
)

// Обновляем позицию в локальном хранилище
setInterval(() => {
    const [x, y] = win.getPosition()
    localStorage.setItem('x', x)
    localStorage.setItem('y', y)
}, 3)

// Обработчик вызова контекстного меню
document.body.addEventListener('contextmenu', e => {
    // Сохраняем позицию курсора в локальное хранилище 
    localStorage.setItem('context_x', e.pageX)
    localStorage.setItem('context_y', e.pageY)
    // Сигнал компоненту на вызов контекста
    ipcRenderer.sendSync('VPN_CONTEXT')
    e.preventDefault()
}); 

// Подключаемся и отключаемся от VPN
let start = false
plane.addEventListener('click', () => {
    start = !start
    if (start) {
        // Сигнал компоненту на подключение от VPN
        ipcRenderer.sendSync('VPN_CONNECT')
    } else {
        // Сигнал компоненту на отключение от VPN
        ipcRenderer.sendSync('VPN_DISCONNECT')
    }	
})

// Тут начинается анимация
const statVPN = new VPNstatus(plane, btnBody, btnBorder)

// Отключен
statVPN.color('red')
statVPN.reject()

// Обработчик сигналов с статусами от компонента
ipcRenderer.on('VPN_STATUS', (e, data) => {
    const { animation, color } = JSON.parse(data)

    statVPN.color(color)
    statVPN[animation]()
})
	
</script>
</body>
</html>


script.js — Вся анимация окна.

По мимо script.js для анимации также используется TweenMax.js.

Cодержимое файла script.js.

Код
class VPNstatus {
    constructor (plane, btnBody, btnBorder) {
        this.plane      = plane
        this.btnBody    = btnBody
        this.btnBorder  = btnBorder
        // скорость Анимации
        this.speedAnimation = 0.9

        this.colors = {
            red: {
                boxShadow: '1px 1px 5px 1px rgba(255, 24, 37, 0.4)',
                borderBackground: '#e2333d',
                background: '#f06069'
            },
            blue: {
                background: '#60a2f0',
                borderBackground: '#3286e3',
                boxShadow: '2px 2px 5px 1px rgba(40, 138, 255, 0.4)'
            },
            yellow: {
                background: '#f5cc5b',
                borderBackground: '#f1c131',
                boxShadow: '2px 2px 5px 1px rgba(255, 197, 37, 0.4)'
            },
            orange: {
                background: '#f09c60',
                borderBackground: '#e37a32',
                boxShadow: '2px 2px 5px 1px rgba(255, 127, 35, 0.4)'
            }
        }

        this.step = {
            start: {
                left: '-40px',
                top: '73px'
            },
            center: {
                top: '19px',
                left: '13px'
            },
            end: {
                top: '-45px',
                left: '71px'
            }   
        }

        this.status = 0
    }

    color (value) {
        btnBody.style.background = this.colors[value].background
        btnBorder.style.background = this.colors[value].borderBackground
        btnBorder.style.boxShadow = this.colors[value].boxShadow
    }

    waiting () {
        statVPN.center_to_end(() => {
            this.start_to_end(() => {
                if (this.status == 1) {
                    TweenMax.killAll()
                    this.plane.style.left = this.step.start.left;
                    this.plane.style.top = this.step.start.top;
                    statVPN.start_to_center()
                    this.status = 2
                }
            })
        })
    }

    resolve () {
        this.status = 1
    }

    reject () {
        this.status = 2
        statVPN.center_to_end(() => {
            statVPN.start_to_center()
        })
    }

    start_to_end (callback) {
        this.plane.style.left = this.step.start.left;
        this.plane.style.top = this.step.start.top;
        TweenMax.to(this.plane, this.speedAnimation * 2, {
            top: this.step.end.top,
            left: this.step.end.left,
            repeat: -1,
            ease: Elastic.ease,
            onRepeat: callback ? callback : () => {}
        })
    }

    center_to_end (callback) {
        this.plane.style.left = this.step.center.left;
        this.plane.style.top = this.step.center.top;
        TweenMax.to(this.plane, this.speedAnimation, {
            top: this.step.end.top,
            left: this.step.end.left,
            ease: Elastic.ease,
            onComplete: callback ? callback : () => {}
        })
    }

    start_to_center (callback) {
        this.plane.style.left = this.step.start.left;
        this.plane.style.top = this.step.start.top;
        TweenMax.to(this.plane, this.speedAnimation, {
            top: this.step.center.top,
            left: this.step.center.left,
            ease: Elastic.ease,
            onComplete: callback ? callback : () => {}
        })
    }

}


style.css — Стили для страницы.

В моем приложении очень важно, чтобы в стилях глобально был запрещен, overflow: hidden; без этого свойства бывает баг с окном при изменении положения «панели быстрого запуска», а именно появляется scroll.

image

Cодержимое файла style.css.

Код
* {
    padding: 0;
    margin: 0;
    overflow: hidden;
    -webkit-app-region: drag;
}

body {
    width: 100%;
    height: 100vh;
    background: rgba(0, 0, 0, 0);
}

.btn-border {
    position: absolute;
    left: 1px;
    top: 1px;
    width: 111px;
    height: 111px;
    background: #e2333d;
    border-radius: 100%;
    box-shadow: 1px 1px 5px 1px rgba(255, 24, 37, 0.4);
    display: flex;
    justify-content: center;
    align-items: center;
}

.btn-body {
    border-radius: 100%;
    overflow: hidden;
    width: 85px;
    height: 85px;
    background: #f06069;
    cursor: pointer;
    -webkit-app-region: no-drag;
}

.but-plane {
    position: relative;
    background: url('paper-plane.svg');
    width: 60%;
    height: 60%;
    top: 71px;
    left: -38px;
    cursor: pointer;
    -webkit-app-region: no-drag;
}


Application

Использование в приложении: /app/Vpn.

API

Интерфейс компонента Vpn. Уверен, после изучения следующего куска кода станет очевидно удобство того подхода, о котором я говорил в 1 части. После того как компонент приобрел такую оболочку в виде класса со своими методами, работать с ним стало гораздо проще. Если вы все же не убедились в этом сейчас, то обязательно убедитесь позже.

const { app }  = require('electron')
    , VPN      = require('./../../app/components/vpn')

app.on('ready', async() => {

    const Vpn = new VPN()

    // Только после того как окно инициализируется программа продолжит исполнятся
    await Vpn.ready()

    // Показываем окно
    Vpn.show()
        // Показываем Tray
    Vpn.showTray()

    // Обработчик подключения
    Vpn.onConnect(() => {
        console.log('Connect')
            // Передаем в окно статус
        Vpn.setStatus('waiting', 'yellow')
        setTimeout(() => {
            console.log('Connected!')
                // Передаем в окно статус
            Vpn.setStatus('resolve', 'blue')
        }, 4000)
    })

    // Обработчик отключения 
    Vpn.onDisconnect(() => {
        console.log('Disconnect')
            // Передаем в окно статус
        Vpn.setStatus('reject', 'red')
    })

    Vpn.onContext(() => {
        console.log('Context menu')
    })

    // Переподключатель если в течении 5 сек после объявления 
    // не будет вызвана функция Vpn.stopReconnect()
    // произойдет переподключение
    Vpn.reconnect(next => {
        console.log('Reconnect')
        next()
    }, 5000)

    // Предотвращает или останавливает переподключение
    //stopReconnect()

    // Обработчик клика на Tray
    Vpn.onTrayClick(() => {
        // Имитация подключения и отключения
        if (!Vpn.status) {
            Vpn.connect()
        } else {
            Vpn.disconnect()
        }
    })

    // Обработчик клика правой кнопкой мыши на Tray
    Vpn.onTrayRightClick(() => {
        if (Vpn.isVisible()) {
            Vpn.hide()
        } else {
            Vpn.show()
        }
    })

})

Test

Версия для тестирования: /app_test/Vpn.

image

Возможные статусы и цвета для Vpn.setStatus.
Цвета Анимации Описание
red reject Отключение
blue resolve Подключение
yellow waiting Ожидание
orange


6 часть — Notify компонент


Собственный VPN клиент на JavaScript by JSus

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


  1. hardex
    29.11.2018 13:05

    EventEmitter уже вроде как изобрели