انتقال برنامه های USB به وب قسمت 2: gPhoto2

با نحوه انتقال gPhoto2 به WebAssembly آشنا شوید تا دوربین‌های خارجی را از طریق USB از یک برنامه وب کنترل کنید.

در پست قبلی نشان دادم که چگونه کتابخانه libusb برای اجرا در وب با WebAssembly / Emscripten ، Asyncify و WebUSB منتقل شد.

من همچنین یک نسخه آزمایشی ساخته شده با gPhoto2 را نشان دادم که می تواند دوربین های DSLR و بدون آینه را از طریق USB از یک برنامه وب کنترل کند. در این پست بیشتر به جزئیات فنی پشت پورت gPhoto2 می پردازم.

از آنجایی که من WebAssembly را هدف قرار می دادم، نمی توانستم از libusb و libgphoto2 ارائه شده توسط توزیع های سیستم استفاده کنم. در عوض، من به برنامه خود نیاز داشتم تا از فورک سفارشی libgphoto2 من استفاده کند، در حالی که آن فورک libgphoto2 باید از فورک سفارشی من در libusb استفاده می کرد.

علاوه بر این، libgphoto2 از libtool برای بارگیری افزونه‌های پویا استفاده می‌کند، و حتی با وجود اینکه مجبور نبودم libtool را مانند دو کتابخانه دیگر فورک کنم، هنوز مجبور بودم آن را در WebAssembly بسازم و به جای بسته سیستم، libgphoto2 را به آن ساخت سفارشی اشاره کنم.

در اینجا یک نمودار وابستگی تقریبی آمده است (خطوط چین نشان دهنده پیوند پویا است):

یک نمودار "برنامه" را بسته به "libgphoto2 fork" نشان می دهد که به "libtool" بستگی دارد. بلوک 'libtool' به طور پویا به 'libgphoto2 ports' و 'libgphoto2 camlibs' بستگی دارد. در نهایت، «پورت های libgphoto2» به طور ایستا به «چنگال libusb» بستگی دارد.

اکثر سیستم‌های ساخت مبتنی بر پیکربندی، از جمله مواردی که در این کتابخانه‌ها استفاده می‌شوند، از طریق پرچم‌های مختلف، مسیرهای نادیده‌گیری برای وابستگی‌ها را امکان‌پذیر می‌کنند، بنابراین اولین کاری که من سعی کردم انجام دهم این بود. با این حال، زمانی که نمودار وابستگی پیچیده می‌شود، فهرست مسیرها برای وابستگی‌های هر کتابخانه پرمخاطب و مستعد خطا می‌شود. من همچنین برخی از اشکالات را پیدا کردم که در آن سیستم‌های ساخت واقعاً برای وابستگی‌هایشان برای زندگی در مسیرهای غیر استاندارد آماده نشده بودند.

در عوض، یک رویکرد ساده‌تر این است که یک پوشه جداگانه به عنوان ریشه سیستم سفارشی (اغلب به "sysroot" کوتاه می‌شود) ایجاد کنید و تمام سیستم‌های ساخت درگیر را به آن اشاره کنید. به این ترتیب، هر کتابخانه در حین ساخت، هم وابستگی های خود را در sysroot مشخص شده جستجو می کند و هم خود را در همان sysroot نصب می کند تا دیگران بتوانند راحت تر آن را پیدا کنند.

Emscripten در حال حاضر sysroot خود را در زیر (path to emscripten cache)/sysroot دارد که از آن برای کتابخانه‌های سیستم ، پورت‌های Emscripten و ابزارهایی مانند CMake و pkg-config استفاده می‌کند. من تصمیم گرفتم از همان sysroot برای وابستگی هایم نیز استفاده مجدد کنم.

# This is the default path, but you can override it
# to store the cache elsewhere if you want.
#
# For example, it might be useful for Docker builds
# if you want to preserve the deps between reruns.
EM_CACHE
= $(EMSCRIPTEN)/cache

# Sysroot is always under the `sysroot` subfolder.
SYSROOT
= $(EM_CACHE)/sysroot

# …

# For all dependencies I've used the same ./configure command with the
# earlier defined SYSROOT path as the --prefix.
deps
/%/Makefile: deps/%/configure
        cd $
(@D) && ./configure --prefix=$(SYSROOT) # …

با چنین پیکربندی، من فقط نیاز به اجرای make install در هر وابستگی داشتم، که آن را در زیر sysroot نصب می‌کرد و سپس کتابخانه‌ها به طور خودکار یکدیگر را پیدا می‌کردند.

برخورد با بارگذاری پویا

همانطور که در بالا ذکر شد، libgphoto2 از libtool برای شمارش و بارگذاری پویا آداپتورهای پورت I/O و کتابخانه های دوربین استفاده می کند. برای مثال، کد بارگیری کتابخانه های ورودی/خروجی به شکل زیر است:

lt_dlinit ();
lt_dladdsearchdir
(iolibs);
result
= lt_dlforeachfile (iolibs, foreach_func, list);
lt_dlexit
();

چند مشکل با این روش در وب وجود دارد:

  • هیچ پشتیبانی استانداردی برای پیوند پویا ماژول های WebAssembly وجود ندارد. Emscripten پیاده‌سازی سفارشی خود را دارد که می‌تواند API dlopen() مورد استفاده توسط libtool را شبیه‌سازی کند، اما از شما می‌خواهد ماژول‌های "main" و "side" را با پرچم‌های مختلف بسازید، و مخصوصا برای dlopen() ، همچنین ماژول‌های جانبی را از قبل بارگذاری کنید. در هنگام راه‌اندازی برنامه در سیستم فایل شبیه‌سازی شده ، ادغام آن پرچم‌ها و ترفندها در یک سیستم ساخت خودکار موجود با تعداد زیادی از سخت‌افزار است. کتابخانه های پویا
  • حتی اگر خود dlopen() پیاده سازی شود، راهی برای برشمردن تمام کتابخانه های پویا در یک پوشه خاص در وب وجود ندارد، زیرا اکثر سرورهای HTTP به دلایل امنیتی لیست های دایرکتوری را در معرض نمایش قرار نمی دهند.
  • پیوند کتابخانه های پویا در خط فرمان به جای شمارش در زمان اجرا می تواند منجر به مشکلاتی مانند مشکل نمادهای تکراری شود که به دلیل تفاوت بین نمایش کتابخانه های مشترک در Emscripten و سایر سیستم عامل ها ایجاد می شود.

می‌توان سیستم ساخت را با آن تفاوت‌ها تطبیق داد و لیست پلاگین‌های پویا را در جایی در حین ساخت کدگذاری کرد، اما یک راه ساده‌تر برای حل همه این مسائل، اجتناب از پیوند پویا برای شروع است.

به نظر می رسد، libtool روش های مختلف پیوند پویا را در پلتفرم های مختلف انتزاع می کند، و حتی از نوشتن لودرهای سفارشی برای دیگران پشتیبانی می کند. یکی از لودرهای داخلی که پشتیبانی می کند "Dlpreopening" نام دارد:

Libtool پشتیبانی ویژه ای برای dlopening شی libtool و فایل های کتابخانه libtool ارائه می دهد، به طوری که نمادهای آنها را می توان حتی بر روی پلتفرم ها بدون هیچ گونه توابع dlopen و dlsym حل کرد.

Libtool با پیوند دادن اشیاء به برنامه در زمان کامپایل، و ایجاد ساختارهای داده ای که نشان دهنده جدول نماد برنامه است، -dlopen را در پلتفرم های استاتیک شبیه سازی می کند. برای استفاده از این ویژگی، باید هنگام پیوند دادن برنامه خود، اشیایی را که می خواهید برنامه شما باز شود، با استفاده از پرچم های -dlopen یا -dlpreopen اعلام کنید ( به حالت پیوند مراجعه کنید).

این مکانیسم امکان شبیه‌سازی بارگذاری پویا را در سطح libtool به جای Emscripten می‌دهد، در حالی که همه چیز را به صورت ایستا به یک کتابخانه واحد پیوند می‌دهد.

تنها مشکلی که این کار حل نمی کند، شمارش کتابخانه های پویا است. لیست این موارد هنوز باید در جایی هاردکد شود. خوشبختانه، مجموعه پلاگین هایی که برای برنامه نیاز داشتم حداقل است:

  • از طرف پورت ها، من فقط به اتصال دوربین مبتنی بر libusb اهمیت می دهم و به حالت های PTP/IP، دسترسی سریال یا درایو USB اهمیت نمی دهم.
  • در سمت camlibs، پلاگین های مختلفی برای فروشنده وجود دارد که ممکن است برخی از عملکردهای تخصصی را ارائه دهند، اما برای کنترل تنظیمات عمومی و گرفتن عکس، کافی است از پروتکل انتقال تصویر استفاده کنید، که توسط camlib ptp2 نشان داده شده است و تقریباً توسط هر دوربین روی دوربین پشتیبانی می شود. بازار

در اینجا نمودار وابستگی به روز شده با همه چیزهایی که به صورت ایستا به هم مرتبط هستند به نظر می رسد:

یک نمودار "برنامه" را بسته به "libgphoto2 fork" نشان می دهد که به "libtool" بستگی دارد. 'libtool' به 'ports: libusb1' و 'camlibs: libptp2' بستگی دارد. 'ports: libusb1' به 'libusb fork' بستگی دارد.

بنابراین این همان چیزی است که من برای ساخت‌های Emscripten کدگذاری کردم:

LTDL_SET_PRELOADED_SYMBOLS();
lt_dlinit
();
#ifdef __EMSCRIPTEN__
  result
= foreach_func("libusb1", list);
#else
  lt_dladdsearchdir
(iolibs);
  result
= lt_dlforeachfile (iolibs, foreach_func, list);
#endif
lt_dlexit
();

و

LTDL_SET_PRELOADED_SYMBOLS();
lt_dlinit
();
#ifdef __EMSCRIPTEN__
  ret
= foreach_func("libptp2", &foreach_data);
#else
  lt_dladdsearchdir
(dir);
  ret
= lt_dlforeachfile (dir, foreach_func, &foreach_data);
#endif
lt_dlexit
();

در سیستم ساخت autoconf، اکنون باید -dlpreopen با هر دوی آن فایل‌ها به‌عنوان پرچم‌های پیوند برای همه فایل‌های اجرایی (مثلا، آزمایش‌ها و برنامه آزمایشی خودم) اضافه کنم، مانند این:

if HAVE_EMSCRIPTEN
LDADD
+= -dlpreopen $(top_builddir)/libgphoto2_port/usb1.la \
         
-dlpreopen $(top_builddir)/camlibs/ptp2.la
endif

در نهایت، اکنون که همه نمادها به صورت ایستا در یک کتابخانه واحد به هم مرتبط هستند، libtool به راهی برای تعیین اینکه کدام نماد متعلق به کدام کتابخانه است نیاز دارد. برای رسیدن به این هدف، توسعه دهندگان باید نام همه نمادهای در معرض نمایش مانند {function name} را به {library name}_LTX_{function name} تغییر دهند. ساده ترین راه برای انجام این کار استفاده از #define برای تعریف مجدد نام نمادها در بالای فایل پیاده سازی است:

// …
#include "config.h"

/* Define _LTX_ names - required to prevent clashes when using libtool preloading. */
#define gp_port_library_type libusb1_LTX_gp_port_library_type
#define gp_port_library_list libusb1_LTX_gp_port_library_list
#define gp_port_library_operations libusb1_LTX_gp_port_library_operations

#include <gphoto2/gphoto2-port-library.h>
// …

این طرح نام‌گذاری همچنین از تداخل نام جلوگیری می‌کند، در صورتی که تصمیم بگیرم در آینده افزونه‌های مخصوص دوربین را در همان برنامه پیوند دهم.

پس از اجرای همه این تغییرات، من توانستم برنامه آزمایشی را بسازم و افزونه ها را با موفقیت بارگذاری کنم.

ایجاد رابط کاربری تنظیمات

gPhoto2 به کتابخانه های دوربین اجازه می دهد تا تنظیمات خود را به شکل درخت ویجت تعریف کنند. سلسله مراتب انواع ویجت شامل موارد زیر است:

  • پنجره - ظرف پیکربندی سطح بالا
    • بخش ها - گروه های نامگذاری شده از ویجت های دیگر
    • فیلدهای دکمه
    • فیلدهای متنی
    • فیلدهای عددی
    • فیلدهای تاریخ
    • تغییر وضعیت می دهد
    • دکمه های رادیویی

نام، نوع، فرزندان، و تمام ویژگی‌های مرتبط دیگر هر ویجت را می‌توان از طریق C API در معرض نمایش قرار داد (و در صورت وجود مقادیر، اصلاح کرد). آنها با هم، پایه ای برای ایجاد خودکار تنظیمات UI در هر زبانی که می تواند با C تعامل داشته باشد، فراهم می کنند.

تنظیمات را می توان از طریق gPhoto2 یا در خود دوربین در هر زمان از زمان تغییر داد. علاوه بر این، برخی از ویجت‌ها می‌توانند فقط خواندنی باشند و حتی خود حالت فقط خواندنی به حالت دوربین و تنظیمات دیگر بستگی دارد. برای مثال، سرعت شاتر یک فیلد عددی قابل نوشتن در M (حالت دستی) است، اما در P (حالت برنامه) به یک فیلد فقط خواندنی اطلاعاتی تبدیل می‌شود. در حالت P، مقدار سرعت شاتر نیز بسته به روشنایی صحنه ای که دوربین به آن نگاه می کند، پویا و پیوسته در حال تغییر خواهد بود.

در مجموع، مهم است که همیشه اطلاعات به‌روز از دوربین متصل در رابط کاربری نشان داده شود، در حالی که به کاربر اجازه می‌دهد آن تنظیمات را از همان رابط کاربری ویرایش کند. رسیدگی به چنین جریان داده های دو طرفه پیچیده تر است.

gPhoto2 مکانیسمی برای بازیابی تنظیمات تغییر یافته ندارد، فقط کل درخت یا ویجت‌های جداگانه را بازیابی کند. به منظور به روز نگه داشتن رابط کاربری بدون سوسو زدن و از دست دادن فوکوس ورودی یا موقعیت اسکرول، به روشی نیاز داشتم تا درختان ویجت را بین فراخوانی ها از هم جدا کنم و فقط ویژگی های رابط کاربری تغییر یافته را به روز کنم. خوشبختانه، این یک مشکل در وب حل شده است و عملکرد اصلی چارچوب هایی مانند React یا Preact است. من با Preact برای این پروژه رفتم، زیرا بسیار سبک‌تر است و هر کاری را که نیاز دارم انجام می‌دهد.

در سمت C++، اکنون باید درخت تنظیمات را از طریق C API پیوند داده شده قبلی بازیابی و به صورت بازگشتی پیاده کنم و هر ویجت را به یک شی جاوا اسکریپت تبدیل کنم:

static std::pair<val, val> walk_config(CameraWidget *widget) {
  val result
= val::object();

  val name
(GPP_CALL(const char *, gp_widget_get_name(widget, _)));
  result
.set("name", name);
  result
.set("info", /* … */);
  result
.set("label", /* … */);
  result
.set("readonly", /* … */);

 
auto type = GPP_CALL(CameraWidgetType, gp_widget_get_type(widget, _));

 
switch (type) {
   
case GP_WIDGET_RANGE: {
      result
.set("type", "range");
      result
.set("value", GPP_CALL(float, gp_widget_get_value(widget, _)));

     
float min, max, step;
      gpp_try
(gp_widget_get_range(widget, &min, &max, &step));
      result
.set("min", min);
      result
.set("max", max);
      result
.set("step", step);

     
break;
   
}
   
case GP_WIDGET_TEXT: {
      result
.set("type", "text");
      result
.set("value",
                  GPP_CALL
(const char *, gp_widget_get_value(widget, _)));

     
break;
   
}
   
// …

در سمت جاوا اسکریپت، اکنون می توانم configToJS فراخوانی کنم، بر روی نمایش جاوا اسکریپت برگشتی درخت تنظیمات قدم بزنم و رابط کاربری را از طریق تابع Preact h بسازم:

let inputElem;
switch (config.type) {
 
case 'range': {
    let
{ min, max, step } = config;
    inputElem
= h(EditableInput, {
      type
: 'number',
      min
,
      max
,
      step
,
     
attrs
   
});
   
break;
 
}
 
case 'text':
    inputElem
= h(EditableInput, attrs);
   
break;
 
case 'toggle': {
    inputElem
= h('input', {
      type
: 'checkbox',
     
attrs
   
});
   
break;
 
}
 
// …

با اجرای مکرر این عملکرد در یک حلقه رویداد نامحدود، می‌توانم رابط کاربری تنظیمات را دریافت کنم تا همیشه آخرین اطلاعات را نشان دهد، در حالی که هر زمان که یکی از فیلدها توسط کاربر ویرایش شود، دستورات را به دوربین ارسال کنم.

Preact می تواند از تفاوت نتایج و به روز رسانی DOM فقط برای بیت های تغییر یافته UI مراقبت کند، بدون اینکه تمرکز صفحه یا وضعیت های ویرایش را مختل کند. یکی از مشکلاتی که باقی می ماند جریان داده های دو طرفه است. چارچوب‌هایی مانند React و Preact حول جریان داده‌های یک طرفه طراحی شده‌اند، زیرا استدلال درباره داده‌ها و مقایسه آن‌ها بین تکرارها بسیار آسان‌تر است، اما من با اجازه دادن به یک منبع خارجی - دوربین - برای به‌روزرسانی تنظیمات، این انتظار را زیر پا می‌گذارم. UI در هر زمان.

من با انصراف از به‌روزرسانی‌های رابط کاربری برای هر فیلد ورودی که در حال حاضر توسط کاربر در حال ویرایش است، این مشکل را حل کردم:

/**
 * Wrapper around <input /> that doesn't update it while it's in focus to allow editing.
 */

class EditableInput extends Component {
  ref
= createRef();

  shouldComponentUpdate
() {
   
return this.props.readonly || document.activeElement !== this.ref.current;
 
}

  render
(props) {
   
return h('input', Object.assign(props, {ref: this.ref}));
 
}
}

به این ترتیب، همیشه تنها یک مالک برای هر فیلد مشخص وجود دارد. یا در حال حاضر کاربر در حال ویرایش آن است و مقادیر به‌روزرسانی‌شده دوربین مختل نمی‌شود، یا دوربین در حال به‌روزرسانی مقدار فیلد در حالی که فوکوس نیست.

ساخت یک فید زنده "ویدیویی".

در طول همه گیری، افراد زیادی به جلسات آنلاین رفتند. در میان چیزهای دیگر، این منجر به کمبود در بازار وب کم شد. برای دریافت کیفیت ویدیویی بهتر در مقایسه با دوربین‌های داخلی لپ‌تاپ، و در پاسخ به کمبودهای گفته شده، بسیاری از دارندگان دوربین‌های DSLR و بدون آینه به دنبال راه‌هایی برای استفاده از دوربین‌های عکاسی خود به عنوان وب‌کم بودند. چندین فروشنده دوربین حتی ابزارهای رسمی را برای این منظور ارسال کردند .

مانند ابزارهای رسمی، gPhoto2 از پخش ویدیو از دوربین به یک فایل ذخیره شده محلی یا مستقیماً به یک وب کم مجازی نیز پشتیبانی می کند . من می خواستم از این ویژگی برای ارائه یک نمای زنده در نسخه نمایشی خود استفاده کنم. با این حال، در حالی که در ابزار کنسول در دسترس است، من نتوانستم آن را در هیچ کجای APIهای کتابخانه libgphoto2 پیدا کنم.

با نگاهی به کد منبع تابع مربوطه در ابزار کنسول، متوجه شدم که در واقع به هیچ وجه ویدیو نمی‌گیرد، بلکه پیش‌نمایش دوربین را به‌عنوان تصاویر JPEG جداگانه در یک حلقه بی‌پایان بازیابی می‌کند و آنها را یکی یکی می‌نویسد. یک جریان M-JPEG تشکیل دهید:

while (1) {
 
const char *mime;
  r
= gp_camera_capture_preview (p->camera, file, p->context);
 
// …

من شگفت زده شدم که این رویکرد به اندازه کافی کارآمد عمل می کند تا تصوری از ویدیوی بیدرنگ صاف داشته باشد. من حتی در مورد اینکه بتوانم همان عملکرد را در برنامه وب نیز با تمام انتزاعات اضافی و Asyncify در راه داشته باشم، شک داشتم. با این حال تصمیم گرفتم به هر حال تلاش کنم.

در سمت C++، من روشی به نام capturePreviewAsBlob() را معرفی کردم که همان تابع gp_camera_capture_preview() را فراخوانی می‌کند و فایل درون حافظه حاصل را به Blob تبدیل می‌کند که می‌تواند راحت‌تر به دیگر APIهای وب ارسال شود:

val capturePreviewAsBlob() {
 
return gpp_rethrow([=]() {
   
auto &file = get_file();

    gpp_try
(gp_camera_capture_preview(camera.get(), &file, context.get()));

   
auto params = blob_chunks_and_opts(file);
   
return Blob.new_(std::move(params.first), std::move(params.second));
 
});
}

در سمت جاوا اسکریپت، من یک حلقه شبیه به gPhoto2 دارم که تصاویر پیش‌نمایش را به‌عنوان Blob s بازیابی می‌کند، آنها را در پس‌زمینه با createImageBitmap رمزگشایی می‌کند و آنها را به بوم در فریم انیمیشن بعدی منتقل می‌کند :

while (this.canvasRef.current) {
 
try {
    let blob
= await this.props.getPreview();

    let img
= await createImageBitmap(blob, { /* … */ });
    await
new Promise(resolve => requestAnimationFrame(resolve));
    canvasCtx
.transferFromImageBitmap(img);
 
} catch (err) {
   
// …
 
}
}

استفاده از آن APIهای مدرن تضمین می کند که تمام کارهای رمزگشایی در پس زمینه انجام می شود و بوم تنها زمانی به روز می شود که هم تصویر و هم مرورگر به طور کامل برای طراحی آماده شده باشند. این به 30+ FPS ثابت در لپ تاپ من رسید که با عملکرد اصلی gPhoto2 و نرم افزار رسمی سونی مطابقت داشت.

همگام سازی دسترسی USB

هنگامی که یک انتقال داده USB درخواست می شود در حالی که عملیات دیگری در حال انجام است، معمولاً منجر به خطای "دستگاه مشغول است". از آنجایی که پیش‌نمایش و تنظیمات رابط کاربری به‌طور مرتب به‌روزرسانی می‌شوند، و کاربر ممکن است در تلاش برای گرفتن تصویر یا تغییر تنظیمات به طور همزمان باشد، چنین تضادهایی بین عملیات‌های مختلف بسیار مکرر است.

برای اجتناب از آنها، من نیاز به همگام سازی تمام دسترسی های داخل برنامه داشتم. برای آن، من یک صف async مبتنی بر وعده ساخته ام:

let context = await new Module.Context();

let queue
= Promise.resolve();

function schedule(op) {
  let res
= queue.then(() => op(context));
  queue
= res.catch(rethrowIfCritical);
 
return res;
}

با زنجیر کردن هر عملیات در یک callback then() از وعده queue موجود، و ذخیره نتیجه زنجیره‌ای به عنوان مقدار جدید queue ، می‌توانم مطمئن شوم که تمام عملیات‌ها یک به یک، به ترتیب و بدون همپوشانی اجرا می‌شوند.

هر گونه خطای عملیات به تماس گیرنده برگردانده می شود، در حالی که خطاهای بحرانی (غیر منتظره) کل زنجیره را به عنوان یک وعده رد شده علامت گذاری می کنند و اطمینان حاصل می کنند که هیچ عملیات جدیدی پس از آن برنامه ریزی نمی شود.

با نگه داشتن زمینه ماژول در یک متغیر خصوصی (غیرصادراتی)، خطرات دسترسی تصادفی به context را در جایی دیگر در برنامه بدون انجام schedule() به حداقل می‌رسانم.

برای گره زدن چیزها به یکدیگر، اکنون هر دسترسی به زمینه دستگاه باید در یک فراخوانی schedule() مانند این پیچیده شود:

let config = await this.connection.schedule((context) => context.configToJS());

و

this.connection.schedule((context) => context.captureImageAsFile());

پس از آن، تمام عملیات بدون درگیری با موفقیت اجرا شد.

نتیجه گیری

برای کسب اطلاعات بیشتر در مورد پیاده سازی، می توانید به راحتی پایگاه کد را در Github مرور کنید. همچنین می‌خواهم از مارکوس مایسنر برای نگهداری gPhoto2 و بررسی‌هایش از روابط عمومی بالادستی من تشکر کنم.

همانطور که در این پست ها نشان داده شده است، API های WebAssembly، Asyncify و Fugu حتی برای پیچیده ترین برنامه ها یک هدف کامپایل توانمند ارائه می کنند. آنها به شما این امکان را می دهند که کتابخانه یا برنامه ای را که قبلاً برای یک پلتفرم ساخته شده است بگیرید و آن را به وب پورت کنید و آن را برای تعداد بسیار بیشتری از کاربران در دستگاه های دسکتاپ و تلفن همراه به طور یکسان در دسترس قرار دهید.