将 USB 应用移植到 Web 中。第 1 部分:libusb

了解如何使用 WebAssembly 和 Fugu API 将与外部设备交互的代码移植到 Web。

Ingvar Stepanyan
Ingvar Stepanyan

在上一篇博文中,我介绍了如何使用 File System Access API、WebAssembly 和 Asyncify 将使用文件系统 API 的应用移植到 Web。现在,我想继续讨论将 Fugu API 与 WebAssembly 集成,以及在不丢失重要功能的情况下将应用移植到 Web 的同一主题。

我将展示如何通过将 libusb(一种用 C 语言编写的热门 USB 库)移植到 WebAssembly(通过 Emscripten)、Asyncify 和 WebUSB,将与 USB 设备通信的应用移植到 Web。

首先:演示

移植库时,最重要的事情是选择合适的演示,该演示应能展示移植库的功能,让您能够以各种方式对其进行测试,同时具有视觉吸引力。

我选择的创意是单反相机遥控器。特别是,开源项目 gPhoto2 在该领域已经存在了足够长的时间,可以对各种数码相机进行逆向工程并实现对它们的支持。它支持多种协议,但我最感兴趣的是 USB 支持,它是通过 libusb 实现的。

我将分两部分介绍构建此演示的步骤。在这篇博文中,我将介绍如何移植 libusb 本身,以及将其他热门库移植到 Fugu API 可能需要哪些技巧。在第二篇博文中,我将详细介绍如何移植和集成 gPhoto2 本身。

最终,我得到了一个可正常运行的 Web 应用,该应用可以预览单反相机的实时画面,并能通过 USB 控制其设置。在阅读技术细节之前,欢迎先观看实时或预先录制的演示:

演示在连接到 Sony 相机的笔记本电脑上运行。

有关相机特定问题的说明

您可能已经注意到,在视频中更改设置需要一些时间。与您可能遇到的大多数其他问题一样,这并非由 WebAssembly 或 WebUSB 的性能引起,而是由 gPhoto2 与为演示选择的特定相机交互的方式引起。

Sony a6600 未提供用于直接设置 ISO、光圈或快门速度等值的 API,而仅提供用于按指定步数增加或减少这些值的命令。更复杂的是,它也不会返回实际支持的值列表,返回的列表似乎在许多索尼相机型号中都是硬编码的。

设置其中一个值时,gPhoto2 别无选择,只能:

  1. 朝着所选值的方向迈出一步(或几步)。
  2. 请稍等片刻,让摄像头更新设置。
  3. 读回相机实际采用的值。
  4. 检查最后一步是否跳过了所需的值,或者是否在列表的末尾或开头循环。
  5. 重复。

这可能需要一些时间,但如果该值确实受相机支持,则会达到该值;如果不支持,则会停止在最接近的支持值。

其他相机可能会有不同的设置、底层 API 和特性。请注意,gPhoto2 是一个开源项目,对所有相机型号进行自动化或手动测试根本不可行,因此我们始终欢迎详细的问题报告和 PR(但请务必先使用官方 gPhoto2 客户端重现问题)。

重要的跨平台兼容性说明

遗憾的是,在 Windows 上,任何“知名”设备(包括 DSLR 相机)都会被分配一个与 WebUSB 不兼容的系统驱动程序。如果您想在 Windows 上试用该演示,则必须使用 Zadig 等工具将所连接单反相机的驱动程序替换为 WinUSB 或 libusb。这种方法对我以及许多其他用户来说都很有效,但您在使用时需自行承担风险。

在 Linux 上,您可能需要设置自定义权限,以允许通过 WebUSB 访问您的单反相机,但这取决于您的发行版。

在 macOS 和 Android 上,该演示应该可以开箱即用。如果您在 Android 手机上尝试,请务必切换到横屏模式,因为我没有花太多精力来使其自适应(欢迎提交 PR!):

Android 手机通过 USB-C 数据线连接到 Canon 相机。
同一演示在 Android 手机上运行。图片由 Surma 提供。

如需查看有关 WebUSB 跨平台使用的更深入指南,请参阅“为 WebUSB 构建设备”的“平台专属注意事项”部分。

向 libusb 添加新后端

现在,我们来了解技术细节。虽然可以提供类似于 libusb 的 shim API(之前已有其他人这样做过),并将其他应用与其关联,但这种方法容易出错,并且会使进一步的扩展或维护变得更加困难。我想以一种可能在未来贡献回上游并合并到 libusb 的方式正确地做事。

幸运的是,libusb 自述文件中写道:

“libusb 在内部进行了抽象,因此有望移植到其他操作系统。如需了解详情,请参阅 PORTING 文件。”

libusb 的结构是将公共 API 与“后端”分开。这些后端负责通过操作系统的低级 API 列出、打开、关闭设备以及与设备进行实际通信。libusb 已经通过这种方式抽象出 Linux、macOS、Windows、Android、OpenBSD/NetBSD、Haiku 和 Solaris 之间的差异,并可在所有这些平台上运行。

我必须为 Emscripten+WebUSB“操作系统”添加另一个后端。这些后端的实现位于 libusb/os 文件夹中:

~/w/d/libusb $ ls libusb/os
darwin_usb.c           haiku_usb_raw.h  threads_posix.lo
darwin_usb.h           linux_netlink.c  threads_posix.o
events_posix.c         linux_udev.c     threads_windows.c
events_posix.h         linux_usbfs.c    threads_windows.h
events_posix.lo        linux_usbfs.h    windows_common.c
events_posix.o         netbsd_usb.c     windows_common.h
events_windows.c       null_usb.c       windows_usbdk.c
events_windows.h       openbsd_usb.c    windows_usbdk.h
haiku_pollfs.cpp       sunos_usb.c      windows_winusb.c
haiku_usb_backend.cpp  sunos_usb.h      windows_winusb.h
haiku_usb.h            threads_posix.c
haiku_usb_raw.cpp      threads_posix.h

每个后端都包含具有常见类型和辅助函数的 libusbi.h 标头,并且需要公开 usbi_os_backend 类型的 usbi_backend 变量。例如,Windows 后端如下所示:

const struct usbi_os_backend usbi_backend = {
  "Windows",
  USBI_CAP_HAS_HID_ACCESS,
  windows_init,
  windows_exit,
  windows_set_option,
  windows_get_device_list,
  NULL,   /* hotplug_poll */
  NULL,   /* wrap_sys_device */
  windows_open,
  windows_close,
  windows_get_active_config_descriptor,
  windows_get_config_descriptor,
  windows_get_config_descriptor_by_value,
  windows_get_configuration,
  windows_set_configuration,
  windows_claim_interface,
  windows_release_interface,
  windows_set_interface_altsetting,
  windows_clear_halt,
  windows_reset_device,
  NULL,   /* alloc_streams */
  NULL,   /* free_streams */
  NULL,   /* dev_mem_alloc */
  NULL,   /* dev_mem_free */
  NULL,   /* kernel_driver_active */
  NULL,   /* detach_kernel_driver */
  NULL,   /* attach_kernel_driver */
  windows_destroy_device,
  windows_submit_transfer,
  windows_cancel_transfer,
  NULL,   /* clear_transfer_priv */
  NULL,   /* handle_events */
  windows_handle_transfer_completion,
  sizeof(struct windows_context_priv),
  sizeof(union windows_device_priv),
  sizeof(struct windows_device_handle_priv),
  sizeof(struct windows_transfer_priv),
};

通过查看这些属性,我们可以看到该结构体包含后端名称、一组功能、以函数指针形式存在的各种底层 USB 操作的处理程序,以及最后用于存储私有设备/上下文/传输级数据的分配大小。

私有数据字段至少可用于存储所有这些内容的操作系统句柄,因为如果没有句柄,我们就无法知道任何给定操作适用于哪个项。在 Web 实现中,操作系统句柄将是底层 WebUSB JavaScript 对象。在 Emscripten 中,表示和存储这些对象的自然方式是通过 emscripten::val 类,该类作为 Embind(Emscripten 的绑定系统)的一部分提供。

该文件夹中的大多数后端都是用 C 语言实现的,但也有一些是用 C++ 实现的。Embind 仅适用于 C++,因此我别无选择,只能添加 libusb/libusb/os/emscripten_webusb.cpp,并使用所需的结构和 sizeof(val) 来表示私有数据字段:

#include <emscripten.h>
#include <emscripten/val.h>

#include "libusbi.h"

using namespace emscripten;

// …function implementations

const usbi_os_backend usbi_backend = {
  .name = "Emscripten + WebUSB backend",
  .caps = LIBUSB_CAP_HAS_CAPABILITY,
  // …handlers—function pointers to implementations above
  .device_priv_size = sizeof(val),
  .transfer_priv_size = sizeof(val),
};

将 WebUSB 对象存储为设备句柄

libusb 会提供指向已分配的私密数据区域的指针,供您直接使用。为了将这些指针作为 val 实例进行处理,我添加了一些小型辅助函数,用于就地构造这些指针、将其作为引用进行检索,以及移出值:

// We store an Embind handle to WebUSB USBDevice in "priv" metadata of
// libusb device, this helper returns a pointer to it.
struct ValPtr {
 public:
  void init_to(val &&value) { new (ptr) val(std::move(value)); }

  val &get() { return *ptr; }
  val take() { return std::move(get()); }

 protected:
  ValPtr(val *ptr) : ptr(ptr) {}

 private:
  val *ptr;
};

struct WebUsbDevicePtr : ValPtr {
 public:
  WebUsbDevicePtr(libusb_device *dev)
      : ValPtr(static_cast<val *>(usbi_get_device_priv(dev))) {}
};

val &get_web_usb_device(libusb_device *dev) {
  return WebUsbDevicePtr(dev).get();
}

struct WebUsbTransferPtr : ValPtr {
 public:
  WebUsbTransferPtr(usbi_transfer *itransfer)
      : ValPtr(static_cast<val *>(usbi_get_transfer_priv(itransfer))) {}
};

同步 C 上下文中的异步 Web API

现在,我们需要一种方法来处理异步 WebUSB API,因为 libusb 需要同步操作。为此,我可以使用 Asyncify,更具体地说,可以使用它通过 val::await() 实现的 Embind 集成。

我还想正确处理 WebUSB 错误并将其转换为 libusb 错误代码,但 Embind 目前无法从 C++ 端处理 JavaScript 异常或 Promise 拒绝。如需解决此问题,您可以在 JavaScript 端捕获拒绝,并将结果转换为 { error, value } 对象,以便现在可以从 C++ 端安全地解析该对象。我结合使用了 EM_JS 宏和 Emval.to{Handle, Value} API 来实现此目的:

EM_JS(EM_VAL, em_promise_catch_impl, (EM_VAL handle), {
  let promise = Emval.toValue(handle);
  promise = promise.then(
    value => ({error : 0, value}),
    error => {
      const ERROR_CODES = {
        // LIBUSB_ERROR_IO
        NetworkError : -1,
        // LIBUSB_ERROR_INVALID_PARAM
        DataError : -2,
        TypeMismatchError : -2,
        IndexSizeError : -2,
        // LIBUSB_ERROR_ACCESS
        SecurityError : -3,
        …
      };
      console.error(error);
      let errorCode = -99; // LIBUSB_ERROR_OTHER
      if (error instanceof DOMException)
      {
        errorCode = ERROR_CODES[error.name] ?? errorCode;
      }
      else if (error instanceof RangeError || error instanceof TypeError)
      {
        errorCode = -2; // LIBUSB_ERROR_INVALID_PARAM
      }
      return {error: errorCode, value: undefined};
    }
  );
  return Emval.toHandle(promise);
});

val em_promise_catch(val &&promise) {
  EM_VAL handle = promise.as_handle();
  handle = em_promise_catch_impl(handle);
  return val::take_ownership(handle);
}

// C++ struct representation for {value, error} object from above
// (performs conversion in the constructor).
struct promise_result {
  libusb_error error;
  val value;

  promise_result(val &&result)
      : error(static_cast<libusb_error>(result["error"].as<int>())),
        value(result["value"]) {}

  // C++ counterpart of the promise helper above that takes a promise, catches
  // its error, converts to a libusb status and returns the whole thing as
  // `promise_result` struct for easier handling.
  static promise_result await(val &&promise) {
    promise = em_promise_catch(std::move(promise));
    return {promise.await()};
  }
};

现在,我可以对 WebUSB 操作返回的任何 Promise 使用 promise_result::await(),并分别检查其 error 和 value 字段。

例如,从 libusb_device_handle 检索表示 USBDevice 的 val,调用其 open() 方法,等待其结果,并返回错误代码作为 libusb 状态代码,如下所示:

int em_open(libusb_device_handle *handle) {
  auto web_usb_device = get_web_usb_device(handle->dev);
  return promise_result::await(web_usb_device.call<val>("open")).error;
}

设备枚举

当然,在打开任何设备之前,libusb 需要先检索可用设备列表。后端必须通过 get_device_list 处理程序实现此操作。

难点在于,与其他平台不同,出于安全原因,无法枚举 Web 上所有已连接的 USB 设备。而是将流程分为两部分。首先,Web 应用通过 navigator.usb.requestDevice() 请求具有特定属性的设备,然后用户手动选择要公开的设备或拒绝权限提示。之后,应用会通过 navigator.usb.getDevices() 列出已获批准且已连接的设备。

起初,我尝试在 get_device_list 处理程序的实现中直接使用 requestDevice()。不过,显示包含已连接设备列表的权限提示被视为敏感操作,必须由用户互动(例如点击页面上的按钮)触发,否则始终会返回被拒绝的 promise。libusb 应用通常希望在应用启动时列出已连接的设备,因此无法使用 requestDevice()。

相反,我必须将 navigator.usb.requestDevice() 的调用留给最终开发者,并且仅公开 navigator.usb.getDevices() 中已获批准的设备:

// Store the global `navigator.usb` once upon initialisation.
thread_local const val web_usb = val::global("navigator")["usb"];

int em_get_device_list(libusb_context *ctx, discovered_devs **devs) {
  // C++ equivalent of `await navigator.usb.getDevices()`.
  // Note: at this point we must already have some devices exposed -
  // caller must have called `await navigator.usb.requestDevice(...)`
  // in response to user interaction before going to LibUSB.
  // Otherwise this list will be empty.
  auto result = promise_result::await(web_usb.call<val>("getDevices"));
  if (result.error) {
    return result.error;
  }
  auto &web_usb_devices = result.value;
  // Iterate over the exposed devices.
  uint8_t devices_num = web_usb_devices["length"].as<uint8_t>();
  for (uint8_t i = 0; i < devices_num; i++) {
    auto web_usb_device = web_usb_devices[i];
    // …
    *devs = discovered_devs_append(*devs, dev);
  }
  return LIBUSB_SUCCESS;
}

大多数后端代码都以与上文所示类似的方式使用 val 和 promise_result。数据传输处理代码中还有一些更有趣的技巧,但这些实现细节对于本文的目的而言并不重要。如果您感兴趣,请务必查看 GitHub 上的代码和注释。

将事件循环移植到 Web

我想讨论的 libusb 移植的另一个部分是事件处理。如上一篇文章所述,C 等系统语言中的大多数 API 都是同步的,事件处理也不例外。它通常通过无限循环来实现,该循环从一组外部 I/O 源“轮询”(尝试读取数据或阻止执行,直到有可用数据),当其中至少有一个源响应时,将其作为事件传递给相应的处理程序。处理程序完成后,控制权会返回到循环,并暂停以进行下一次轮询。

在 Web 上,这种方法存在一些问题。

首先,WebUSB 不会也无法公开底层设备的原始句柄,因此无法直接轮询这些句柄。其次,libusb 会将 eventfd 和 pipe API 用于其他事件,以及在没有原始设备句柄的操作系统上处理传输,但 Emscripten 目前不支持 eventfd,而 pipe 虽然受支持,但目前不符合规范,无法等待事件。

最后,最大的问题是 Web 有自己的事件循环。此全局事件循环用于任何外部 I/O 操作(包括 fetch()、计时器或本例中的 WebUSB),并在相应操作完成时调用事件或 Promise 处理程序。执行另一个嵌套的无限事件循环会阻止浏览器的事件循环继续进行,这意味着不仅界面会无响应,而且代码也永远不会收到它正在等待的 I/O 事件的通知。这通常会导致死锁,而我在演示中尝试使用 libusb 时也遇到了这种情况。网页卡住了。

与其他阻塞式 I/O 一样,为了将此类事件循环移植到 Web,开发者需要找到一种方法,在不阻塞主线程的情况下运行这些循环。一种方法是重构应用,在单独的线程中处理 I/O 事件,并将结果传回主线程。另一种方法是使用 Asyncify 暂停循环,并以非阻塞方式等待事件。

我不想对 libusb 或 gPhoto2 进行重大更改,并且我已经使用 Asyncify 进行 Promise 集成,因此我选择了这条路径。为了模拟 poll() 的阻塞变体,在初始概念验证中,我使用了如下所示的循环:

#ifdef __EMSCRIPTEN__
  // TODO: optimize this. Right now it will keep unwinding-rewinding the stack
  // on each short sleep until an event comes or the timeout expires.
  // We should probably create an actual separate thread that does signaling
  // or come up with a custom event mechanism to report events from
  // `usbi_signal_event` and process them here.
  double until_time = emscripten_get_now() + timeout_ms;
  do {
    // Emscripten `poll` ignores timeout param, but pass 0 explicitly just
    // in case.
    num_ready = poll(fds, nfds, 0);
    if (num_ready != 0) break;
    // Yield to the browser event loop to handle events.
    emscripten_sleep(0);
  } while (emscripten_get_now() < until_time);
#else
  num_ready = poll(fds, nfds, timeout_ms);
#endif

它的作用是:

  1. 调用 poll() 以检查后端是否已报告任何事件。如果存在,循环就会停止。否则,Emscripten 的 poll() 实现将立即返回 0。
  2. 调用 emscripten_sleep(0)。此函数在后台使用 Asyncify 和 setTimeout(),在此处用于将控制权交还给主浏览器事件循环。这样,浏览器就可以处理任何用户互动和 I/O 事件,包括 WebUSB。
  3. 检查指定的超时时间是否已过,如果未过,则继续循环。

正如注释中所述,这种方法并非最佳,因为即使还没有要处理的 USB 事件(大多数情况下都是如此),它也会一直使用 Asyncify 保存/恢复整个调用堆栈,而且在现代浏览器中,setTimeout() 本身的持续时间至少为 4 毫秒。不过,在概念验证中,它仍能很好地从单反相机生成 13-14 FPS 的直播。

后来,我决定利用浏览器事件系统来改进它。有几种方法可以进一步改进此实现,但目前我选择直接在全局对象上发出自定义事件,而不将其与特定 libusb 数据结构相关联。我通过以下基于 EM_ASYNC_JS 宏的等待和通知机制实现了这一点:

EM_JS(void, em_libusb_notify, (void), {
  dispatchEvent(new Event("em-libusb"));
});

EM_ASYNC_JS(int, em_libusb_wait, (int timeout), {
  let onEvent, timeoutId;

  try {
    return await new Promise(resolve => {
      onEvent = () => resolve(0);
      addEventListener('em-libusb', onEvent);

      timeoutId = setTimeout(resolve, timeout, -1);
    });
  } finally {
    removeEventListener('em-libusb', onEvent);
    clearTimeout(timeoutId);
  }
});

每当 libusb 尝试报告事件(例如数据传输完成)时,都会使用 em_libusb_notify() 函数:

void usbi_signal_event(usbi_event_t *event)
{
  uint64_t dummy = 1;
  ssize_t r;

  r = write(EVENT_WRITE_FD(event), &dummy, sizeof(dummy));
  if (r != sizeof(dummy))
    usbi_warn(NULL, "event write failed");
#ifdef __EMSCRIPTEN__
  em_libusb_notify();
#endif
}

同时,em_libusb_wait() 部分用于在收到 em-libusb 事件或超时后从 Asyncify 休眠中“唤醒”:

double until_time = emscripten_get_now() + timeout_ms;
for (;;) {
  // Emscripten `poll` ignores timeout param, but pass 0 explicitly just
  // in case.
  num_ready = poll(fds, nfds, 0);
  if (num_ready != 0) break;
  int timeout = until_time - emscripten_get_now();
  if (timeout <= 0) break;
  int result = em_libusb_wait(timeout);
  if (result != 0) break;
}

由于休眠和唤醒次数显著减少,此机制解决了早期基于 emscripten_sleep() 的实现的效率问题,并将 DSLR 演示吞吐量从 13-14 FPS 提高到稳定的 30+ FPS,这足以实现流畅的实时画面。

构建系统和首个测试

后端完成后,我必须将其添加到 Makefile.am 和 configure.ac。这里唯一值得关注的是 Emscripten 专用标志修改:

emscripten)
  AC_SUBST(EXEEXT, [.html])
  # Note: LT_LDFLAGS is not enough here because we need link flags for executable.
  AM_LDFLAGS="${AM_LDFLAGS} --bind -s ASYNCIFY -s ASSERTIONS -s ALLOW_MEMORY_GROWTH -s INVOKE_RUN=0 -s EXPORTED_RUNTIME_METHODS=['callMain']"
  ;;

首先,Unix 平台上的可执行文件通常没有文件扩展名。不过,Emscripten 会根据您请求的扩展程序生成不同的输出。我使用 AC_SUBST(EXEEXT, …) 将可执行文件扩展名更改为 .html,以便软件包中的任何可执行文件(测试和示例)都成为具有 Emscripten 默认 shell 的 HTML,该 shell 负责加载和实例化 JavaScript 和 WebAssembly。

其次,由于我使用的是 Embind 和 Asyncify,因此我需要启用这些功能 (--bind -s ASYNCIFY),并通过链接器参数允许动态内存增长 (-s ALLOW_MEMORY_GROWTH)。遗憾的是,库无法向链接器报告这些标志,因此每个使用此 libusb 端口的应用都必须在其 build 配置中添加相同的链接器标志。

最后,如前所述,WebUSB 要求通过用户手势完成设备枚举。libusb 示例和测试假设它们可以在启动时枚举设备,如果不进行更改,则会失败并报错。相反,我必须停用自动执行 (-s INVOKE_RUN=0) 并公开手动 callMain() 方法 (-s EXPORTED_RUNTIME_METHODS=...)。

完成所有这些操作后,我就可以使用静态 Web 服务器提供生成的文件,初始化 WebUSB,并在开发者工具的帮助下手动运行这些 HTML 可执行文件。

屏幕截图,显示了一个 Chrome 窗口,其中在本地提供的“testlibusb”页面上打开了开发者工具。DevTools 控制台正在评估 `navigator.usb.requestDevice({ filters: [] })`,这触发了权限提示,目前正在要求用户选择应与页面共享的 USB 设备。当前选择的是 ILCE-6600(一款 Sony 相机)。

下一步的屏幕截图,其中开发者工具仍处于打开状态。选择设备后,控制台评估了一个新表达式 `Module.callMain(['-v'])`,该表达式在详细模式下执行了 `testlibusb` 应用。输出会显示之前连接的 USB 摄像头的各种详细信息:制造商 Sony、产品 ILCE-6600、序列号、配置等。

虽然看起来很简单,但在将库移植到新平台时,首次生成有效输出的阶段总是令人兴奋!

使用端口

如上文所述,移植依赖于一些 Emscripten 功能,这些功能目前需要在应用的链接阶段启用。如果您想在自己的应用中使用此 libusb 端口,则需要执行以下操作:

  1. 下载最新的 libusb(作为 build 的一部分,以归档形式下载),或将其作为 git 子模块添加到项目中。
  2. 在 libusb 文件夹中运行 autoreconf -fiv。
  3. 运行 emconfigure ./configure –host=wasm32 –prefix=/some/installation/path,初始化项目以进行交叉编译,并设置要放置构建工件的路径。
  4. 运行 emmake make install。
  5. 将应用或更高级别的库指向在之前选择的路径下搜索 libusb。
  6. 将以下标志添加到应用的链接参数中:--bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH。

该库目前存在一些限制:

  • 不支持取消转移。这是 WebUSB 的一项限制,而该限制又源于 libusb 本身缺乏跨平台传输取消功能。
  • 不支持等时传输。按照现有传输模式的实现方式作为示例,添加该模式应该不难,但它也是一种较为罕见的模式,而且我没有任何设备可以测试它,所以目前我将其保留为不受支持。如果您确实有此类设备,并且希望为该库做出贡献,欢迎提交 PR!
  • 前面提到的跨平台限制。这些限制是由操作系统施加的,因此我们无能为力,只能要求用户替换驱动程序或权限。不过,如果您要移植 HID 或串行设备,可以按照 libusb 示例将其他一些库移植到其他 Fugu API。例如,您可以将 C 库 hidapi 移植到 WebHID,并完全避开与低级 USB 访问相关的这些问题。

总结

在本文中,我展示了如何借助 Emscripten、Asyncify 和 Fugu API,通过一些集成技巧将 libusb 等低级库移植到 Web。

移植此类必不可少且广泛使用的底层库尤其有益,因为这反过来又可以将更高级别的库甚至整个应用也移植到 Web。这让以前仅限于一两个平台的用户才能获得的体验,现在可以扩展到各种设备和操作系统,只需点击链接即可获得这些体验。

在下一篇博文中,我将逐步介绍构建 Web gPhoto2 演示所涉及的步骤,该演示不仅会检索设备信息,还会广泛使用 libusb 的传输功能。同时,我希望 libusb 示例能给您带来启发,让您尝试演示、体验库本身,甚至将另一个广泛使用的库也移植到某个 Fugu API。