جارٍ نقل تطبيقات USB إلى الويب. الجزء الثاني: 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" و"مكتبة libgphoto2 camlibs". أخيرًا، تعتمد "منافذ libgphoto2" بشكل ثابت على "libusb fork".

تسمح معظم أنظمة الإنشاء المستندة إلى الإعدادات، بما في ذلك تلك المستخدَمة في هذه المكتبات، بإلغاء مسارات الملحقات من خلال علامات مختلفة، لذا حاولت إجراء ذلك أولاً. ومع ذلك، عندما يصبح الرسم البياني للتبعيات معقّدًا، تصبح قائمة عمليات إلغاء المسار لكل تبعيات المكتبة مفصّلة وقابلة للخطأ. كما وجدت أيضًا بعض الأخطاء التي لم تكن فيها أنظمة التصميم مُجهزة بالفعل لتبعياتها لتتواجد في مسارات غير قياسية.

بدلاً من ذلك، فإن الطريقة الأسهل هي إنشاء مجلد منفصل كجذر نظام مخصص (غالبًا ما يتم اختصاره إلى "sysroot") وتوجيه جميع أنظمة التصميم المعنيّة إليه. وبهذه الطريقة، ستبحث كل مكتبة عن تبعياتها في جذر النظام المحدد أثناء عملية الإنشاء، وستثبت أيضًا نفسها في نفس sysroot حتى يتمكن الآخرون من العثور عليها بسهولة أكبر.

يحتوي Emscripten على نظام جذر خاص به ضمن (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 لتعداد محولات منافذ الإدخال/الإخراج وتحميل مكتبات الكاميرا ديناميكيًا. على سبيل المثال، يبدو رمز تحميل مكتبات الإدخال/الإخراج على النحو التالي:

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

هناك بعض المشاكل في هذا الأسلوب على الويب:

  • لا تتوفّر إمكانية ربط ديناميكي وحدات WebAssembly بشكل عادي. يحتوي Emscripten على تنفيذ مخصّص يمكنه محاكاة واجهة برمجة التطبيقات dlopen() المستخدَمة في libtool، ولكنّه يتطلّب منك إنشاء الوحدات "الأساسية" و"الإضافية" باستخدام علامات مختلفة، وبالنسبة إلى dlopen() على وجه التحديد، عليك أيضًا تحميل الوحدات الإضافية مسبقًا في نظام الملفات المحاكي أثناء بدء تشغيل التطبيق. قد يكون من الصعب دمج هذه العلامات والتعديلات في نظام إنشاء autoconf حالي يحتوي على الكثير من المكتبات الديناميكية.
  • حتى إذا تم تنفيذ dlopen() نفسه، ما مِن طريقة لتعداد جميع المكتبات الديناميكية في مجلد معيّن على الويب، لأنّ معظم خوادم HTTP لا تعرض قوائم الدليل لأسباب تتعلّق بالأمان.
  • يمكن أن يؤدي أيضًا ربط المكتبات الديناميكية في سطر الأوامر بدلاً من التعداد في بيئة التشغيل إلى حدوث مشاكل مثل مشكلة الرموز المكررة ناجمة عن الاختلافات بين تمثيل المكتبات المشتركة في Emscripten وعلى الأنظمة الأساسية الأخرى.

من الممكن تكييف نظام التصميم مع هذه الاختلافات وترميز قائمة المكونات الإضافية الديناميكية في مكان ما أثناء عملية الإنشاء، ولكن هناك طريقة أسهل لحل جميع هذه المشاكل تتمثل في تجنب الربط الديناميكي للبدء.

تبين أن libtool يستخرج العديد من طرق الربط الديناميكي على الأنظمة الأساسية المختلفة، كما يتيح كتابة برامج تحميل مخصصة للآخرين. ويُطلق على إحدى برامج التحميل المضمّنة التي يتوافق معها اسم "Dlpreopening":

"يوفّر libtool دعمًا خاصًا لفتح ملفّات مكتبة libtool وملفّات مثيل libtool باستخدام dlopen، حتى يمكن حلّ رموزها حتى على الأنظمة الأساسية التي لا تتضمّن أيّ وظائف dlopen وdlsym.

يحاكي Libtool دالة -dlopen على الأنظمة الأساسية الثابتة من خلال ربط العناصر بالبرنامج في وقت الترجمة، وإنشاء بنى بيانات تمثّل جدول رموز البرنامج. ولاستخدام هذه الميزة، عليك تعريف الكائنات التي تريد أن يميّز تطبيقك بها تطبيقك باستخدام العلامتَين -dlopen أو -dlpreopen عند ربط برنامجك (اطّلِع على وضع الربط)."

تتيح هذه الآلية محاكاة التحميل الديناميكي على مستوى libtool بدلاً من Emscripten، مع ربط كل العناصر بشكل ثابت في مكتبة واحدة.

المشكلة الوحيدة التي لا يحلها هذا هو تعداد المكتبات الديناميكية. لا تزال قائمة هذه العناصر بحاجة إلى الترميز الثابت في مكان ما. لحسن الحظ، كانت مجموعة المكوّنات الإضافية التي احتاجتها للتطبيق محدودة:

  • من ناحية المنافذ، لا يهمّني سوى اتصال الكاميرا المستنِد إلى libusb وليس PTP/IP أو الوصول التسلسلي أو أوضاع محرك USB.
  • على الجانب الآخر، هناك العديد من المكوّنات الإضافية الخاصة بالمورّدين والتي قد توفّر بعض الوظائف المتخصصة، إلا أنّ التحكّم في الإعدادات العامة والتقاط الصور يكفي لاستخدام بروتوكول نقل الصور الذي يمثّله بروتوكول ptp2 camlib ويدعمه تقريبًا كل كاميرا في السوق.

في ما يلي شكل مخطّط التبعيات المعدَّل مع ربط كل العناصر ببعضها بشكل ثابت:

يعرض مخطّط بياني "التطبيق" الذي يعتمد على "libgphoto2 fork"، والذي يعتمد بدوره على "libtool". يعتمد "libtool" على "المنافذ: libusb1" و"camlibs: libptp2". تعتمد "منافذ: libusb1" على "نسخة libusb".

في ما يلي الرمز غير القابل للتغيير الذي أضفته إلى عمليات إنشاء 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 مكشوفة. ويوفّران معًا أساسًا لإنشاء واجهة مستخدم الإعدادات تلقائيًا بأي لغة يمكنها التفاعل مع C.

يمكن تغيير الإعدادات من خلال gPhoto2 أو باستخدام الكاميرا نفسها في أي وقت. بالإضافة إلى ذلك، يمكن أن تكون بعض التطبيقات المصغّرة للأجهزة قابلة للقراءة فقط، وحتى حالة القراءة فقط نفسها تعتمد على وضع الكاميرا والإعدادات الأخرى. على سبيل المثال، سرعة الإغلاق هي حقل رقمي قابل للكتابة في M (الوضع اليدوي)، ولكنه يصبح حقلاً للمعلومات للقراءة فقط في P (وضع البرنامج). في وضع P، ستكون قيمة سرعة الغالق ديناميكية أيضًا وتتغيّر باستمرار استنادًا إلى سطوع المشهد الذي ترصده الكاميرا.

وبوجه عام، من المهم عرض معلومات محدَّثة دائمًا من الكاميرا المتصلة في واجهة المستخدم، مع السماح للمستخدم في الوقت نفسه بتعديل هذه الإعدادات من واجهة المستخدم نفسها. إنّ تدفق البيانات الثنائي الاتجاه هذا أكثر تعقيدًا في التعامل معه.

لا يتضمّن gPhoto2 آلية لاسترداد الإعدادات التي تم تغييرها فقط، بل الشجرة بأكملها أو التطبيقات المصغّرة الفردية فقط. للحفاظ على حداثة واجهة المستخدم بدون وميض أو فقدان تركيز الإدخال أو موضع التمرير، كنت بحاجة إلى طريقة للمقارنة بين أشجار التطبيقات المصغّرة بين عمليات الاستدعاء وتعديل سمات واجهة المستخدم التي تم تغييرها فقط. لحسن الحظ، تم حلّ هذه المشكلة على الويب، وهي الوظيفة الأساسية لإطارات العمل مثل React أو Preact. لقد تعاونت مع Preact في هذا المشروع، حيث يوفر نموذجًا خفيفًا بدرجة أكبر ويتيح تنفيذ كل ما أحتاج إليه.

من جانب C++، كنت بحاجة الآن إلى استرداد شجرة الإعدادات والتنقّل فيها بشكل تسلسلي من خلال واجهة برمجة التطبيقات C API المرتبطة سابقًا، وتحويل كل عنصر واجهة مستخدم إلى عنصر JavaScript:

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;
    }
    // …

من جانب JavaScript، يمكنني الآن استدعاء configToJS والانتقال إلى تمثيل JavaScript الذي تم إرجاعه لشجرة الإعدادات وإنشاء واجهة المستخدم من خلال دالة 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 فقط للأجزاء التي تم تغييرها من واجهة المستخدم، بدون التأثير في تركيز الصفحة أو حالات التعديل. تبقى مشكلة واحدة وهي تدفّق البيانات الثنائي الاتجاه. تم تصميم إطارات العمل، مثل React وPreact، استنادًا إلى تدفق البيانات أحادي الاتجاه، لأنّ ذلك يسهّل كثيرًا فهم البيانات ومقارنتها بين عمليات إعادة التشغيل، ولكنّني أخالف هذا التوقع من خلال السماح لمصدر خارجي، وهو الكاميرا، بتعديل واجهة مستخدم الإعدادات في أي وقت.

لقد حللتُ هذه المشكلة من خلال إيقاف تحديثات واجهة المستخدم لأي حقول إدخال يعدّلها المستخدم حاليًا:

/**
 * 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}));
  }
}

بهذه الطريقة، يكون هناك دائمًا مالك واحد فقط لأي حقل معين. إما أنّ المستخدم يعدّله حاليًا، ولن تتداخل القيم المعدَّلة من الكاميرا معه، أو تعدّل الكاميرا قيمة الحقل عندما لا يكون الهدف في التركيز.

إنشاء خلاصة "فيديو" مباشرة

خلال فترة الجائحة، انتقل الكثير من الأشخاص إلى الاجتماعات على الإنترنت. وقد أدّى ذلك، من بين أمور أخرى، إلى نقص في سوق كاميرات الويب. للحصول على جودة فيديو أفضل مقارنةً بالكاميرات المدمجة في أجهزة الكمبيوتر المحمول، ونتيجةً للنقص المذكور، بدأ العديد من مالكي الكاميرات الرقمية ذات العدسة الأحادية العاكسة والكاميرات غير المزوّدة بمرآة في البحث عن طرق لاستخدام كاميرات التصوير الخاصة بهم ككاميرات ويب. وقد شحَّن العديد من مورّدي الكاميرات أدوات مساعدة رسمية لهذا الغرض تحديدًا.

مثل الأدوات الرسمية، يتوافق برنامج gPhoto2 مع بث الفيديو من الكاميرا إلى ملف مخزّن على الجهاز أو مباشرةً إلى كاميرا ويب افتراضية. أردت استخدام هذه الميزة لتوفير عرض مباشر في العرض الترويجي. ومع أنّه متاح في أداة وحدة التحكّم، لم أتمكّن من العثور عليه في أي مكان في واجهات برمجة تطبيقات مكتبة 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 يمكن تمريره إلى واجهات برمجة تطبيقات الويب الأخرى بسهولة أكبر:

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));
  });
}

من جهة JavaScript، لديّ حلقة مشابهة للحلقة في gPhoto2، وهي تستمر في استرداد صور المعاينة بتنسيق Blob، وفك ترميزها في الخلفية باستخدام 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) {
    // …
  }
}

ويضمن استخدام واجهات برمجة التطبيقات الحديثة هذه تنفيذ جميع أعمال فك الترميز في الخلفية، ولا يتم تحديث اللوحة إلا عندما تكون كل من الصورة والمتصفّح مستعدَين بالكامل للرسم. وقد تم تحقيق معدّل 30 لقطة في الثانية بشكل ثابت على الكمبيوتر المحمول، والذي طابق الأداء الأصلي لكل من gPhoto2 وبرنامج Sony الرسمي.

مزامنة إمكانية الوصول عبر USB

عند طلب نقل بيانات عبر USB أثناء إجراء عملية أخرى، سيؤدي ذلك عادةً إلى ظهور خطأ "الجهاز مشغول". وبما أنّ واجهة المستخدم الخاصة بالإعدادات والمعاينة يتم تعديلها بانتظام، وقد يحاول المستخدم التقاط صورة أو تعديل الإعدادات في الوقت نفسه، تبيّن أنّ هذه التعارضات بين العمليات المختلفة تحدث بشكل متكرر للغاية.

ولتجنُّب حدوثها، كان عليّ مزامنة جميع عمليات الوصول داخل التطبيق. لذلك، أنشأت قائمة انتظار غير متزامنة مستندة إلى وعود:

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;
}

من خلال ربط كل عملية في دالة استدعاء then() لوعد queue الحالي، وتخزين النتيجة المرتبطة كقيمة جديدة لـ queue، يمكنني التأكّد من تنفيذ جميع العمليات واحدة تلو الأخرى، بالترتيب وبدون تداخلات.

يتم عرض أي أخطاء في العملية للمتصل، في حين تضع الأخطاء الملحّة (غير المتوقّعة) علامة على السلسلة بأكملها كوعد مرفوض، وتؤكّد عدم جدولة أي عملية جديدة بعد ذلك.

من خلال إبقاء سياق الوحدة في متغيّر خاص (غير مصدَّر)، أنا أحدّ من مخاطر الوصول إلى context عن طريق الخطأ في مكان آخر في التطبيق بدون إجراء استدعاء schedule().

لتوضيح الأمر، يجب الآن تضمين كل عملية وصول إلى سياق الجهاز في طلب schedule() على النحو التالي:

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

و

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

بعد ذلك، تم تنفيذ جميع العمليات بنجاح بدون أي تعارضات.

الخاتمة

يمكنك تصفُّح قاعدة الرموز على Github للحصول على مزيد من إحصاءات التنفيذ. أريد أيضًا أن أشكر ماركوس ميسنر على صيانة gPhoto2 وعلى مراجعاته لطلبات الدمج في الإصدارات العلنية.

كما هو موضّح في هذه المنشورات، توفِّر واجهات برمجة التطبيقات WebAssembly وAsyncify وFugu API هدف التجميع الفعّال للتطبيقات الأكثر تعقيدًا. فهي تتيح لك أخذ مكتبة أو تطبيق تم إنشاؤه سابقًا لنظام أساسي واحد، ونقله إلى الويب، مما يجعله متاحًا لعدد أكبر بكثير من المستخدمين عبر أجهزة الكمبيوتر المكتبية والأجهزة المحمولة على حد سواء.