Spec-Zone.ru › Electron

Межпроцессное взаимодействие

Межпроцессное взаимодействие (IPC) является ключевой частью создания функционально богатых настольных приложений в Electron. Поскольку основной и рендерные процессы имеют разные обязанности в модели процесса Electron, IPC является единственным способом выполнения многих распространенных задач, таких как вызов нативного API из вашего пользовательского интерфейса или вызов изменений в вашем веб-содержимом из нативных меню.

Каналы IPC​

В Electron процессы обмениваются сообщениями, передавая их через определенные разработчиками «каналы» с использованием модулей ipcMain и ipcRenderer. Эти каналы являются произвольными (вы можете назвать их как угодно) и двунаправленными (вы можете использовать одно и то же имя канала для обоих модулей).

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

Понимание контекстно-изолированных процессов​

Прежде чем переходить к деталям реализации, вы должны ознакомиться с концепцией использования скрипта предварительной загрузки для импорта модулей Node.js и Electron в контекстно-изолированном процессе рендеринга.

  • Для получения полного обзора модели процессов Electron вы можете прочитать документацию по модели процессов.
  • Для ознакомления с экспонированием API из скрипта предварительной загрузки с использованием модуля contextBridge, ознакомьтесь с учебником по изоляции контекста.

Паттерн 1: рендер-процесс к основному процессу (односторонний)​

Для отправки одностороннего сообщения IPC из процесса рендеринга в основной процесс, вы можете использовать API ipcRenderer.send для отправки сообщения, которое затем будет получено API ipcMain.on.

Обычно этот паттерн используется для вызова API основного процесса из вашего веб-содержимого. Мы продемонстрируем этот паттерн, создав простое приложение, которое может программно изменять заголовок окна.

Для этой демонстрации вам потребуется добавить код в основной процесс, процесс рендеринга и скрипт предварительной загрузки. Полный код приведен ниже, но мы будем объяснять каждый файл по отдельности в следующих разделах.

docs/fiddles/ipc/pattern-1 (20.1.0)Открыть в Fiddle
  • main.js
  • preload.js
  • index.html
  • renderer.js
const {app, BrowserWindow, ipcMain} = require('electron')
const path = require('path')

function createWindow () {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })

  ipcMain.on('set-title', (event, title) => {
    const webContents = event.sender
    const win = BrowserWindow.fromWebContents(webContents)
    win.setTitle(title)
  })

  mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
  createWindow()
  
  app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') app.quit()
})
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
    setTitle: (title) => ipcRenderer.send('set-title', title)
})
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    <title>Hello World!</title>
  </head>
  <body>
    Title: <input id="title"/>
    <button id="btn" type="button">Set</button>
    <script src="./renderer.js"></script>
  </body>
</html>
const setButton = document.getElementById('btn')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', () => {
    const title = titleInput.value
    window.electronAPI.setTitle(title)
});

1. Прослушивание событий с помощью ipcMain.on​

В основном процессе установите прослушиватель IPC на канале set-title с помощью API ipcMain.on:

main.js (Основной процесс)
const {app, BrowserWindow, ipcMain} = require('electron')
const path = require('path')

//...

function handleSetTitle (event, title) {
  const webContents = event.sender
  const win = BrowserWindow.fromWebContents(webContents)
  win.setTitle(title)
}

function createWindow () {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })
  mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
  ipcMain.on('set-title', handleSetTitle)
  createWindow()
}
//...

Указанный выше handleSetTitle обратный вызов имеет два параметра: структуру IpcMainEvent и строку title. Всякий раз, когда приходит сообщение по каналу set-title, эта функция найдет экземпляр BrowserWindow, привязанный к отправителю сообщения, и использует API win.setTitle для него.

информация

Убедитесь, что вы загружаете index.html и preload.js точки входа для следующих шагов!

2. Экспонирование ipcRenderer.send через предварительную загрузку​

Для отправки сообщений в прослушиватель, созданный выше, вы можете использовать API ipcRenderer.send. По умолчанию процесс рендеринга не имеет доступа к модулям Node.js или Electron. Как разработчик приложения, вы должны выбрать, какие API экспонировать из скрипта предварительной загрузки с помощью API contextBridge.

В скрипт предварительной загрузки добавьте следующий код, который экспонирует глобальную переменную window.electronAPI в ваш процесс рендеринга.

preload.js (Скрипт предварительной загрузки)
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
    setTitle: (title) => ipcRenderer.send('set-title', title)
})

В этот момент вы сможете использовать функцию window.electronAPI.setTitle() в процессе рендеринга.

Предупреждение о безопасности

Мы не экспонируем напрямую весь API ipcRenderer.send по соображениям безопасности. Убедитесь, что вы ограничиваете доступ рендерного процесса к API Electron по мере возможности.

3. Создание пользовательского интерфейса процесса рендеринга​

В загруженном HTML-файле вашего BrowserWindow добавьте простой пользовательский интерфейс, состоящий из текстового поля и кнопки:

index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    <title>Hello World!</title>
  </head>
  <body>
    Title: <input id="title"/>
    <button id="btn" type="button">Set</button>
    <script src="./renderer.js"></script>
  </body>
</html>

Чтобы сделать эти элементы интерактивными, мы добавим несколько строк кода в импортированный файл renderer.js, использующий функциональность window.electronAPI, экспонированную скриптом предварительной загрузки:

renderer.js (Процесс рендеринга)
const setButton = document.getElementById('btn')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', () => {
    const title = titleInput.value
    window.electronAPI.setTitle(title)
});

В этот момент ваша демонстрация должна быть полностью функциональной. Попробуйте использовать поле ввода и посмотрите, что происходит с заголовком вашего BrowserWindow!

Паттерн 2: рендер-процесс к основному процессу (двусторонний)​

Распространенное применение двустороннего IPC — вызов модуля основного процесса из кода вашего процесса рендеринга и ожидание результата. Это можно сделать с помощью ipcRenderer.invoke в паре с ipcMain.handle.

В следующем примере мы откроем диалоговое окно выбора файла из процесса рендеринга и вернём путь выбранного файла.

Для этой демонстрации вам потребуется добавить код в основной процесс, процесс рендеринга и скрипт предварительной загрузки. Полный код приведен ниже, но мы будем объяснять каждый файл по отдельности в следующих разделах.

docs/fiddles/ipc/pattern-2 (20.1.0)Открыть в Fiddle
  • main.js
  • preload.js
  • index.html
  • renderer.js
const {app, BrowserWindow, ipcMain, dialog} = require('electron')
const path = require('path')

async function handleFileOpen() {
  const { canceled, filePaths } = await dialog.showOpenDialog()
  if (canceled) {
    return
  } else {
    return filePaths[0]
  }
}

function createWindow () {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })
  mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
  ipcMain.handle('dialog:openFile', handleFileOpen)
  createWindow()
  app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') app.quit()
})
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI',{
  openFile: () => ipcRenderer.invoke('dialog:openFile')
})
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    <title>Dialog</title>
  </head>
  <body>
    <button type="button" id="btn">Open a File</button>
    File path: <strong id="filePath"></strong>
    <script src='./renderer.js'></script>
  </body>
</html>
const btn = document.getElementById('btn')
const filePathElement = document.getElementById('filePath')

btn.addEventListener('click', async () => {
  const filePath = await window.electronAPI.openFile()
  filePathElement.innerText = filePath
})

1. Прослушивание событий с помощью ipcMain.handle​

В основном процессе мы создадим функцию handleFileOpen(), которая вызовет dialog.showOpenDialog и вернёт значение пути к файлу, выбранному пользователем. Эта функция используется в качестве обратного вызова всякий раз, когда через канал dialog:openFile от процесса визуализации отправляется сообщение ipcRender.invoke. Возвращаемое значение затем возвращается как Promise первоначальному вызову invoke.

Обработка ошибок

Ошибки, возникающие через handle в основном процессе, не прозрачны, так как они сериализуются, и в процесс визуализации предоставляется только свойство message исходной ошибки. Подробности см. в #24427.

main.js (Основной процесс)
const { BrowserWindow, dialog, ipcMain } = require('electron')
const path = require('path')

//...

async function handleFileOpen() {
  const { canceled, filePaths } = await dialog.showOpenDialog()
  if (canceled) {
    return
  } else {
    return filePaths[0]
  }
}

function createWindow () {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })
  mainWindow.loadFile('index.html')
}

app.whenReady(() => {
  ipcMain.handle('dialog:openFile', handleFileOpen)
  createWindow()
})
//...
О названиях каналов

Префикс dialog: в имени канала IPC не влияет на код. Он служит только в качестве пространства имён, что помогает улучшить читаемость кода.

Информация

Убедитесь, что вы загружаете точки входа index.html и preload.js для следующих шагов!

2. Экспонирование ipcRenderer.invoke через загрузчик​

В скрипте загрузчика мы экспонируем однострочную функцию openFile, которая вызывает и возвращает значение ipcRenderer.invoke('dialog:openFile'). Мы будем использовать этот API на следующем шаге для вызова системного диалога открытия файла из пользовательского интерфейса процесса визуализации.

preload.js (Скрипт загрузчика)
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  openFile: () => ipcRenderer.invoke('dialog:openFile')
})
Предупреждение о безопасности

Мы не экспонируем весь API ipcRenderer.invoke напрямую по соображениям безопасности. Убедитесь, что доступ процесса визуализации к API Electron ограничен по возможности.

3. Создание пользовательского интерфейса процесса визуализации​

Наконец, давайте создадим HTML-файл, который мы загрузим в наш BrowserWindow.

index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    <title>Dialog</title>
  </head>
  <body>
    <button type="button" id="btn">Open a File</button>
    File path: <strong id="filePath"></strong>
    <script src='./renderer.js'></script>
  </body>
</html>

Пользовательский интерфейс состоит из одного элемента кнопки #btn, который будет использоваться для запуска нашего API загрузчика, и элемента #filePath, который будет использоваться для отображения пути к выбранному файлу. Для работы этих компонентов потребуется несколько строк кода в скрипте процесса визуализации:

renderer.js (Процесс визуализации)
const btn = document.getElementById('btn')
const filePathElement = document.getElementById('filePath')

btn.addEventListener('click', async () => {
  const filePath = await window.electronAPI.openFile()
  filePathElement.innerText = filePath
})

В приведенном выше фрагменте мы прослушиваем клики по кнопке #btn и вызываем наш API window.electronAPI.openFile() для запуска системного диалога открытия файла. Затем мы отображаем путь к выбранному файлу в элементе #filePath.

Примечание: устаревшие подходы​

API ipcRenderer.invoke был добавлен в Electron 7 в качестве удобного для разработчиков способа решения задач двусторонней IPC из процесса визуализации. Однако существуют несколько альтернативных подходов к этому шаблону IPC.

Избегайте устаревших подходов, если это возможно

Мы рекомендуем использовать ipcRenderer.invoke, когда это возможно. Следующие шаблоны двусторонней связи между процессами визуализации и основным процессом документированы для исторических целей.

Информация

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

Использование ipcRenderer.send​

API ipcRenderer.send, который мы использовали для односторонней связи, также можно использовать для организации двусторонней связи. До Electron 7 это был рекомендуемый способ асинхронной двусторонней связи через IPC.

preload.js (Скрипт загрузчика)
// You can also put expose this code to the renderer
// process with the `contextBridge` API
const { ipcRenderer } = require('electron')

ipcRenderer.on('asynchronous-reply', (_event, arg) => {
  console.log(arg) // prints "pong" in the DevTools console
})
ipcRenderer.send('asynchronous-message', 'ping')
main.js (Основной процесс)
ipcMain.on('asynchronous-message', (event, arg) => {
  console.log(arg) // prints "ping" in the Node console
  // works like `send`, but returning a message back
  // to the renderer that sent the original message
  event.reply('asynchronous-reply', 'pong')
})

У этого подхода есть несколько недостатков:

  • Вам нужно настроить второй прослушиватель ipcRenderer.on для обработки ответа в процессе визуализации. С помощью invoke значение ответа возвращается в виде Promise первоначальному вызову API.
  • Нет очевидного способа сопоставить сообщение asynchronous-reply с исходным сообщением asynchronous-message. Если у вас очень часто происходят сообщения туда и обратно по этим каналам, вам потребуется добавить дополнительный код приложения для отслеживания каждого вызова и ответа индивидуально.

Использование ipcRenderer.sendSync​

API ipcRenderer.sendSync отправляет сообщение в основной процесс и ожидает синхронный ответ.

main.js (Основной процесс)
const { ipcMain } = require('electron')
ipcMain.on('synchronous-message', (event, arg) => {
  console.log(arg) // prints "ping" in the Node console
  event.returnValue = 'pong'
})
preload.js (Скрипт загрузчика)
// You can also put expose this code to the renderer
// process with the `contextBridge` API
const { ipcRenderer } = require('electron')

const result = ipcRenderer.sendSync('synchronous-message', 'ping')
console.log(result) // prints "pong" in the DevTools console

Структура этого кода очень похожа на модель invoke, но мы рекомендуем **избегать использования этого API** по причинам производительности. Его синхронная природа означает, что он заблокирует процесс визуализации до получения ответа.

Шаблон 3: основной процесс – процесс визуализации​

При отправке сообщения из основного процесса в процесс визуализации необходимо указать, какой процесс визуализации получает сообщение. Сообщения должны отправляться в процесс визуализации через его экземпляр WebContents. Этот экземпляр WebContents содержит метод send, который можно использовать так же, как и ipcRenderer.send.

Чтобы продемонстрировать этот шаблон, мы создадим счётчик, управляемый системным меню.

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

docs/fiddles/ipc/pattern-3 (20.1.0)Открыть в Fiddle
  • main.js
  • preload.js
  • index.html
  • renderer.js
const {app, BrowserWindow, Menu, ipcMain} = require('electron')
const path = require('path')

function createWindow () {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })

  const menu = Menu.buildFromTemplate([
    {
      label: app.name,
      submenu: [
      {
        click: () => mainWindow.webContents.send('update-counter', 1),
        label: 'Increment',
      },
      {
        click: () => mainWindow.webContents.send('update-counter', -1),
        label: 'Decrement',
      }
      ]
    }

  ])

  Menu.setApplicationMenu(menu)
  mainWindow.loadFile('index.html')

  // Open the DevTools.
  mainWindow.webContents.openDevTools()
}

app.whenReady().then(() => {
  ipcMain.on('counter-value', (_event, value) => {
    console.log(value) // will print value to Node console
  })
  createWindow()
  
  app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') app.quit()
})
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  handleCounter: (callback) => ipcRenderer.on('update-counter', callback)
})
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    <title>Menu Counter</title>
  </head>
  <body>
    Current value: <strong id="counter">0</strong>
    <script src="./renderer.js"></script>
  </body>
</html>
const counter = document.getElementById('counter')

window.electronAPI.handleCounter((event, value) => {
    const oldValue = Number(counter.innerText)
    const newValue = oldValue + value
    counter.innerText = newValue
    event.sender.send('counter-value', newValue)
})

1. Отправка сообщений с помощью модуля webContents​

Для этой демонстрации нам сначала нужно создать пользовательское меню в основном процессе с использованием модуля Electron Menu, который использует API webContents.send для отправки сообщения IPC из основного процесса в целевой рендерер.

main.js (Основной процесс)
const {app, BrowserWindow, Menu, ipcMain} = require('electron')
const path = require('path')

function createWindow () {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })

  const menu = Menu.buildFromTemplate([
    {
      label: app.name,
      submenu: [
        {
          click: () => mainWindow.webContents.send('update-counter', 1),
          label: 'Increment',
        },
        {
          click: () => mainWindow.webContents.send('update-counter', -1),
          label: 'Decrement',
        }
      ]
    }
  ])
  Menu.setApplicationMenu(menu)

  mainWindow.loadFile('index.html')
}
//...

Для целей данного учебника важно отметить, что обработчик click отправляет сообщение (либо 1, либо -1) в процесс рендерера через канал update-counter.

click: () => mainWindow.webContents.send('update-counter', -1)
информация

Убедитесь, что вы загружаете точки входа index.html и preload.js для следующих шагов!

2. Экспонирование ipcRenderer.on через предустановку​

Как и в предыдущем примере взаимодействия рендерер-в-основной, мы используем модули contextBridge и ipcRenderer в скрипте предустановки для экспонирования функциональности IPC для процесса рендерера:

preload.js (Скрипт предустановки)
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
    onUpdateCounter: (callback) => ipcRenderer.on('update-counter', callback)
})

После загрузки скрипта предустановки процесс рендерера должен иметь доступ к функции-обработчику window.electronAPI.onUpdateCounter().

Предупреждение о безопасности

Мы не экспонируем напрямую весь API ipcRenderer.on по соображениям безопасности. Убедитесь, что доступ рендерера к API Electron ограничен по возможности.

информация

В случае этого минимального примера вы можете вызвать ipcRenderer.on напрямую в скрипте предустановки вместо экспонирования через мост контекста.

preload.js (Скрипт предустановки)
const { ipcRenderer } = require('electron')

window.addEventListener('DOMContentLoaded', () => {
    const counter = document.getElementById('counter')
    ipcRenderer.on('update-counter', (_event, value) => {
        const oldValue = Number(counter.innerText)
        const newValue = oldValue + value
        counter.innerText = newValue
    })
})

Однако, этот подход имеет ограниченную гибкость по сравнению с экспонированием ваших API предустановки через мост контекста, так как ваш обработчик не может напрямую взаимодействовать с вашим кодом рендерера.

3. Создание пользовательского интерфейса процесса рендерера​

Для объединения всего этого, мы создадим интерфейс в загруженном HTML-файле, который содержит элемент #counter, который мы будем использовать для отображения значений:

index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    <title>Menu Counter</title>
  </head>
  <body>
    Current value: <strong id="counter">0</strong>
    <script src="./renderer.js"></script>
  </body>
</html>

Наконец, чтобы обновить значения в HTML-документе, мы добавим несколько строк манипуляции DOM, чтобы значение элемента #counter обновлялось всякий раз, когда мы вызываем событие update-counter.

renderer.js (Процесс рендерера)
const counter = document.getElementById('counter')

window.electronAPI.onUpdateCounter((_event, value) => {
    const oldValue = Number(counter.innerText)
    const newValue = oldValue + value
    counter.innerText = newValue
})

В приведенном коде мы передаем обратный вызов в функцию window.electronAPI.onUpdateCounter, экспонированную нашим скриптом предустановки. Второй параметр value соответствует значению 1 или -1, которые мы передавали в вызов webContents.send из нативного меню.

Необязательно: возврат ответа​

Для главного-в-рендеринг IPC нет эквивалента для ipcRenderer.invoke. Вместо этого вы можете отправить ответ обратно в основной процесс из callback ipcRenderer.on.

Мы можем продемонстрировать это с небольшими изменениями в коде из предыдущего примера. В процессе рендерера используйте параметр event, чтобы отправить ответ обратно в основной процесс через канал counter-value.

renderer.js (Процесс рендерера)
const counter = document.getElementById('counter')

window.electronAPI.onUpdateCounter((event, value) => {
  const oldValue = Number(counter.innerText)
  const newValue = oldValue + value
  counter.innerText = newValue
  event.sender.send('counter-value', newValue)
})

В основном процессе прослушивайте события counter-value и обрабатывайте их соответствующим образом.

main.js (Основной процесс)
//...
ipcMain.on('counter-value', (_event, value) => {
  console.log(value) // will print value to Node console
})
//...

Шаблон 4: Рендерер-в-рендерер​

Нет прямого способа отправлять сообщения между процессами рендерера в Electron с использованием модулей ipcMain и ipcRenderer. Для достижения этой цели у вас есть два варианта:

  • Используйте основной процесс в качестве посредника для сообщений между рендерерами. Это подразумевает отправку сообщения от одного рендерера в основной процесс, который перенаправит сообщение другому рендереру.
  • Передайте MessagePort из основного процесса в оба рендерера. Это позволит осуществить прямое общение между рендерерами после первоначальной настройки.

Сериализация объектов​

Реализация IPC в Electron использует стандарт HTML алгоритм структурированного клонирования для сериализации объектов, передаваемых между процессами, что означает, что только определённые типы объектов могут передаваться через каналы IPC.

В частности, DOM-объекты (например, Element, Location и DOMMatrix), объекты Node.js, основанные на классах C++ (например, process.env, некоторые члены Stream) и объекты Electron, основанные на классах C++ (например, WebContents, BrowserWindow и WebFrame) не сериализуются с помощью алгоритма структурированного клонирования.

© GitHub Inc.
Licensed under the MIT license.
https://www.electronjs.org/docs/latest/tutorial/ipc

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API