جارٍ نقل تطبيقات USB إلى الويب. الجزء الثاني: gPhoto2

تعرَّف على كيفية نقل gPhotos2 إلى WebAssembly للتحكّم في الكاميرات الخارجية عبر USB من خلال تطبيق ويب.

في المشاركة السابقة، عرضتُ كيف تم نقل مكتبة libusb لتشغيلها على الويب باستخدام WebAssembly وEmscripten وAsyncify وWebUSB.

لقد عرضتُ أيضًا عرضًا توضيحيًا تم إنشاؤه باستخدام gPhoto2 ويمكنه التحكّم في الكاميرات ذات العدسة الأحادية العاكسة (DSLR) والكاميرات غير المعكوسة المرايا عبر USB من تطبيق ويب. في هذه المشاركة، سأتعمق أكثر في التفاصيل الفنية المتعلقة بمنفذ gPhotos2.

أنظمة تصميم تشير إلى الشوكات المخصّصة

بما أنني كنت أستهدف WebAssembly، لم أتمكن من استخدام libusb وlibgphoto2 اللذين تقدمهما توزيعات النظام. بدلاً من ذلك، كنت في حاجة إلى استخدام شوكتي المخصصة لـ libgphoto2، بينما كان على شوكة libgphoto2 استخدام شوكة libusb المخصصة.

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

في ما يلي رسم تخطيطي تقريبي للتبعية (تشير الخطوط المتقطعة إلى الارتباط الديناميكي):

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

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

هناك بعض المشاكل المرتبطة بهذا النهج على الويب:

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

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

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

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

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

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

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

إليك ما يبدو عليه مخطط التبعية المحدّث مع كل شيء مرتبط بشكل ثابت معًا:

يعرض الرسم التخطيطي "التطبيق" استنادًا إلى "libgphoto2 fork" (وهو شوكة) التي تعتمد على "libtools". يعتمد "libtools" على "ports: libusb1" و"camlibs: libptp2". "ports: libusb1" (المنافذ: 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 ();

في نظام إنشاء الربط التلقائي، عليّ الآن إضافة الملف -dlpreopen مع كلا الملفين كعلامات روابط لجميع الملفات التنفيذية (أمثلة واختبارات وتطبيقي التجريبي الخاص بي)، على النحو التالي:

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

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

ويمنع نظام التسمية هذا أيضًا تعارض الأسماء في حال قررت ربط المكوّنات الإضافية الخاصة بالكاميرا في التطبيق نفسه في المستقبل.

بعد تنفيذ جميع هذه التغييرات، يمكنني إنشاء تطبيق الاختبار وتحميل المكونات الإضافية بنجاح.

جارٍ إنشاء واجهة مستخدم الإعدادات

يسمح gPhotos2 لمكتبات الكاميرا بتحديد إعداداتها على شكل شجرة أدوات. ويتكوّن العرض الهرمي لأنواع الأدوات مما يلي:

  • نافذة: حاوية إعدادات من المستوى الأعلى
    • الأقسام - مجموعات ذات أسماء مصغّرة أخرى
    • حقول الأزرار
    • الحقول النصية
    • الحقول الرقمية
    • حقول التاريخ
    • تبديل
    • أزرار الاختيار

يمكن الاستعلام عن الاسم والنوع والعناصر الفرعية وجميع الخصائص الأخرى ذات الصلة لكل أداة (وتعديلها أيضًا في حال القيم) عبر واجهة C API المكشوفة. معًا، يوفرون أساسًا لإنشاء واجهة مستخدم الإعدادات تلقائيًا بأي لغة يمكن أن تتفاعل مع C.

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

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

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

على جانب C++، أنا بحاجة الآن لاسترداد شجرة الإعدادات والسير بشكل متكرر عبر واجهة برمجة تطبيقات C التي تم ربطها سابقًا، وتحويل كل أداة إلى كائن 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;
  }
  // …

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

يمكن أن يساعد التنفيذ في تغيير النتائج وتعديل نموذج العناصر في المستند (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}));
  }
}

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

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

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

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

بعد الاطّلاع على رمز المصدر الخاص بالوظيفة المقابلة في الأداة المساعدة الخاصة بوحدة التحكّم، وجدت أنّه لا يتم الحصول على فيديو على الإطلاق، بل يواصل استرداد معاينة الكاميرا على أنّها صور JPEG فردية في حلقة لا نهاية لها، وكتابتها واحدة تلو الأخرى لتشكيل بث M-JPEG:

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

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

على جانب 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، لديّ حلقة تكرار مشابهة لتلك الموجودة في gPhotos2، وهي تستمر في استرداد صور المعاينة على شكل Blobs، وفك ترميزها في الخلفية باستخدام 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 لقطة في الثانية على الكمبيوتر المحمول، وهو ما يتناسب مع الأداء الأصلي لكل من gPhotos2 وبرنامج 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());

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

الخلاصة

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

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